Add graphics settings and stabilize city-view render binding caches

This commit is contained in:
chazmm committed 2026-09-06 16:20:54 +01:00
1 parent 83df8e62f4
commit d31f81f95e
16 files changed
+788 -42

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
/target/
/bin/
/logs/
/settings/
/assets/private/
/maps/private/
/maps/*.skate
Generated
+1
View File
@@ -4381,6 +4381,7 @@ version = "0.1.0"
dependencies = [
"bevy",
"half",
"serde",
"serde_json",
"skate-core",
"skate-data",
+1 -1
View File
@@ -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') + '"'
+7 -1
View File
@@ -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.
+1
View File
@@ -10,3 +10,4 @@ skate-core.workspace = true
skate-data.workspace = true
half = "=2.7.1"
serde_json.workspace = true
serde.workspace = true
+2
View File
@@ -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,
));
+420
View File
@@ -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<u32>,
status: String,
}
pub(crate) fn gameplay_active(menu: Option<Res<Menu>>) -> bool {
menu.is_none_or(|m| !m.open)
}
#[derive(Resource)]
struct SceneTarget(Handle<Image>);
#[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<crate::config::Config>,
mut images: ResMut<Assets<Image>>,
mut window: Single<&mut Window, With<PrimaryWindow>>,
cameras: Query<Entity, With<Camera3d>>,
adapter: Res<RenderAdapter>,
) {
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::<GraphicsSettings>(&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<T: PartialEq + Copy>(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<ButtonInput<KeyCode>>,
mut menu: ResMut<Menu>,
mut time: ResMut<Time<Virtual>>,
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
mut exit: MessageWriter<AppExit>,
) {
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<Menu>,
mut window: Single<&mut Window, With<PrimaryWindow>>,
target: Res<SceneTarget>,
mut images: ResMut<Assets<Image>>,
mut cameras: Query<&mut Msaa, With<Camera3d>>,
mut previous: Local<Option<GraphicsSettings>>,
) {
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<Menu>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
mut status: Single<&mut Text, With<StatusLabel>>,
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<Res<Menu>>, mut pacer: ResMut<FramePacer>) {
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);
}
}
+1 -6
View File
@@ -14,7 +14,7 @@ pub(crate) struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ControllerInput>()
.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<ControllerInput>) {
fn publish_actions(mut input: ResMut<ControllerInput>) {
input.publish_actions();
}
fn exit_requested(keys: Res<ButtonInput<KeyCode>>, mut exit: MessageWriter<AppExit>) {
if keys.just_pressed(KeyCode::Escape) {
exit.write(AppExit::Success);
}
}
+2
View File
@@ -11,6 +11,8 @@ mod physics;
mod skater_animation;
mod verification;
mod performance;
mod graphics_menu;
mod render_capacity;
mod world;
mod skate_world;
+77
View File
@@ -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::<MeshPipeline>),
);
}
}
}
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<RenderDevice>,
buffers: Option<ResMut<BatchedInstanceBuffers<MeshUniform, MeshInputUniform>>>,
) {
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::<MeshUniform>() 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);
}
}
}
+16 -2
View File
@@ -365,6 +365,14 @@ fn render_texture_ids(textures: &[skate_data::skate_map::Texture]) -> Vec<u32> {
fn render_material_ids(
materials: &[skate_data::skate_map::Material],
texture_ids: &[u32],
) -> Vec<usize> {
material_ids(materials, texture_ids, true)
}
fn material_ids(
materials: &[skate_data::skate_map::Material],
texture_ids: &[u32],
include_lightmap: bool,
) -> Vec<usize> {
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() {
+45
View File
@@ -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.
+11 -1
View File
@@ -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<RenderMesh>` 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:
+47
View File
@@ -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);
+6
View File
@@ -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);
+150 -31
View File
@@ -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<BindGroup>,
skinned: Option<MeshBindGroupPair>,
morph_targets: HashMap<AssetId<Mesh>, MeshBindGroupPair>,
lightmaps: HashMap<LightmapSlabIndex, BindGroup>,
lightmaps: std::sync::Arc<HashMap<LightmapSlabIndex, BindGroup>>,
}
/// Cached lightmap lookup table for one rendering phase.
#[derive(Default)]
pub struct PhaseLightmapCache {
key: Option<LightmapTableKey>,
groups: std::sync::Arc<HashMap<LightmapSlabIndex, BindGroup>>,
previous: Option<Box<PhaseLightmapCache>>,
}
/// Meshes that actually need morph bindings, refreshed when GPU assets change.
#[derive(Default)]
pub struct MorphMeshCache(Vec<AssetId<Mesh>>);
impl MorphMeshCache {
pub(crate) fn refresh(&mut self, meshes: &RenderAssets<RenderMesh>) -> &[AssetId<Mesh>] {
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<BufferSize>,
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<SkinUniforms>,
weights_uniform: Res<MorphUniforms>,
mut render_lightmaps: ResMut<RenderLightmaps>,
mut lightmap_tables: Local<TypeIdMap<PhaseLightmapCache>>,
mut morph_meshes: Local<MorphMeshCache>,
) {
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<Mesh>],
) -> 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
}