diff --git a/.gitignore b/.gitignore index d7a32326..158ad119 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target/ /bin/ /logs/ +/settings/ /assets/private/ /maps/private/ /maps/*.skate diff --git a/Cargo.lock b/Cargo.lock index 90c4f0b0..e2b5983d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4381,6 +4381,7 @@ version = "0.1.0" dependencies = [ "bevy", "half", + "serde", "serde_json", "skate-core", "skate-data", diff --git a/Launch.ps1 b/Launch.ps1 index 2d4592e1..d9770b27 100644 --- a/Launch.ps1 +++ b/Launch.ps1 @@ -6,7 +6,7 @@ try { if (-not (Test-Path -LiteralPath $executable)) { throw 'Game is not built. Run BUILD.bat first.' } New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot 'logs') -Force | Out-Null $log = Join-Path $PSScriptRoot ('logs/game-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') - Write-Host 'Starting imported skater project (Easy). Use your XInput controller; Esc exits.' + Write-Host 'Starting imported skater project (Easy). Use your XInput controller; Esc opens graphics/pause menu.' Write-Host "Log: $log" $errorLog = [System.IO.Path]::ChangeExtension($log, 'stderr.log') $assetArgument = '"' + (Join-Path $PSScriptRoot 'assets') + '"' diff --git a/README.md b/README.md index d97b57a0..0bebf822 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,13 @@ # Imported skater project Double-click **PLAY.bat** to launch. Use an XInput-compatible controller; -**Esc** exits. Startup selects the supplied **Easy** profile. +**Esc** opens the pause/graphics menu. Startup selects the supplied **Easy** profile. + +The menu supports window resolution (720p through 4K), internal render scale +(25–100%), supported MSAA levels (Off/2×/4×/8×), and an FPS cap (30–240 or +Unlimited). Click a row to cycle it, or use arrow keys and Enter. Changes apply +immediately and save to `settings/graphics.json`. Escape or Resume returns to +gameplay; Quit game exits. The menu stays at the window's native resolution. The standing/rolling **360 flip** now uses the supplied gesture files and ActionGraph/MotionGraph route, including right-stick crouch preparation. diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index aba17f63..f87cc2bd 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -10,3 +10,4 @@ skate-core.workspace = true skate-data.workspace = true half = "=2.7.1" serde_json.workspace = true +serde.workspace = true diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 64b4106e..c83a178f 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -94,6 +94,8 @@ pub(crate) fn build( animation::AnimationPlugin, world::WorldPlugin, camera::CameraPlugin, + crate::graphics_menu::GraphicsMenuPlugin, + crate::render_capacity::RenderCapacityPlugin, verification::VerificationPlugin, crate::performance::PerformancePlugin, )); diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs new file mode 100644 index 00000000..f9646e70 --- /dev/null +++ b/crates/skate-game/src/graphics_menu.rs @@ -0,0 +1,420 @@ +//! Native-resolution pause UI over a separately scaled 3D render target. +use bevy::{ + camera::RenderTarget, + image::ImageSampler, + prelude::*, + render::{ + render_resource::{Extent3d, TextureFormat}, + renderer::RenderAdapter, + }, + window::{PresentMode, PrimaryWindow}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; + +const RESOLUTIONS: &[(u32, u32)] = &[ + (1280, 720), + (1280, 800), + (1600, 900), + (1920, 1080), + (1920, 1200), + (2560, 1440), + (2560, 1600), + (3840, 2160), +]; +const SCALES: &[u32] = &[25, 50, 67, 75, 85, 100]; +const LIMITS: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +struct GraphicsSettings { + width: u32, + height: u32, + scale: u32, + samples: u32, + fps: u32, +} +impl Default for GraphicsSettings { + fn default() -> Self { + Self { + width: 1280, + height: 800, + scale: 100, + samples: 4, + fps: 0, + } + } +} +impl GraphicsSettings { + fn validated(mut self) -> Self { + if !RESOLUTIONS.contains(&(self.width, self.height)) { + (self.width, self.height) = (1280, 800); + } + if !SCALES.contains(&self.scale) { + self.scale = 100; + } + if ![1, 2, 4, 8].contains(&self.samples) { + self.samples = 4; + } + if !LIMITS.contains(&self.fps) { + self.fps = 0; + } + self + } + fn internal_size(&self, window: UVec2) -> UVec2 { + (window * self.scale / 100).max(UVec2::ONE) + } +} +#[derive(Resource)] +pub(crate) struct Menu { + open: bool, + selected: usize, + settings: GraphicsSettings, + path: PathBuf, + supported_msaa: Vec, + status: String, +} +pub(crate) fn gameplay_active(menu: Option>) -> bool { + menu.is_none_or(|m| !m.open) +} +#[derive(Resource)] +struct SceneTarget(Handle); +#[derive(Resource)] +struct FramePacer(Instant); +#[derive(Component)] +struct MenuRoot; +#[derive(Component)] +struct MenuRow(usize); +#[derive(Component)] +struct MenuLabel(usize); +#[derive(Component)] +struct StatusLabel; + +pub(crate) struct GraphicsMenuPlugin; +impl Plugin for GraphicsMenuPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(FramePacer(Instant::now())) + .add_systems(PostStartup, setup) + .add_systems(Update, (interact, apply, labels).chain()) + .add_systems(Last, pace); + } +} +fn setup( + mut commands: Commands, + config: Res, + mut images: ResMut>, + mut window: Single<&mut Window, With>, + cameras: Query>, + adapter: Res, +) { + let path = config + .asset_root + .parent() + .unwrap_or(&config.asset_root) + .join("settings/graphics.json"); + let settings = match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes).unwrap_or_else(|e| { + warn!("Graphics settings: {e}"); + GraphicsSettings::default() + }), + Err(_) => GraphicsSettings::default(), + } + .validated(); + let supported_msaa: Vec<_> = [1, 2, 4, 8] + .into_iter() + .filter(|&samples| { + [ + TextureFormat::Rgba16Float, + TextureFormat::Rgba8UnormSrgb, + TextureFormat::Depth32Float, + ] + .into_iter() + .all(|format| { + adapter + .get_texture_format_features(format) + .flags + .sample_count_supported(samples) + }) + }) + .collect(); + let mut settings = settings; + if !supported_msaa.contains(&settings.samples) { + settings.samples = 1; + } + window + .resolution + .set_physical_resolution(settings.width, settings.height); + window.present_mode = PresentMode::AutoNoVsync; + let size = settings.internal_size(window.physical_size()); + let mut image = Image::new_target_texture(size.x, size.y, TextureFormat::Rgba8UnormSrgb, None); + image.sampler = ImageSampler::linear(); + let target = images.add(image); + for camera in &cameras { + commands.entity(camera).insert(( + RenderTarget::Image(target.clone().into()), + msaa(settings.samples), + )); + // Render the world before the presentation camera consumes its image. + commands.entity(camera).insert(Camera { + order: -1, + ..default() + }); + } + let output = commands + .spawn((Camera2d, Msaa::Off, IsDefaultUiCamera)) + .id(); + commands.spawn(( + Node { + width: percent(100), + height: percent(100), + position_type: PositionType::Absolute, + ..default() + }, + ImageNode::new(target.clone()), + UiTargetCamera(output), + )); + commands.spawn((MenuRoot, UiTargetCamera(output), GlobalZIndex(10), Node { + display: Display::None, width:percent(100), height:percent(100), align_items:AlignItems::Center, + justify_content:JustifyContent::Center, position_type:PositionType::Absolute, ..default() + }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| { + root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(28)),flex_direction:FlexDirection::Column,row_gap:px(10),border_radius:BorderRadius::all(px(12)),..default() }, + BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { + panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); + panel.spawn((Text::new("GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); + for i in 0..6 { + panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(48),padding:UiRect::all(px(12)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, + BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { + row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:20.,..default()},TextColor(Color::WHITE))); + }); + } + panel.spawn((StatusLabel,Text::new(""),TextFont {font_size:15.,..default()},TextColor(Color::srgb(0.65,0.75,0.8)))); + panel.spawn((Text::new("Click to cycle | Up/Down select | Left/Right change\nEsc resume | Changes save automatically"),TextFont {font_size:14.,..default()},TextColor(Color::srgb(0.65,0.75,0.8)))); + }); + }); + commands.insert_resource(SceneTarget(target)); + commands.insert_resource(Menu { + open: false, + selected: 0, + settings, + path, + supported_msaa, + status: String::new(), + }); +} +fn msaa(samples: u32) -> Msaa { + match samples { + 2 => Msaa::Sample2, + 4 => Msaa::Sample4, + 8 => Msaa::Sample8, + _ => Msaa::Off, + } +} +fn cycle(values: &[T], value: T, direction: i32) -> T { + let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32; + values[(index + direction).rem_euclid(values.len() as i32) as usize] +} +fn interact( + keys: Res>, + mut menu: ResMut, + mut time: ResMut>, + buttons: Query<(&Interaction, &MenuRow), Changed>, + mut exit: MessageWriter, +) { + if keys.just_pressed(KeyCode::Escape) { + menu.open = !menu.open; + } + let mut action = None; + if menu.open { + if keys.just_pressed(KeyCode::ArrowUp) { + menu.selected = (menu.selected + 5) % 6; + } + if keys.just_pressed(KeyCode::ArrowDown) { + menu.selected = (menu.selected + 1) % 6; + } + if keys.just_pressed(KeyCode::ArrowLeft) { + action = Some((menu.selected, -1)); + } + if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) { + action = Some((menu.selected, 1)); + } + for (interaction, row) in &buttons { + if *interaction == Interaction::Pressed { + menu.selected = row.0; + action = Some((row.0, 1)); + } + } + } + if let Some((row, direction)) = action { + match row { + 0 => { + let size = cycle( + RESOLUTIONS, + (menu.settings.width, menu.settings.height), + direction, + ); + (menu.settings.width, menu.settings.height) = size; + } + 1 => menu.settings.scale = cycle(SCALES, menu.settings.scale, direction), + 2 => { + menu.settings.samples = + cycle(&menu.supported_msaa, menu.settings.samples, direction) + } + 3 => menu.settings.fps = cycle(LIMITS, menu.settings.fps, direction), + 4 => menu.open = false, + 5 => { + exit.write(AppExit::Success); + } + _ => {} + } + if row < 4 { + let save = (|| -> Result<(), String> { + std::fs::create_dir_all(menu.path.parent().unwrap()).map_err(|e| e.to_string())?; + std::fs::write( + &menu.path, + serde_json::to_vec_pretty(&menu.settings).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) + })(); + menu.status = match save { + Ok(()) => "Saved".into(), + Err(e) => format!("Could not save: {e}"), + }; + } + } + if menu.open { + time.pause(); + } else { + time.unpause(); + } +} +fn apply( + menu: Res, + mut window: Single<&mut Window, With>, + target: Res, + mut images: ResMut>, + mut cameras: Query<&mut Msaa, With>, + mut previous: Local>, +) { + if previous + .as_ref() + .is_none_or(|p| p.width != menu.settings.width || p.height != menu.settings.height) + { + window + .resolution + .set_physical_resolution(menu.settings.width, menu.settings.height); + } + if previous + .as_ref() + .is_none_or(|p| p.samples != menu.settings.samples) + { + for mut samples in &mut cameras { + *samples = msaa(menu.settings.samples); + } + } + let size = menu.settings.internal_size(window.physical_size()); + if let Some(image) = images.get(&target.0) { + if image.size() != size { + images.get_mut(&target.0).unwrap().resize(Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }); + } + } + *previous = Some(menu.settings.clone()); +} +fn labels( + menu: Res, + window: Single<&Window, With>, + mut root: Single<&mut Node, With>, + mut labels: Query<(&MenuLabel, &mut Text), Without>, + mut status: Single<&mut Text, With>, + mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>, +) { + root.display = if menu.open { + Display::Flex + } else { + Display::None + }; + if !menu.open { + return; + } + let s = &menu.settings; + let size = s.internal_size(window.physical_size()); + for (label, mut text) in &mut labels { + **text = match label.0 { + 0 => format!("Resolution {} x {}", s.width, s.height), + 1 => format!( + "Internal resolution {}% ({} x {})", + s.scale, size.x, size.y + ), + 2 => format!( + "MSAA {}", + if s.samples == 1 { + "Off".into() + } else { + format!("{}x", s.samples) + } + ), + 3 => format!( + "FPS limit {}", + if s.fps == 0 { + "Unlimited".into() + } else { + s.fps.to_string() + } + ), + 4 => "Resume".into(), + _ => "Quit game".into(), + }; + } + ***status = menu.status.clone(); + for (row, interaction, mut color) in &mut buttons { + color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered { + Color::srgb(0.10, 0.30, 0.34) + } else { + Color::srgb(0.08, 0.11, 0.15) + }; + } +} +fn pace(menu: Option>, mut pacer: ResMut) { + let Some(menu) = menu else { + return; + }; + if menu.settings.fps > 0 { + let period = Duration::from_secs_f64(1. / f64::from(menu.settings.fps)); + if let Some(wait) = period.checked_sub(pacer.0.elapsed()) { + std::thread::sleep(wait); + } + } + pacer.0 = Instant::now(); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn invalid_saved_values_fall_back() { + let settings: GraphicsSettings = + serde_json::from_str(r#"{"width":0,"height":999999,"scale":0,"samples":3,"fps":1}"#) + .unwrap(); + assert_eq!(settings.validated(), GraphicsSettings::default()); + } + #[test] + fn scaled_target_and_cycle_boundaries() { + let s = GraphicsSettings { + scale: 50, + ..default() + }; + assert_eq!( + s.internal_size(UVec2::new(1920, 1080)), + UVec2::new(960, 540) + ); + assert_eq!(s.internal_size(UVec2::ZERO), UVec2::ONE); + assert_eq!(cycle(LIMITS, 0, -1), 240); + assert_eq!(cycle(LIMITS, 240, 1), 0); + } +} diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index 91087583..051421e3 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -14,7 +14,7 @@ pub(crate) struct InputPlugin; impl Plugin for InputPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(PreUpdate, (poll_controllers, exit_requested)) + .add_systems(PreUpdate, poll_controllers.run_if(crate::graphics_menu::gameplay_active)) .add_systems(FixedUpdate, publish_actions.in_set(SimulationSet::Input)); } } @@ -38,8 +38,3 @@ fn poll_controllers(mut input: ResMut) { fn publish_actions(mut input: ResMut) { input.publish_actions(); } -fn exit_requested(keys: Res>, mut exit: MessageWriter) { - if keys.just_pressed(KeyCode::Escape) { - exit.write(AppExit::Success); - } -} diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 7d4ae50b..a0246c1d 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -11,6 +11,8 @@ mod physics; mod skater_animation; mod verification; mod performance; +mod graphics_menu; +mod render_capacity; mod world; mod skate_world; diff --git a/crates/skate-game/src/render_capacity.rs b/crates/skate-game/src/render_capacity.rs new file mode 100644 index 00000000..ee1143ed --- /dev/null +++ b/crates/skate-game/src/render_capacity.rs @@ -0,0 +1,77 @@ +//! Keep instance-buffer identities stable as views discover more geometry. +use bevy::{ + pbr::{MeshInputUniform, MeshPipeline, MeshUniform}, + prelude::*, + render::{ + Render, RenderApp, RenderSystems, + batching::gpu_preprocessing::{BatchedInstanceBuffers, write_batched_instance_buffers}, + renderer::RenderDevice, + }, +}; + +pub(crate) struct RenderCapacityPlugin; +impl Plugin for RenderCapacityPlugin { + fn build(&self, app: &mut App) { + if let Some(render) = app.get_sub_app_mut(RenderApp) { + render.add_systems( + Render, + reserve_instances + .in_set(RenderSystems::PrepareResourcesFlush) + .before(write_batched_instance_buffers::), + ); + } + } +} + +fn capacity(required: usize, limit: usize) -> usize { + if required == 0 { + return 0; + } + required + .checked_next_power_of_two() + .unwrap_or(required) + .min(limit) + .max(required) +} + +fn reserve_instances( + device: Res, + buffers: Option>>, +) { + let Some(mut buffers) = buffers else { + return; + }; + let limits = device.limits(); + let maximum = limits + .max_buffer_size + .min(u64::from(limits.max_storage_buffer_binding_size)) + / std::mem::size_of::() as u64; + for phase in buffers.phase_instance_buffers.values_mut() { + let required = phase.data_buffer.len(); + if required != 0 { + // Bevy's subsequent write_buffer preserves the logical count and + // initializes only the actual instances through GPU preprocessing. + phase + .data_buffer + .reserve(capacity(required, maximum as usize), &device); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn growth_is_bounded_and_never_smaller_than_required() { + assert_eq!(capacity(0, 100), 0); + assert_eq!(capacity(33, 100), 64); + assert_eq!(capacity(64, 100), 64); + assert_eq!(capacity(65, 100), 100); + // Leave unsupported requests to Bevy's validation; never truncate data. + assert_eq!(capacity(101, 100), 101); + for n in 1..10000 { + assert!(capacity(n, 10000) >= n); + assert!(capacity(n, 10000) <= 10000); + } + } +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index eba147d3..3c46d64e 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -365,6 +365,14 @@ fn render_texture_ids(textures: &[skate_data::skate_map::Texture]) -> Vec { fn render_material_ids( materials: &[skate_data::skate_map::Material], texture_ids: &[u32], +) -> Vec { + material_ids(materials, texture_ids, true) +} + +fn material_ids( + materials: &[skate_data::skate_map::Material], + texture_ids: &[u32], + include_lightmap: bool, ) -> Vec { let mut unique = HashMap::new(); materials @@ -376,7 +384,7 @@ fn render_material_ids( let textures = m.textures.map(|id| texture_ids[id as usize]); let key = [ textures[0], - textures[1], + if include_lightmap { textures[1] } else { 0 }, textures[2], textures[3], textures[4], @@ -477,6 +485,9 @@ pub(crate) fn spawn( ) }; let material_ids = render_material_ids(&map.materials, &texture_ids); + // Lightmaps belong to mesh entities, not StandardMaterial. Distinct baked + // lighting still needs separate geometry batches but can share a PBR material. + let pbr_ids = self::material_ids(&map.materials, &texture_ids, false); let groups = render_groups(&map.geometry, &material_ids); eprintln!( "SKATE_RENDER_BATCHES count={} triangles={}", @@ -540,7 +551,7 @@ pub(crate) fn spawn( warn!("SKATE material {} tangent generation: {error}", m.name); } } - let material = material_handles[material_index] + let material = material_handles[pbr_ids[material_index]] .get_or_insert_with(|| { let orm = texture(m.textures[3], 2); materials.add(StandardMaterial { @@ -683,6 +694,9 @@ mod tests { map.materials[1].roughness = map.materials[0].roughness; map.materials[1].textures[1] = 0; assert_eq!(render_material_ids(&map.materials, &texture_ids), [0, 1]); + assert_eq!(material_ids(&map.materials, &texture_ids, false), [0, 0]); + map.materials[1].indirect_strength += 1.; + assert_eq!(material_ids(&map.materials, &texture_ids, false), [0, 1]); } #[test] fn texture_sharing_preserves_pixels_dimensions_and_color_space() { diff --git a/docs/performance.md b/docs/performance.md index f9668234..35662fdc 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -97,3 +97,48 @@ baseline. Exploration can still encounter heavier views and loading/shader spike Validation: GPU cache regression test passed, game suite passed (43 passed, 21 existing ignored), and the staged executable completed a normal city startup capture with benchmark settings disabled. + +## City-facing dips at 1440p / 8x MSAA + +The next pass uses the user's saved 2560 x 1440, 100% internal resolution, +8x MSAA, unlimited FPS settings and the same rotating-camera benchmark. +Do not compare these results directly with the earlier stationary 1280 x 800 run. + +| Metric | Before | After | +| --- | ---: | ---: | +| Average FPS | 84.9 | 119.1 | +| Frame interval p95 | 28.9 ms | 23.5 ms | +| Render preparation per frame | 5.42 ms | 1.49 ms | + +Reports: `logs/city-baseline.json` and `logs/city-double.json`. Temporary direct +instrumentation measured roughly 3–4 ms in mesh binding preparation before the +final fix and about 0.05 ms after warmup. GPU telemetry during the optimized run +sampled roughly 24–39% usage. Heavy views and frame spikes remain. +The final clean build, with temporary tracing and GPU telemetry polling removed, +averaged 145.0 FPS with an 18.9 ms p95 frame interval (`logs/city-final.json`). +The 119–145 FPS range illustrates run/instrumentation variation; it is not a +guaranteed minimum while exploring the whole map. + +Bevy's `collect_buffers_for_phase` exchanges two buffer allocations every frame. +A single-table cache therefore misses every frame even with static resource +contents. The patch now retains both phase tables, checks resource identities +and lightmap revisions, and shares unchanged tables without visiting every slab. +The preceding one-table trial did not materially improve the sweep and is not +the implementation shipped here. + +The game also reserves geometrically growing instance-buffer capacity before +Bevy writes it, bounded by device limits. This reduces reallocations when visible +counts increase; it does not change logical counts or dispatch extra instances. +Mesh morph-target lookup is refreshed on GPU asset changes, and identical PBR +materials can share handles while each mesh retains its own baked lightmap. + +No triangles, collision detail, lighting, or draw distance were removed. LODs +remain a possible next step for distant geometry, particularly hierarchical LODs +that also reduce the number of draws. They do not address the buffer-cache miss +fixed here. GPU occlusion culling is also relevant to a dense city, but the current +engine's version is experimental: [Bevy's migration notes](https://bevy.org/learn/migration-guides/0-18-to-0-19/#occlusion-culling-is-no-longer-experimental) +describe correctness fixes in 0.19. It was not enabled as an unverified workaround. + +Validation: the explicit GPU regression test and game suite (46 passed, +21 existing ignored) passed. The staged build completed the normal San Vanelona +startup capture at the user's saved settings, with benchmark overrides disabled. diff --git a/vendor/README.md b/vendor/README.md index 7cc07227..a749668e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -19,6 +19,15 @@ Local changes are restricted to: - `src/render/mesh_bindings.rs`: reuse or create the binding through that cache. The lightmap slab parameter is now mutable. - `src/lightmap/cache_tests.rs`: explicit GPU regression test. +- `src/render/mesh.rs`: retain complete lookup tables for both alternating + instance buffers per phase. Keys include buffer identity, offset, size, layout, + and the lightmap binding revision. A slab creation, texture upload, or removal + changes that revision. Tables are shared by `Arc`, so a cache hit avoids + rebuilding and cloning thousands of individual entries. Keep only two tables + per phase, and discard caches for phases that disappear. +- `src/render/mesh.rs`: cache the IDs of meshes with morph targets when + `RenderAssets` changes. Each phase still rebuilds its dynamic morph + bindings from the current GPU resources, but skips scanning static city meshes. Writing new instance data into an existing buffer does not require rebuilding a bind group. Changing the buffer, bound range, layout, or texture does. The patch @@ -33,7 +42,8 @@ cargo test -p bevy_pbr --lib cache_tracks_binding_identity_and_texture_replaceme It covers cloned versus replaced buffers, changed binding ranges/layouts, writes to an existing buffer, texture insertion/removal, bounded eviction, and actual -bind-group construction/reuse. Regular game tests and a city screenshot/benchmark +bind-group construction/reuse, alternating-buffer table reuse, table revisions, +and morph-target additions/replacements/removals. Regular game tests and a city screenshot/benchmark also exercise the patched renderer. Upstream references reviewed September 6, 2026: diff --git a/vendor/bevy_pbr/src/lightmap/cache_tests.rs b/vendor/bevy_pbr/src/lightmap/cache_tests.rs index f26299b1..87b97368 100644 --- a/vendor/bevy_pbr/src/lightmap/cache_tests.rs +++ b/vendor/bevy_pbr/src/lightmap/cache_tests.rs @@ -58,6 +58,22 @@ fn cache_tracks_binding_identity_and_texture_replacement() { let layout = device.create_bind_group_layout(None, &[]); let different_layout = device.create_bind_group_layout(None, &[]); let group = device.create_bind_group(None, &layout, &[]); + let mut table = crate::render::mesh::PhaseLightmapCache::default(); + table.remember(&binding(&a, 0, 256), layout.id(), 7); + assert!(table.matches(&binding(&a.clone(), 0, 256), layout.id(), 7)); + assert!(!table.matches(&binding(&a, 0, 256), layout.id(), 8)); + assert!(!table.matches(&binding(&b, 0, 256), layout.id(), 7)); + assert!(!table.matches(&binding(&a, 256, 256), layout.id(), 7)); + assert!(!table.matches(&binding(&a, 0, 512), layout.id(), 7)); + assert!(!table.matches(&binding(&a, 0, 256), different_layout.id(), 7)); + table.remember(&binding(&b, 0, 256), layout.id(), 7); + // Bevy exchanges its per-phase buffers every frame. Both allocations must + // remain reusable, rather than evicting one another on every camera turn. + for _ in 0..10 { + assert!(table.select(&binding(&a, 0, 256), layout.id(), 7)); + assert!(table.select(&binding(&b, 0, 256), layout.id(), 7)); + } + assert!(!table.select(&binding(&a, 0, 256), layout.id(), 8)); let extent = Extent3d { width: 1, height: 1, @@ -91,6 +107,37 @@ fn cache_tracks_binding_identity_and_texture_replacement() { cube_array: image.clone(), d3: image.clone(), }; + // Adding/removing morph targets must refresh the compact mesh list. + let source = bevy_mesh::Mesh::new( + PrimitiveTopology::TriangleList, + bevy_asset::RenderAssetUsages::default(), + ); + let mut layouts = bevy_mesh::MeshVertexBufferLayouts::default(); + let mesh_layout = source.get_mesh_vertex_buffer_layout(&mut layouts); + let mesh = |morph| bevy_render::mesh::RenderMesh { + vertex_count: 0, + morph_targets: morph, + buffer_info: bevy_render::mesh::RenderMeshBufferInfo::NonIndexed, + key_bits: bevy_mesh::BaseMeshPipelineKey::empty(), + layout: mesh_layout.clone(), + }; + let static_id = AssetId::Uuid { + uuid: bevy_asset::uuid::Uuid::from_u128(1), + }; + let morph_id = AssetId::Uuid { + uuid: bevy_asset::uuid::Uuid::from_u128(2), + }; + let mut render_meshes = RenderAssets::default(); + render_meshes.insert(static_id, mesh(None)); + render_meshes.insert(morph_id, mesh(Some(image.texture_view.clone()))); + let mut morph_cache = crate::render::mesh::MorphMeshCache::default(); + assert_eq!(morph_cache.refresh(&render_meshes), &[morph_id]); + render_meshes.insert(morph_id, mesh(None)); + assert!(morph_cache.refresh(&render_meshes).is_empty()); + render_meshes.insert(static_id, mesh(Some(image.texture_view.clone()))); + assert_eq!(morph_cache.refresh(&render_meshes), &[static_id]); + render_meshes.remove(static_id); + assert!(morph_cache.refresh(&render_meshes).is_empty()); // Exercise both single-texture and binding-array slab storage. for bindless in [false, true] { let mut slab = LightmapSlab::new(&fallback, bindless); diff --git a/vendor/bevy_pbr/src/lightmap/mod.rs b/vendor/bevy_pbr/src/lightmap/mod.rs index ede5763f..3f4b01f7 100644 --- a/vendor/bevy_pbr/src/lightmap/mod.rs +++ b/vendor/bevy_pbr/src/lightmap/mod.rs @@ -145,6 +145,8 @@ pub(crate) struct RenderLightmap { /// system. #[derive(Resource)] pub struct RenderLightmaps { + /// Changes whenever slab bindings are added, removed, or replaced. + pub(crate) binding_revision: u64, /// The mapping from every lightmapped entity to its lightmap info. /// /// Entities without lightmaps, or for which the mesh or lightmap isn't @@ -301,6 +303,7 @@ fn extract_lightmaps( return true; }; render_lightmaps.slabs[usize::from(slab_index)].insert(slot_index, gpu_image.clone()); + render_lightmaps.binding_revision = render_lightmaps.binding_revision.wrapping_add(1); false }); } @@ -357,6 +360,7 @@ pub fn init_render_lightmaps( let bindless_supported = binding_arrays_are_usable(&render_device, &render_adapter); commands.insert_resource(RenderLightmaps { + binding_revision: 0, render_lightmaps: default(), slabs: vec![], free_slabs: FixedBitSet::new(), @@ -369,6 +373,7 @@ impl RenderLightmaps { /// Creates a new slab, appends it to the end of the list, and returns its /// slab index. fn create_slab(&mut self, fallback_images: &FallbackImage) -> LightmapSlabIndex { + self.binding_revision = self.binding_revision.wrapping_add(1); let slab_index = LightmapSlabIndex::from(self.slabs.len()); self.free_slabs.grow_and_insert(slab_index.into()); self.slabs @@ -401,6 +406,7 @@ impl RenderLightmaps { slab_index: LightmapSlabIndex, slot_index: LightmapSlotIndex, ) { + self.binding_revision = self.binding_revision.wrapping_add(1); let slab = &mut self.slabs[usize::from(slab_index)]; slab.remove(fallback_images, slot_index); diff --git a/vendor/bevy_pbr/src/render/mesh.rs b/vendor/bevy_pbr/src/render/mesh.rs index e0243899..3a254197 100644 --- a/vendor/bevy_pbr/src/render/mesh.rs +++ b/vendor/bevy_pbr/src/render/mesh.rs @@ -2,16 +2,16 @@ use crate::{ material_bind_groups::{MaterialBindGroupIndex, MaterialBindGroupSlot}, resources::write_atmosphere_buffer, }; -use bevy_asset::{embedded_asset, load_embedded_asset, AssetId}; +use bevy_asset::{AssetId, embedded_asset, load_embedded_asset}; use bevy_camera::{ + Camera, Camera3d, Projection, primitives::Aabb, visibility::{NoFrustumCulling, RenderLayers, ViewVisibility, VisibilityRange}, - Camera, Camera3d, Projection, }; use bevy_core_pipeline::{ - core_3d::{AlphaMask3d, Opaque3d, Transmissive3d, Transparent3d, CORE_3D_DEPTH_FORMAT}, + core_3d::{AlphaMask3d, CORE_3D_DEPTH_FORMAT, Opaque3d, Transmissive3d, Transparent3d}, deferred::{AlphaMask3dDeferred, Opaque3dDeferred}, - oit::{prepare_oit_buffers, OrderIndependentTransparencySettingsOffset}, + oit::{OrderIndependentTransparencySettingsOffset, prepare_oit_buffers}, prepass::MotionVectorPrepass, }; use bevy_derive::{Deref, DerefMut}; @@ -21,7 +21,7 @@ use bevy_ecs::{ prelude::*, query::{QueryData, ROQueryItem}, relationship::RelationshipSourceCollection, - system::{lifetimeless::*, SystemParamItem, SystemState}, + system::{SystemParamItem, SystemState, lifetimeless::*}, }; use bevy_image::{BevyDefault, ImageSampler, TextureFormatPixelInfo}; use bevy_light::{ @@ -30,20 +30,22 @@ use bevy_light::{ }; use bevy_math::{Affine3, Rect, UVec2, Vec3, Vec4}; use bevy_mesh::{ - skinning::SkinnedMesh, BaseMeshPipelineKey, Mesh, Mesh3d, MeshTag, MeshVertexBufferLayoutRef, - VertexAttributeDescriptor, + BaseMeshPipelineKey, Mesh, Mesh3d, MeshTag, MeshVertexBufferLayoutRef, + VertexAttributeDescriptor, skinning::SkinnedMesh, }; -use bevy_platform::collections::{hash_map::Entry, HashMap}; +use bevy_platform::collections::{HashMap, hash_map::Entry}; use bevy_render::{ + Extract, batching::{ + GetBatchData, GetFullBatchData, NoAutomaticBatching, gpu_preprocessing::{ self, GpuPreprocessingSupport, IndirectBatchSet, IndirectParametersBuffers, IndirectParametersCpuMetadata, IndirectParametersIndexed, IndirectParametersNonIndexed, InstanceInputUniformBuffer, UntypedPhaseIndirectParametersBuffers, }, - no_gpu_preprocessing, GetBatchData, GetFullBatchData, NoAutomaticBatching, + no_gpu_preprocessing, }, - mesh::{allocator::MeshAllocator, RenderMesh, RenderMeshBufferInfo}, + mesh::{RenderMesh, RenderMeshBufferInfo, allocator::MeshAllocator}, render_asset::RenderAssets, render_phase::{ BinnedRenderPhasePlugin, InputUniformIndex, PhaseItem, PhaseItemExtraIndex, RenderCommand, @@ -57,11 +59,10 @@ use bevy_render::{ self, NoIndirectDrawing, RenderVisibilityRanges, RetainedViewEntity, ViewTarget, ViewUniformOffset, }, - Extract, }; -use bevy_shader::{load_shader_library, Shader, ShaderDefVal, ShaderSettings}; +use bevy_shader::{Shader, ShaderDefVal, ShaderSettings, load_shader_library}; use bevy_transform::components::GlobalTransform; -use bevy_utils::{default, Parallel, TypeIdMap}; +use bevy_utils::{Parallel, TypeIdMap, default}; use core::any::TypeId; use core::mem::size_of; use material_bind_groups::MaterialBindingId; @@ -71,8 +72,8 @@ use self::irradiance_volume::IRRADIANCE_VOLUMES_ARE_USABLE; use crate::{ render::{ morph::{ - extract_morphs, no_automatic_morph_batching, prepare_morphs, MorphIndices, - MorphUniforms, + MorphIndices, MorphUniforms, extract_morphs, no_automatic_morph_batching, + prepare_morphs, }, skin::no_automatic_skin_batching, }, @@ -83,15 +84,15 @@ use bevy_core_pipeline::prepass::{DeferredPrepass, DepthPrepass, NormalPrepass}; use bevy_core_pipeline::tonemapping::{DebandDither, Tonemapping}; use bevy_ecs::change_detection::Tick; use bevy_ecs::system::SystemChangeTick; +use bevy_render::RenderSystems::PrepareAssets; use bevy_render::camera::TemporalJitter; use bevy_render::prelude::Msaa; use bevy_render::sync_world::{MainEntity, MainEntityHashMap}; use bevy_render::view::ExtractedView; -use bevy_render::RenderSystems::PrepareAssets; use bytemuck::{Pod, Zeroable}; use nonmax::{NonMaxU16, NonMaxU32}; -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use static_assertions::const_assert_eq; /// Provides support for rendering 3D meshes. @@ -2749,7 +2750,97 @@ pub struct MeshPhaseBindGroups { model_only: Option, skinned: Option, morph_targets: HashMap, MeshBindGroupPair>, - lightmaps: HashMap, + lightmaps: std::sync::Arc>, +} + +/// Cached lightmap lookup table for one rendering phase. +#[derive(Default)] +pub struct PhaseLightmapCache { + key: Option, + groups: std::sync::Arc>, + previous: Option>, +} + +/// Meshes that actually need morph bindings, refreshed when GPU assets change. +#[derive(Default)] +pub struct MorphMeshCache(Vec>); +impl MorphMeshCache { + pub(crate) fn refresh(&mut self, meshes: &RenderAssets) -> &[AssetId] { + self.0 = meshes + .iter() + .filter_map(|(id, mesh)| mesh.morph_targets.as_ref().map(|_| id)) + .collect(); + &self.0 + } +} +struct LightmapTableKey { + buffer: Buffer, + offset: u64, + size: Option, + layout: BindGroupLayoutId, + revision: u64, +} +impl PhaseLightmapCache { + pub(crate) fn select( + &mut self, + model: &BindingResource, + layout: BindGroupLayoutId, + revision: u64, + ) -> bool { + if self.matches(model, layout, revision) { + return true; + } + if self + .previous + .as_ref() + .is_some_and(|previous| previous.matches(model, layout, revision)) + { + let mut previous = self.previous.take().unwrap(); + core::mem::swap(self, &mut previous); + self.previous = Some(previous); + return true; + } + false + } + pub(crate) fn matches( + &self, + model: &BindingResource, + layout: BindGroupLayoutId, + revision: u64, + ) -> bool { + let (Some(key), BindingResource::Buffer(model)) = (&self.key, model) else { + return false; + }; + &*key.buffer == model.buffer + && key.offset == model.offset + && key.size == model.size + && key.layout == layout + && key.revision == revision + } + pub(crate) fn remember( + &mut self, + model: &BindingResource, + layout: BindGroupLayoutId, + revision: u64, + ) { + if self.key.is_some() { + self.previous = Some(Box::new(PhaseLightmapCache { + key: self.key.take(), + groups: core::mem::take(&mut self.groups), + previous: None, + })); + } + self.key = match model { + BindingResource::Buffer(model) => Some(LightmapTableKey { + buffer: Buffer::from(model.buffer.clone()), + offset: model.offset, + size: model.size, + layout, + revision, + }), + _ => None, + }; + } } pub struct MeshBindGroupPair { @@ -2773,7 +2864,7 @@ impl MeshPhaseBindGroups { self.model_only = None; self.skinned = None; self.morph_targets.clear(); - self.lightmaps.clear(); + self.lightmaps = default(); } /// Get the `BindGroup` for `RenderMesh` with given `handle_id` and lightmap /// key `lightmap`. @@ -2826,7 +2917,12 @@ pub fn prepare_mesh_bind_groups( skins_uniform: Res, weights_uniform: Res, mut render_lightmaps: ResMut, + mut lightmap_tables: Local>, + mut morph_meshes: Local, ) { + if meshes.is_changed() { + morph_meshes.refresh(&meshes); + } // CPU mesh preprocessing path. if let Some(cpu_batched_instance_buffer) = cpu_batched_instance_buffer && let Some(instance_data_binding) = cpu_batched_instance_buffer @@ -2843,6 +2939,8 @@ pub fn prepare_mesh_bind_groups( &skins_uniform, &weights_uniform, &mut render_lightmaps, + lightmap_tables.entry(TypeId::of::<()>()).or_default(), + &morph_meshes.0, ); commands.insert_resource(MeshBindGroups::CpuPreprocessing( @@ -2874,11 +2972,18 @@ pub fn prepare_mesh_bind_groups( &skins_uniform, &weights_uniform, &mut render_lightmaps, + lightmap_tables.entry(*phase_type_id).or_default(), + &morph_meshes.0, ); gpu_preprocessing_mesh_bind_groups.insert(*phase_type_id, mesh_phase_bind_groups); } + lightmap_tables.retain(|phase, _| { + gpu_batched_instance_buffers + .phase_instance_buffers + .contains_key(phase) + }); commands.insert_resource(MeshBindGroups::GpuPreprocessing( gpu_preprocessing_mesh_bind_groups, )); @@ -2895,6 +3000,8 @@ fn prepare_mesh_bind_groups_for_phase( skins_uniform: &SkinUniforms, weights_uniform: &MorphUniforms, render_lightmaps: &mut RenderLightmaps, + lightmap_table: &mut PhaseLightmapCache, + morph_meshes: &[AssetId], ) -> MeshPhaseBindGroups { let layouts = &mesh_pipeline.mesh_layouts; @@ -2922,7 +3029,10 @@ fn prepare_mesh_bind_groups_for_phase( // group. if let Some(weights) = weights_uniform.current_buffer.buffer() { let prev_weights = weights_uniform.prev_buffer.buffer().unwrap_or(weights); - for (id, gpu_mesh) in meshes.iter() { + for (id, gpu_mesh) in morph_meshes + .iter() + .filter_map(|&id| meshes.get(id).map(|mesh| (id, mesh))) + { if let Some(targets) = gpu_mesh.morph_targets.as_ref() { let bind_group_pair = if is_skinned(&gpu_mesh.layout) { let prev_skin = &skins_uniform.prev_buffer; @@ -2972,18 +3082,27 @@ fn prepare_mesh_bind_groups_for_phase( // Create lightmap bindgroups. There will be one bindgroup for each slab. let bindless_supported = render_lightmaps.bindless_supported; - for (lightmap_slab_id, lightmap_slab) in render_lightmaps.slabs.iter_mut().enumerate() { - groups.lightmaps.insert( - LightmapSlabIndex(NonMaxU32::new(lightmap_slab_id as u32).unwrap()), - layouts.lightmapped( - render_device, - pipeline_cache, - &model, - lightmap_slab, - bindless_supported, - ), - ); + let layout = pipeline_cache + .get_bind_group_layout(&layouts.lightmapped) + .id(); + if !lightmap_table.select(&model, layout, render_lightmaps.binding_revision) { + let mut bindings = HashMap::default(); + for (lightmap_slab_id, lightmap_slab) in render_lightmaps.slabs.iter_mut().enumerate() { + bindings.insert( + LightmapSlabIndex(NonMaxU32::new(lightmap_slab_id as u32).unwrap()), + layouts.lightmapped( + render_device, + pipeline_cache, + &model, + lightmap_slab, + bindless_supported, + ), + ); + } + lightmap_table.remember(&model, layout, render_lightmaps.binding_revision); + lightmap_table.groups = std::sync::Arc::new(bindings); } + groups.lightmaps = lightmap_table.groups.clone(); groups }