diff --git a/crates/skate-data/src/skate_map.rs b/crates/skate-data/src/skate_map.rs index e295dc04..b09d2b34 100644 --- a/crates/skate-data/src/skate_map.rs +++ b/crates/skate-data/src/skate_map.rs @@ -1,8 +1,9 @@ -//! Little-endian SKATE01..14 reader. Layout follows the supplied +//! Little-endian SKATE01..15 reader. Layout follows the supplied //! SK8R15/Source/tools/blender_owned_map/SKATE_FORMAT.md and //! owned/world/src/owned_map_package.cpp. No geometry or physics is inferred. use std::{io::Read, path::Path, time::Instant}; mod texture_decode; +mod storage_v15; #[derive(Debug, PartialEq)] pub struct SkateMap { @@ -314,6 +315,7 @@ impl<'a> Reader<'a> { } /// Borrow compressed payloads while scanning; decompression does not mutate /// the package or depend on any other texture. +#[derive(Clone, Copy)] struct StoredBlock<'a> { expected: usize, method: u32, bytes: &'a [u8] } impl StoredBlock<'_> { fn decode(&self) -> Result, String> { @@ -342,6 +344,7 @@ impl StoredBlock<'_> { .map_err(|e| format!("SKATE Zstandard: {e}"))?; result } + 3..=10 => return storage_v15::decode(method, bytes, expected), _ => return Err(format!("Unsupported SKATE storage method {method}")), }; if decoded.len() != expected { @@ -395,8 +398,8 @@ impl SkateMap { return Err("Invalid SKATE magic/version".into()); } let version = (magic[5] - b'0') * 10 + magic[6] - b'0'; - if !(1..=14).contains(&version) { - return Err("Unsupported SKATE version: reader supports 01 through 14; newer packages require an update".into()); + if !(1..=15).contains(&version) { + return Err("Unsupported SKATE version: reader supports 01 through 15; newer packages require an update".into()); } if r.u()? != 0x12345678 { return Err("Invalid SKATE endian marker".into()); @@ -428,6 +431,15 @@ impl SkateMap { for c in &mut counts[..n] { *c = r.u()?; } + let material_bytes = if version >= 15 { + let size = r.u()? as usize; + Some(r.stored(size)?) + } else { None }; + let mut package_reader = None; + if let Some(bytes) = &material_bytes { + package_reader = Some(r); + r = Reader { bytes, at: 0 }; + } r.check_count(counts[0], if version >= 2 { 80 } else { 48 })?; let mut materials = Vec::new(); for _ in 0..counts[0] { @@ -512,10 +524,14 @@ impl SkateMap { } materials.push(m); } + if let Some(package) = package_reader { + if r.at != r.bytes.len() { return Err("SKATE material block has trailing bytes".into()); } + r = package; + } let texture_started = Instant::now(); r.check_count(counts[1], 20)?; let mut textures = Vec::new(); - let mut texture_blocks = Vec::new(); + let mut texture_blocks: Vec> = Vec::new(); for _ in 0..counts[1] { let name = r.string()?; let width = r.u()?; @@ -534,6 +550,13 @@ impl SkateMap { } StoredBlock { expected, method: 0, bytes: r.take(bytes)? } }; + let block = if version >= 15 && block.method == 11 { + let index = u32::from_le_bytes(block.bytes.try_into() + .map_err(|_| "Invalid SKATE texture reference size")?) as usize; + let source = *texture_blocks.get(index).ok_or("Invalid SKATE forward texture reference")?; + if source.expected != expected { return Err("SKATE texture reference size mismatch".into()); } + source + } else { block }; texture_blocks.push(block); textures.push(Texture { name, diff --git a/crates/skate-data/src/skate_map/storage_v15.rs b/crates/skate-data/src/skate_map/storage_v15.rs new file mode 100644 index 00000000..d1f50452 --- /dev/null +++ b/crates/skate-data/src/skate_map/storage_v15.rs @@ -0,0 +1,152 @@ +//! Reversible SKATE15 storage transforms, matching the bundled exporter. +use super::{Reader, StoredBlock}; + +pub(super) fn decode(method: u32, bytes: &[u8], expected: usize) -> Result, String> { + let mut input = Reader { bytes, at: 0 }; + let size = input.u()? as usize; + // Transformed records are at most a small multiple of their decoded size. + // Bound both lengths before allocating or decompressing untrusted data. + if size > expected.saturating_mul(3).saturating_add(16) || size > 2_147_483_648 { + return Err("SKATE transformed block exceeds size limit".into()); + } + let data = StoredBlock { + expected: size, method: if method <= 6 { 2 } else { 1 }, + bytes: &bytes[4..], + }.decode()?; + let mut source = Reader { bytes: &data, at: 0 }; + let mut result = Vec::new(); + match method { + 3 | 7 => { + let width = source.u()? as usize; + let height = source.u()? as usize; + let row = width.checked_mul(4).ok_or("SKATE filtered width overflow")?; + if row.checked_mul(height) != Some(expected) || width > 16384 || height > 16384 { + return Err("SKATE filtered dimensions mismatch".into()); + } + result.resize(expected, 0u8); + for y in 0..height { + let filter = source.take(1)?[0]; + if filter > 4 { return Err("Invalid SKATE RGBA filter".into()); } + for (x, &value) in source.take(row)?.iter().enumerate() { + let at = y * row + x; + let left = if x >= 4 { result[at - 4] as i32 } else { 0 }; + let above = if y > 0 { result[at - row] as i32 } else { 0 }; + let upper_left = if x >= 4 && y > 0 { result[at - row - 4] as i32 } else { 0 }; + let predictor = match filter { + 1 => left, 2 => above, 3 => (left + above) / 2, + 4 => { + let p = left + above - upper_left; + let a = (p - left).abs(); + let b = (p - above).abs(); + let c = (p - upper_left).abs(); + if a <= b && a <= c { left } else if b <= c { above } else { upper_left } + } + _ => 0, + }; + result[at] = value.wrapping_add(predictor as u8); + } + } + } + 4 | 8 => { + if expected % 56 != 0 || data.len() != expected { return Err("Invalid SKATE vertex streams".into()); } + result.resize(expected, 0); + for (offset, length) in [(0,12), (12,12), (24,8), (32,8), (40,4), (44,8), (52,4)] { + for vertex in 0..expected / 56 { + let at = vertex * 56 + offset; + result[at..at + length].copy_from_slice(source.take(length)?); + } + } + } + 5 | 9 => { + if expected % 4 != 0 { return Err("Invalid SKATE index block size".into()); } + let mut previous = 0i64; + for _ in 0..expected / 4 { + let mut zigzag = 0u64; + // A delta between u32 indices requires at most 33 bits. + for shift in (0..=28).step_by(7) { + let byte = source.take(1)?[0]; + if shift == 28 && byte > 31 { return Err("Invalid SKATE index varint".into()); } + zigzag |= u64::from(byte & 127) << shift; + if byte & 128 == 0 { break; } + } + let current = previous + ((zigzag >> 1) as i64 ^ -((zigzag & 1) as i64)); + let current = u32::try_from(current).map_err(|_| "SKATE index delta overflow")?; + result.extend(current.to_le_bytes()); + previous = i64::from(current); + } + } + 6 | 10 => { + if expected % 48 != 0 { return Err("Invalid SKATE collision block size".into()); } + let count = source.u()? as usize; + let vertices = source.take(count.checked_mul(12).ok_or("SKATE collision vertex overflow")?)?; + for _ in 0..expected / 48 { + for _ in 0..3 { + let index = source.u()? as usize; + if index >= count { return Err("Invalid SKATE collision vertex reference".into()); } + result.extend_from_slice(&vertices[index * 12..index * 12 + 12]); + } + result.extend_from_slice(source.take(12)?); + } + } + _ => return Err("Invalid SKATE storage transform".into()), + } + if source.at != data.len() || result.len() != expected { return Err("SKATE transformed size mismatch or trailing data".into()); } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + fn unpack(method: u32, data: &[u8], expected: usize) -> Result, String> { + let mut bytes = (data.len() as u32).to_le_bytes().to_vec(); + if method <= 6 { bytes.extend(zstd::stream::encode_all(data, 1).unwrap()); } + else { + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(data).unwrap(); + bytes.extend(encoder.finish().unwrap()); + } + decode(method, &bytes, expected) + } + #[test] + fn both_codecs_restore_vertices_indices_collision_and_rgba_exactly() { + let vertices: Vec = (0..112).collect(); + let mut soa = Vec::new(); + for (offset, len) in [(0,12), (12,12), (24,8), (32,8), (40,4), (44,8), (52,4)] { + for v in 0..2 { soa.extend_from_slice(&vertices[v * 56 + offset..v * 56 + offset + len]); } + } + let mut collision = 3u32.to_le_bytes().to_vec(); + collision.extend(0u8..36); + for index in [2u32, 0, 1] { collision.extend(index.to_le_bytes()); } + collision.extend(36u8..48); + let expected_collision: Vec = (24..36).chain(0..24).chain(36..48).collect(); + let mut rgba = 2u32.to_le_bytes().to_vec(); + rgba.extend(5u32.to_le_bytes()); + for filter in 0..5u8 { + rgba.push(filter); + // Constant RGBA rows exercise all five PNG-style predictors. + rgba.extend(if filter == 0 { [10; 8] } else if filter == 1 { [10,10,10,10,0,0,0,0] } + else if filter == 3 { [5,5,5,5,0,0,0,0] } else { [0; 8] }); + } + for shift in [0, 4] { + assert_eq!(unpack(3 + shift, &rgba, 40).unwrap(), vec![10; 40]); + assert_eq!(unpack(4 + shift, &soa, 112).unwrap(), vertices); + assert_eq!(unpack(5 + shift, &[0, 4, 1], 12).unwrap(), [0u32,2,1].into_iter().flat_map(u32::to_le_bytes).collect::>()); + assert_eq!(unpack(6 + shift, &collision, 48).unwrap(), expected_collision); + } + } + #[test] + fn malformed_transforms_fail_without_panics() { + for method in 3..=10 { + assert!(unpack(method, &[], 48).is_err()); + assert!(decode(method, &[255; 4], 48).is_err()); + } + assert!(unpack(9, &[1], 4).is_err()); // negative first index + assert!(unpack(9, &[128; 5], 4).is_err()); + assert!(unpack(9, &[0, 0], 4).is_err()); // trailing varint + assert!(unpack(8, &[0; 56], 55).is_err()); + let mut bad_collision = 0u32.to_le_bytes().to_vec(); + bad_collision.extend([0; 24]); + assert!(unpack(10, &bad_collision, 48).is_err()); + } +} diff --git a/crates/skate-data/tests/skate_map.rs b/crates/skate-data/tests/skate_map.rs index 013e49b7..19356753 100644 --- a/crates/skate-data/tests/skate_map.rs +++ b/crates/skate-data/tests/skate_map.rs @@ -57,6 +57,7 @@ fn fixture(version: u8) -> Vec { if version >= 8 { u(&mut b, 1); } + let material_start = b.len(); s(&mut b, "Surface"); u(&mut b, 1); fs(&mut b, &[0.6, 0.1, 1., 0.5, 0.25, 0.8, 0.]); @@ -78,6 +79,11 @@ fn fixture(version: u8) -> Vec { if version >= 12 { u(&mut b, 0); } + if version >= 15 { + let material = b.split_off(material_start); + u(&mut b, material.len() as u32); + stored(&mut b, &material, 2); + } s(&mut b, "Pixel"); for n in [1, 1, 1] { u(&mut b, n); @@ -172,7 +178,7 @@ fn stored(b: &mut Vec, data: &[u8], method: u8) { } #[test] fn reads_all_documented_versions_without_moving_geometry() { - for version in 1..=14 { + for version in 1..=15 { let map = SkateMap::parse(&fixture(version)).unwrap(); assert_eq!(map.version, version); assert_eq!(map.spawn, [2., 0., 3.]); @@ -194,6 +200,36 @@ fn reads_all_documented_versions_without_moving_geometry() { } } } + +#[test] +fn v15_texture_references_preserve_order_and_reject_forward_or_wrong_size() { + let mut data = fixture(15); + let header_counts = 8 + 4 + 4 + "Fixture".len() + 49 * 4; + data[header_counts + 4..header_counts + 8].copy_from_slice(&3u32.to_le_bytes()); + let mut needle = Vec::new(); + s(&mut needle, "Pixel"); + let start = data.windows(needle.len()).position(|w| w == needle).unwrap(); + let metadata_end = start + needle.len() + 12; + let texture_end = metadata_end + 8 + 4; // fixture v15 uses raw RGBA + let mut references = Vec::new(); + for source in [0, 1] { + references.extend_from_slice(&data[start..metadata_end]); + u(&mut references, 11); + u(&mut references, 4); + u(&mut references, source); + } + data.splice(texture_end..texture_end, references); + let map = SkateMap::parse(&data).unwrap(); + assert_eq!(map.textures.len(), 3); + assert!(map.textures.iter().all(|t| t.rgba == [255, 128, 64, 255])); + let reference = texture_end + (metadata_end - start) + 8; + data[reference..reference + 4].copy_from_slice(&1u32.to_le_bytes()); + assert!(SkateMap::parse(&data).unwrap_err().contains("forward texture reference")); + data[reference..reference + 4].copy_from_slice(&0u32.to_le_bytes()); + let width = texture_end + needle.len(); + data[width..width + 4].copy_from_slice(&2u32.to_le_bytes()); + assert!(SkateMap::parse(&data).unwrap_err().contains("reference size mismatch")); +} #[test] fn rejects_every_truncated_prefix_and_trailing_data() { let data = fixture(8); diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index e4c9be8d..77724e88 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -37,7 +37,7 @@ pub(crate) fn build( physics: GamePhysics, skater: SkaterRuntime, ) -> App { - let retail_scene = config.map.as_ref().is_some_and(|map| map.materials.iter().any(|m| m.retail_definition.is_some())); + let retail_scene = config.map.as_ref().is_some_and(|map| crate::retail_render::RetailScene::for_map(map)); let mut app = App::new(); crate::custom_models::register_source(&mut app); app.register_asset_source("mods", bevy::asset::io::AssetSourceBuilder::platform_default( diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 9734e1da..4ea21f73 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -29,6 +29,7 @@ const RESOLUTIONS: &[(u32, u32)] = &[ (3840, 2160), ]; const SCALES: &[u32] = &[25, 50, 67, 75, 85, 100]; +const DAY_SPEEDS: &[u32] = &[0, 1, 10, 30, 60, 120, 360, 720]; const LIMITS: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -40,6 +41,9 @@ struct GraphicsSettings { samples: u32, fps: u32, occlusion: bool, + hour: f32, + day_speed: u32, + ambient_level: Option, } impl Default for GraphicsSettings { fn default() -> Self { @@ -50,11 +54,17 @@ impl Default for GraphicsSettings { samples: 4, fps: 0, occlusion: true, + hour: 12., + day_speed: 60, + ambient_level: None, } } } impl GraphicsSettings { fn validated(mut self) -> Self { + self.ambient_level = self.ambient_level.map(|level| level.min(100)); + self.hour = if self.hour.is_finite() { self.hour.rem_euclid(24.) } else { 12. }; + if !DAY_SPEEDS.contains(&self.day_speed) { self.day_speed = 60; } if !RESOLUTIONS.contains(&(self.width, self.height)) { (self.width, self.height) = (1280, 800); } @@ -86,8 +96,18 @@ pub(crate) struct Menu { selected_map: usize, multiplayer: bool, browser: bool, + daylight: bool, } impl Menu { + pub(crate) fn ambient_brightness(&self, automatic: f32) -> f32 { + self.settings.ambient_level.map_or(automatic, |level| level as f32 * 10.) + } + pub(crate) fn advance_day(&mut self, seconds: f32) -> f32 { + if !self.open && self.settings.day_speed > 0 { + self.settings.hour = (self.settings.hour + seconds * self.settings.day_speed as f32 / 3600.).rem_euclid(24.); + } + self.settings.hour + } pub(crate) fn diagnostic_settings(&self) -> String { format!("{:?}", self.settings) } @@ -125,7 +145,8 @@ impl Plugin for GraphicsMenuPlugin { app.insert_resource(FramePacer(Instant::now())) .add_systems(PostStartup, setup.in_set(PresentationSetup)) .add_systems(PreUpdate, interact.in_set(MenuInput).after(bevy::input::InputSystems)) - .add_systems(Update, (apply, labels).chain()) + .add_systems(Update, (crate::map_render::advance_day, apply, labels).chain()) + .add_systems(PostUpdate, crate::map_render::position_celestial_bodies.before(bevy::transform::TransformSystems::Propagate)) .add_systems(Last, pace); } } @@ -218,8 +239,8 @@ fn setup( BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..16 { - panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, + for i in 0..17 { + panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(26),padding:UiRect::all(px(3)),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:18.,..default()},TextColor(Color::WHITE))); }); @@ -244,6 +265,7 @@ fn setup( selected_map, multiplayer: false, browser: false, + daylight: false, }); } fn msaa(samples: u32) -> Msaa { @@ -307,7 +329,7 @@ pub(crate) fn interact( } } if menu.open { - let rows = if menu.multiplayer { 11 } else { 16 }; + let rows = if menu.daylight { 4 } else if menu.multiplayer { 11 } else { 17 }; if !panel.focused { if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { menu.selected = (menu.selected + rows - 1) % rows; @@ -331,7 +353,20 @@ pub(crate) fn interact( } } if let Some((row, direction)) = action { - if menu.browser { + let day_action = menu.daylight; + if menu.daylight { + match row { + 0 => menu.settings.hour = ((menu.settings.hour * 4.).round() + direction as f32).rem_euclid(96.) / 4., + 1 => menu.settings.day_speed = cycle(DAY_SPEEDS, menu.settings.day_speed, direction), + 2 => { + // Auto, 0%, 5%, ... 100%, then Auto again. + let index = menu.settings.ambient_level.map_or(0, |level| level as i32 / 5 + 1); + let next = (index + direction).rem_euclid(22); + menu.settings.ambient_level = if next == 0 { None } else { Some((next as u32 - 1) * 5) }; + } + _ => { menu.daylight = false; menu.selected = 16; } + } + } else if menu.browser { match row { 0 => net.browse(0), 1..=5 => net.join_row(row - 1), @@ -432,10 +467,11 @@ pub(crate) fn interact( 13 => menu.status = updater.open(false), 14 => travel.open = true, 15 => mods.begin(), + 16 => { menu.daylight = true; menu.selected = 0; menu.status = "Custom maps: change time, cycle speed and ambient light. Retail lighting stays authored.".into(); }, _ => {} } } - if row < 5 && !menu.multiplayer { + if (row < 5 && !menu.multiplayer && !menu.daylight && !day_action) || (day_action && row < 3) { let save = (|| -> Result<(), String> { std::fs::create_dir_all(menu.path.parent().unwrap()).map_err(|e| e.to_string())?; std::fs::write( @@ -536,7 +572,18 @@ fn labels( let s = &menu.settings; let size = s.internal_size(window.physical_size()); for (label, mut text) in &mut labels { - **text = if menu.browser { + **text = if menu.daylight { + match label.0 { + 0 => { let minutes = (s.hour * 60.).floor() as u32 % 1440; format!("Time of day {:02}:{:02}", minutes / 60, minutes % 60) }, + 1 => if s.day_speed == 0 { "Cycle speed Frozen".into() } else { format!("Cycle speed {}x ({} min/day)", s.day_speed, 1440 / s.day_speed) }, + 2 => match s.ambient_level { + Some(level) => format!("Ambient light {level}%"), + None => "Ambient light Auto (day/night)".into(), + }, + 3 => "Back".into(), + _ => String::new(), + } + } else if menu.browser { match label.0 { 0 => "Refresh public Steam lobbies".into(), 1..=5 => net @@ -621,6 +668,7 @@ fn labels( 13 => "Updates".into(), 15 => "Mods".into(), 14 => "Teleport…".into(), + 16 => "Day & night…".into(), _ => "Multiplayer".into(), } }; @@ -643,7 +691,7 @@ fn labels( menu.status.clone() }; for (row, interaction, mut color, mut node) in &mut buttons { - node.display = if menu.multiplayer && row.0 >= 11 { Display::None } else { Display::Flex }; + node.display = if (menu.daylight && row.0 >= 4) || (menu.multiplayer && row.0 >= 11) { Display::None } else { Display::Flex }; color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered { Color::srgb(0.10, 0.30, 0.34) } else { @@ -682,10 +730,21 @@ mod tests { .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), - multiplayer: false, browser: false, + multiplayer: false, browser: false, daylight: false, maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0, }) .add_systems(Update, apply); + { + let mut menu = app.world_mut().resource_mut::(); + menu.settings.hour = 23.5; + menu.settings.day_speed = 60; + assert!((menu.advance_day(60.) - 0.5).abs() < 0.0001); + menu.open = true; + assert_eq!(menu.advance_day(60.), 0.5); + menu.open = false; + menu.settings.day_speed = 0; + assert_eq!(menu.advance_day(60.), 0.5); + } app.world_mut().spawn((Window::default(), PrimaryWindow)); let camera = app.world_mut().spawn((Camera3d::default(), Msaa::Off)).id(); app.update(); diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 84fd2487..858ab4ad 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -96,9 +96,14 @@ impl PreparedScene { if let Some(map) = map { crate::skate_world::spawn(map, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images, &crate::retail_render::MaterialTuning::load(root)); - crate::retail_render::spawn_backdrop(&map.name, root, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images); - crate::retail_render::spawn_sky(&map.name, root, &mut self.commands, - &mut self.meshes, &mut self.images, &mut self.sky, &mut self.retail); + if crate::retail_render::RetailScene::for_map(map) { + crate::retail_render::spawn_backdrop(&map.name, root, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images); + crate::retail_render::spawn_sky(&map.name, root, &mut self.commands, + &mut self.meshes, &mut self.images, &mut self.sky, &mut self.retail); + } else { + custom_lighting(map, &mut self.commands); + celestial_bodies(&mut self.commands, &mut self.meshes, &mut self.materials, &mut self.images); + } } else { crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials); } @@ -122,6 +127,159 @@ pub(crate) struct MapAssets { sky: Vec>, images: Vec>, } + +/// Scene-owned environment; retired with its sun when switching maps. +#[derive(Component)] +pub(crate) struct DayEnvironment { + values: Vec, + sky: Option, + sun_direction: Vec3, +} +fn orbit(azimuth: f32, hour: f32) -> Vec3 { + let angle = (hour - 6.) * std::f32::consts::PI / 12.; + Vec3::new(azimuth.cos() * angle.cos(), angle.sin(), azimuth.sin() * angle.cos()) +} + +#[derive(Component)] +pub(crate) struct CelestialBody { moon: bool } +fn celestial_bodies(commands: &mut SceneCommands, meshes: &mut StagedAssets, materials: &mut StagedAssets, images: &mut StagedAssets) { + let disc = meshes.add(Rectangle::new(2., 2.).into()); + for moon in [false, true] { + let texture = images.add(celestial_texture(moon)); + commands.spawn(( + Name::new(if moon { "Custom moon" } else { "Custom sun" }), + CelestialBody { moon }, Mesh3d(disc.clone()), + MeshMaterial3d(materials.add(StandardMaterial { + base_color_texture: Some(texture), alpha_mode: AlphaMode::Blend, + unlit: true, cull_mode: None, ..default() + })), + Transform::default(), Visibility::Hidden, + bevy::light::NotShadowCaster, bevy::light::NotShadowReceiver, + )); + } +} +/// Generated once during off-thread map preparation, never during gameplay. +/// A transparent border contains the corona/halo without a hard square edge. +fn celestial_texture(moon: bool) -> Image { + const SIZE: u32 = 256; + let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); + let craters = [(-0.35f32, 0.2f32, 0.22f32), (0.28, -0.3, 0.16), + (0.35, 0.4, 0.12), (-0.12, -0.58, 0.1), (-0.5, -0.28, 0.13), + (0.05, 0.15, 0.08), (0.62, 0.02, 0.1), (-0.15, 0.62, 0.09)]; + for y in 0..SIZE { + for x in 0..SIZE { + let u = (x as f32 + 0.5) * 2. / SIZE as f32 - 1.; + let v = (y as f32 + 0.5) * 2. / SIZE as f32 - 1.; + let r = u.hypot(v); + let q = r / 0.35; + let edge = ((1. - q) * 60.).clamp(0., 1.); + let (rgb, alpha) = if q <= 1. { + if moon { + let px = u / 0.35; + let py = v / 0.35; + let mut tone = 0.73 + 0.055 * (px * 19. + (py * 13.).sin()).sin() + + 0.035 * (py * 37. + px * 29.).cos(); + for (cx, cy, radius) in craters { + let d = (px - cx).hypot(py - cy) / radius; + tone -= 0.19 * (-d * d * 2.).exp(); + tone += 0.12 * (-((d - 1.) * 7.).powi(2)).exp(); + } + tone *= 0.65 + 0.35 * (1. - q * q).max(0.).sqrt(); + ([tone * 0.94, tone * 0.98, tone * 1.07], edge) + } else { + ([1., 0.96 - q * 0.13, 0.78 - q * 0.32], edge) + } + } else { + let angle = v.atan2(u); + let rays = if moon { 1. } else { + 0.65 + 0.2 * (angle * 12. + r * 9.).sin().powi(2) + + 0.15 * (angle * 23. - r * 6.).cos().powi(2) + }; + let glow = (-(q - 1.) * if moon { 4. } else { 2.4 }).exp() + * (1. - r).max(0.).powi(2) * rays; + (if moon { [0.55, 0.68, 1.] } else { [1., 0.63, 0.16] }, + glow * if moon { 0.36 } else { 0.85 }) + }; + for c in rgb { pixels.push((c.clamp(0., 1.) * 255.) as u8); } + pixels.push((alpha.clamp(0., 1.) * 255.) as u8); + } + } + Image::new( + bevy::render::render_resource::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 }, + bevy::render::render_resource::TextureDimension::D2, pixels, + bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ) +} +/// Follow the final gameplay camera before transform propagation, with no +/// parallax. Ordinary depth testing lets buildings and terrain obscure them. +pub(crate) fn position_celestial_bodies( + time: Res>, + environment: Query<&DayEnvironment>, + camera: Query<(&Transform, &Projection), (With, Without)>, + mut bodies: Query<(&CelestialBody, &mut Transform, &mut Visibility), Without>, +) { + let (Ok(environment), Ok((camera, projection))) = (environment.single(), camera.single()) else { return; }; + let far = match projection { Projection::Perspective(p) => p.far, Projection::Orthographic(p) => p.far, _ => 1000. }; + let distance = if far.is_finite() { far * 0.98 } else { 10_000. }; + for (body, mut transform, mut visibility) in &mut bodies { + let direction = environment.sun_direction * if body.moon { -1. } else { 1. }; + *visibility = if direction.y > 0. { Visibility::Inherited } else { Visibility::Hidden }; + *transform = Transform::from_translation(camera.translation + direction * distance) + .looking_at(camera.translation, if direction.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y }) + .with_scale(Vec3::splat(distance * if body.moon { 0.13 } else { 0.115 })); + if !body.moon { + transform.rotate_local_z(time.elapsed_secs() * 0.025); + transform.scale *= 1. + 0.012 * (time.elapsed_secs() * 0.8).sin(); + } + } +} +fn daylight_values(e: &[f32], hour: f32) -> (Color, f32, f32, Transform, f32) { + let angle = (hour - 6.) * std::f32::consts::PI / 12.; + let elevation = angle.sin(); + let daylight = elevation.max(0.); + let extended = e.len() == 45; + let night = elevation < 0.; + let rgb = |i: usize| Color::linear_rgb(e[i].max(0.), e[i + 1].max(0.), e[i + 2].max(0.)); + let color = if extended { rgb(if night { 35 } else { 32 }) } else if night { Color::srgb(0.55, 0.65, 1.) } else { Color::WHITE }; + let strength = if extended { e[if night { 39 } else { 38 }].max(0.) } else if night { 0.025 } else { 1. }; + let ambient = if extended { e[41] + (e[40] - e[41]) * daylight } else { 0.025 + 0.295 * daylight }; + // Signed horizontal motion carries the sun east-to-west. The moon follows + // the opposite hemisphere; both fade to zero at the horizon. + let horizontal = angle.cos() * if night { -1. } else { 1. }; + let direction = Vec3::new(e[11].cos() * horizontal, elevation.abs().max(0.001), e[11].sin() * horizontal).normalize(); + (color, 10_000. * strength * elevation.abs(), ambient.max(0.) * 1000., + Transform::default().looking_to(-direction, if direction.y > 0.99 { Vec3::Z } else { Vec3::Y }), daylight) +} +fn custom_lighting(map: &skate_data::skate_map::SkateMap, commands: &mut SceneCommands) { + let (color, illuminance, brightness, transform, _) = daylight_values(&map.environment, map.environment[10].rem_euclid(24.)); + commands.insert_resource(GlobalAmbientLight { color: Color::WHITE, brightness, ..default() }); + commands.spawn(( + Name::new("Custom map sun/moon"), + DayEnvironment { values: map.environment.to_vec(), sky: None, sun_direction: orbit(map.environment[11], map.environment[10]) }, + DirectionalLight { color, illuminance, shadows_enabled: true, affects_lightmapped_mesh_diffuse: false, ..default() }, + transform, + bevy::light::CascadeShadowConfigBuilder { maximum_distance: 100., first_cascade_far_bound: 10., ..default() }.build(), + )); +} +pub(crate) fn advance_day( + time: Res>, mut menu: ResMut, + mut lights: Query<(&mut DayEnvironment, &mut DirectionalLight, &mut Transform)>, + mut ambient: ResMut, mut clear: ResMut, +) { + if lights.is_empty() { return; } + let hour = menu.advance_day(time.delta_secs()); + for (mut environment, mut light, mut transform) in &mut lights { + let base = *environment.sky.get_or_insert(clear.0); + let (color, illuminance, brightness, pose, daylight) = daylight_values(&environment.values, hour); + environment.sun_direction = orbit(environment.values[11], hour); + light.color = color; + light.illuminance = illuminance; + *transform = pose; + ambient.brightness = menu.ambient_brightness(brightness); + clear.0 = Color::srgb(0.003, 0.005, 0.015).mix(&base, daylight.sqrt()); + } +} impl MapAssets { pub fn retire(world: &mut World) { let entities: Vec<_> = world.query_filtered::>().iter(world).collect(); @@ -143,6 +301,66 @@ impl MapAssets { #[cfg(test)] mod tests { use super::*; + #[test] + fn daylight_orbit_and_authored_zero_intensity() { + let mut e = vec![0.; 12]; + let (_, noon, ambient_day, pose, _) = daylight_values(&e, 12.); + let (_, midnight, ambient_night, _, _) = daylight_values(&e, 0.); + assert!(noon > midnight && ambient_day > ambient_night); + assert!(pose.rotation.is_finite()); + let (_, _, _, morning, _) = daylight_values(&e, 9.); + let (_, _, _, evening, _) = daylight_values(&e, 15.); + assert!(morning.forward().x * evening.forward().x < 0.); + e.resize(45, 0.); + assert_eq!(daylight_values(&e, 12.).1, 0.); + assert_eq!(daylight_values(&e, 0.).1, 0.); + } + #[test] + #[ignore = "requires user-supplied custom map; CPU preparation only"] + fn supplied_custom_map_prepares_and_retires() { + let path = std::env::var("SKATE_TEST_CUSTOM_MAP").unwrap(); + let root = std::env::var("SKATE_TEST_CUSTOM_ASSETS").unwrap(); + let map = skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)).unwrap(); + crate::skate_world::validate_runtime(&map).unwrap(); + assert!(!crate::retail_render::RetailScene::for_map(&map)); + let mut world = world(); + let mut scene = PreparedScene::new(&world); + scene.prepare(Some(&map), std::path::Path::new(&root)); + scene.publish(&mut world); + assert_eq!(world.query::<&DirectionalLight>().iter(&world).count(), 1); + MapAssets::retire(&mut world); + assert_eq!(world.query_filtered::>().iter(&world).count(), 0); + } + #[test] + fn custom_lighting_switches_without_accumulation_and_keeps_pbr() { + let mut world = world(); + let mut map = skate_data::skate_map::SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap(); + map.name = "university".into(); // Names cannot select retail rendering. + map.materials[0].retail_definition = None; + map.extensions.clear(); + for retail in [false, true, false, true] { + map.extensions.clear(); + if retail { + // Legacy retail imports can retain provenance without definitions. + map.extensions.push(skate_data::skate_map::Extension { + tag: *b"WMET", schema: 1, payload: b"{}".to_vec(), + }); + } + assert_eq!(crate::retail_render::RetailScene::for_map(&map), retail); + let mut scene = PreparedScene::new(&world); + scene.prepare(Some(&map), std::path::Path::new("unused")); + scene.publish(&mut world); + assert_eq!(world.query::<&DirectionalLight>().iter(&world).count(), usize::from(!retail)); + assert_eq!(world.query::<&CelestialBody>().iter(&world).count(), if retail { 0 } else { 2 }); + // Only sky discs are unlit; world surfaces retain the PBR path. + let body_materials: Vec<_> = world.query_filtered::<&MeshMaterial3d, With>() + .iter(&world).map(|m| m.0.id()).collect(); + assert!(world.resource::>().iter().all(|(id, m)| body_materials.contains(&id) || !m.unlit)); + MapAssets::retire(&mut world); + assert_eq!(world.query::<&DirectionalLight>().iter(&world).count(), 0); + } + } + fn world() -> World { let mut world = World::new(); world.init_resource::>(); diff --git a/crates/skate-game/src/map_transition.rs b/crates/skate-game/src/map_transition.rs index 84975a3e..8cc5e50e 100644 --- a/crates/skate-game/src/map_transition.rs +++ b/crates/skate-game/src/map_transition.rs @@ -110,7 +110,7 @@ fn start(world: &World, entry: Entry) -> Result { if let Some(map) = &map { crate::skate_world::validate_runtime(map)?; } let validation_time = validation_started.elapsed(); let metadata = CurrentMap::from_package(selected.path, map.as_ref()); - let retail = map.as_ref().is_some_and(|m| m.materials.iter().any(|m| m.retail_definition.is_some())); + let retail = map.as_ref().is_some_and(|m| crate::retail_render::RetailScene::for_map(m)); // Both builders only read the decoded package. Reserve render handles // on a second worker while the first constructs fresh simulation state; // neither publishes to the live world until both have succeeded. diff --git a/crates/skate-game/src/performance.rs b/crates/skate-game/src/performance.rs index 4b4ba29e..bc07002b 100644 --- a/crates/skate-game/src/performance.rs +++ b/crates/skate-game/src/performance.rs @@ -5,6 +5,12 @@ use std::sync::{Arc, Mutex}; use std::{collections::BTreeMap, sync::OnceLock}; use std::{path::PathBuf, time::Instant}; +static EPOCH: OnceLock = OnceLock::new(); +static SLOW_SECTIONS: Mutex> = Mutex::new(Vec::new()); +fn timestamp(now: Instant) -> f64 { + EPOCH.get().map_or(0., |epoch| now.duration_since(*epoch).as_secs_f64() * 1000.) +} + static SECTIONS: Mutex> = Mutex::new(BTreeMap::new()); pub(crate) struct Scope { name: &'static str, @@ -24,9 +30,14 @@ impl Scope { impl Drop for Scope { fn drop(&mut self) { if let Some(start) = self.start { + let elapsed = start.elapsed().as_secs_f64() * 1000.; + if elapsed >= 2. { + let mut slow = SLOW_SECTIONS.lock().unwrap(); + if slow.len() < 256 { slow.push((self.name, timestamp(start), elapsed)); } + } let mut sections = SECTIONS.lock().unwrap(); let entry = sections.entry(self.name).or_default(); - entry.0 += start.elapsed().as_secs_f64() * 1000.; + entry.0 += elapsed; entry.1 += 1; } } @@ -41,7 +52,8 @@ pub(crate) struct Performance { physics_ms: f64, ticks: u32, samples: Vec<[f64; 4]>, - render: Arc>>, + sample_times: Vec, + render: Arc>>, } impl Performance { pub(crate) fn physics(&mut self, elapsed: std::time::Duration) { @@ -57,7 +69,8 @@ impl Plugin for PerformancePlugin { return; }; let now = Instant::now(); - let render = Arc::new(Mutex::new(Vec::new())); + let _ = EPOCH.set(now); + let render = Arc::new(Mutex::new(Vec::with_capacity(16384))); if let Some(render_app) = app.get_sub_app_mut(RenderApp) { render_app .insert_resource(RenderPerformance { @@ -126,7 +139,8 @@ impl Plugin for PerformancePlugin { previous: now, physics_ms: 0., ticks: 0, - samples: Vec::new(), + samples: Vec::with_capacity(16384), + sample_times: Vec::with_capacity(16384), render, }) .add_systems(First, begin) @@ -183,6 +197,7 @@ fn finish(mut p: ResMut, mut exit: MessageWriter) { f64::from(p.ticks), ]; p.samples.push(sample); + p.sample_times.push(timestamp(now)); } if elapsed > 25. && !p.samples.is_empty() { let mean = |column: usize| { @@ -205,6 +220,14 @@ fn finish(mut p: ResMut, mut exit: MessageWriter) { "render_assets_ms_mean": render_mean(2), "render_views_ms_mean": render_mean(3), "render_queue_ms_mean": render_mean(4), "sections_ms_per_call": SECTIONS.lock().unwrap().iter().map(|(&k, &(ms, n))| (k, ms / n as f64)).collect::>(), "samples": p.samples, + "sample_elapsed_ms": p.sample_times, + "render_samples": &*render, + "render_sample_columns": ["prepare_ms", "submit_ms", "assets_ms", "views_ms", "queue_ms", "elapsed_ms", "waiting_pipelines"], + "slow_sections": &*SLOW_SECTIONS.lock().unwrap(), + "slow_section_columns": ["section", "start_elapsed_ms", "duration_ms"], + "frame_ms_max": frames[frames.len()-1], + "frame_ms_p99": frames[(frames.len()-1)*99/100], + "frames_over_8ms": frames.iter().filter(|&&ms| ms > 8.).count(), }); match std::fs::write(&p.path, serde_json::to_vec_pretty(&report).unwrap()) { Ok(()) => { @@ -226,7 +249,7 @@ struct RenderPerformance { prepared: Instant, mark: Instant, phases: [f64; 3], - samples: Arc>>, + samples: Arc>>, } fn render_begin(mut p: ResMut) { p.frame = Instant::now(); @@ -248,7 +271,7 @@ fn render_queue_done(mut p: ResMut) { fn render_prepared(mut p: ResMut) { p.prepared = Instant::now(); } -fn render_finish(mut p: ResMut) { +fn render_finish(mut p: ResMut, pipelines: Res) { let now = Instant::now(); if now .duration_since(*p.start.get_or_insert(now)) @@ -261,6 +284,8 @@ fn render_finish(mut p: ResMut) { p.phases[0], p.phases[1], p.phases[2], + timestamp(now), + pipelines.waiting_pipelines().count() as f64, ]); } } diff --git a/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs b/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs index 08c98507..dfd34683 100644 --- a/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs +++ b/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs @@ -67,6 +67,40 @@ fn actual_world_hit_preserves_mesh_identity_and_packed_surface() { assert!((result.trajectories[0].landing_normal[1] - 1.).abs() < 1e-5); assert_eq!(result.lines.len(), 44); } + +#[test] +fn nonfinite_unused_lane_preserves_sweep_hits_but_invalid_xyz_still_fails() { + let world = plane(0); + let scene = StaticScene::new(&world).unwrap(); + let mut batch = ProbeLayout::stock().prepare(input(), -1); + let expected = scene.execute(&batch).unwrap(); + // Reported crash: finite spatial endpoints, start.w=inf, end.w=NaN. + for request in &mut batch.trajectories { + request.trajectory.position[3] = f32::INFINITY; + request.trajectory.velocity[3] = f32::NEG_INFINITY; + } + for line in &mut batch.lines { + line.start[3] = f32::INFINITY; + line.end[3] = f32::NAN; + } + let actual = scene.execute(&batch).unwrap(); + assert_eq!(actual.trajectories, expected.trajectories); + assert_eq!(actual.lines, expected.lines); + assert_eq!(actual.edges, expected.edges); + for axis in 0..3 { + let mut invalid = batch.clone(); + invalid.lines[0].start[axis] = f32::NAN; + assert!(scene.execute(&invalid).is_err()); + invalid.lines[0].start[axis] = 0.; + invalid.lines[0].end[axis] = f32::INFINITY; + assert!(scene.execute(&invalid).is_err()); + } + for radius in [-1., f32::NAN, f32::INFINITY] { + let mut invalid = batch.clone(); + invalid.lines[0].radius = radius; + assert!(scene.execute(&invalid).is_err()); + } +} #[test] fn mesh_filter_is_not_a_board_flag_or_material_tag() { let world = plane(0x2000); diff --git a/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs b/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs index 55649dce..99f64f01 100644 --- a/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs +++ b/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs @@ -131,14 +131,14 @@ impl<'a> StaticScene<'a> { ) -> Result, &'static str> { if !probe.radius.is_finite() || probe.radius < 0. - || probe - .start - .into_iter() - .chain(probe.end) - .any(|x| !x.is_finite()) + // The native packet has four SIMD lanes, but static collision and + // query_sweep's dot3/length contract consume XYZ only. Lane 3 can + // carry non-finite intermediate values (e.g. inf - inf) without + // making the spatial segment invalid. Never relax XYZ/radius checks. + || probe.start[..3].iter().chain(&probe.end[..3]).any(|x| !x.is_finite()) { // Temporary audit-only error detail: preserve the rejected producer - // packet and caller chain. No substitution or validation change. + // packet and caller chain. Invalid spatial values are never substituted. bevy::log::error!( "OFFBOARD_INVALID_SWEEP start={:?} end={:?} radius={} start_bits={:08x?} end_bits={:08x?} radius_bits={:08x} group={} reject={:08x} caller={}", probe.start, diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 98cd43c4..3165ac60 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -77,10 +77,31 @@ impl Material for CharacterMaterial { } #[derive(Component)] struct ShadowSource; -fn load(mut commands: Commands, config: Res, sources: Query>, mut shadow: ResMut) { + +#[derive(Component)] +struct OriginalCharacterMaterial { + material: Handle, + layers: Option, +} +fn load(mut commands: Commands, config: Res, sources: Query>, mut shadow: ResMut, + retail: Res, + originals: Query<(Entity, &OriginalCharacterMaterial)>, + cameras: Query<(Entity, &RenderLayers), With>, +) { commands.remove_resource::(); for e in &sources { commands.entity(e).despawn(); } *shadow = Default::default(); + for (entity, original) in &originals { + let mut entity = commands.entity(entity); + entity.remove::<(MeshMaterial3d, OriginalCharacterMaterial)>() + .insert(MeshMaterial3d(original.material.clone())); + if let Some(layers) = &original.layers { entity.insert(layers.clone()); } + else { entity.remove::(); } + } + for (entity, layers) in &cameras { + commands.entity(entity).insert(layers.clone().without(28)); + } + if !retail.0 { return; } let Some(map_path) = &config.map_path else { return; }; @@ -201,12 +222,15 @@ fn bind( source: Res>, server: Res, mut materials: ResMut>, - entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d)>, + entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d, Option<&RenderLayers>)>, + parents: Query<&ChildOf>, + players: Query<(), With>, ) { let Some(lighting) = lighting else { return; }; - for (entity, name, handle) in &entities { + for (entity, name, handle, layers) in &entities { + if !parents.iter_ancestors(entity).any(|e| players.contains(e)) { continue; } let Some(data) = lighting.data.materials.get(&name.0) else { continue; }; @@ -253,6 +277,7 @@ fn bind( .entity(entity) .remove::>() .insert(( + OriginalCharacterMaterial { material: handle.0.clone(), layers: layers.cloned() }, MeshMaterial3d(material), RenderLayers::from_layers(&[0, 28]), )); @@ -261,7 +286,7 @@ fn bind( fn shadow_views( mut commands: Commands, lighting: Option>, - cameras: Query<(Entity, Option<&RenderLayers>), With>, + cameras: Query<(Entity, Option<&RenderLayers>), With>, ) { if lighting.is_none() { return; @@ -305,6 +330,37 @@ fn update( mod tests { use super::*; + #[test] + fn custom_scene_restores_character_and_removes_only_gameplay_shadow_layer() { + use bevy::ecs::system::RunSystemOnce; + let mut world = World::new(); + world.insert_resource(crate::config::Config { + asset_root: "unused".into(), verification_capture: None, + map: None, map_path: None, difficulty: crate::difficulty::Difficulty::Hardcore, + check_assets: false, start_paused: false, teleport: None, + multiplayer: Default::default(), map_fingerprint: 0, + }); + world.insert_resource(crate::retail_render::RetailScene(false)); + world.insert_resource(crate::retail_render::ShadowState(Vec4::ONE, Vec4::ONE, [Vec4::ONE; 7])); + let material = Handle::::default(); + let player = world.spawn(( + OriginalCharacterMaterial { material: material.clone(), layers: None }, + MeshMaterial3d(Handle::::default()), + RenderLayers::from_layers(&[0, 28]), + )).id(); + let light = world.spawn(ShadowSource).id(); + let camera = world.spawn((crate::camera::GameplayCamera, RenderLayers::from_layers(&[0, 28]))).id(); + let overlay = world.spawn(RenderLayers::layer(31)).id(); + world.run_system_once(load).unwrap(); + assert!(world.get_entity(light).is_err()); + assert_eq!(world.get::>(player).unwrap().0, material); + assert!(world.get::>(player).is_none()); + assert!(world.get::(player).is_none()); + assert_eq!(world.get::(camera).unwrap(), &RenderLayers::default()); + assert_eq!(world.get::(overlay).unwrap(), &RenderLayers::layer(31)); + assert_eq!(world.resource::().0, Vec4::ZERO); + } + #[test] fn world_receiver_source_excludes_world_casters_and_emits_no_light() { let mut world = World::new(); diff --git a/crates/skate-game/src/retail_exposure.rs b/crates/skate-game/src/retail_exposure.rs index 6052b6bb..a7913288 100644 --- a/crates/skate-game/src/retail_exposure.rs +++ b/crates/skate-game/src/retail_exposure.rs @@ -68,8 +68,11 @@ pub(super) fn install(app: &mut App) { } } -fn load(config: Res, mut settings: ResMut) { +fn load(config: Res, retail: Res, mut settings: ResMut) { + let generation = settings.timing.y + 1.; *settings = Settings::default(); + settings.timing.y = generation; + if !retail.0 { return; } if std::env::var_os("SKATE_FIXED_EXPOSURE").is_some_and(|v| v == "1") { info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode"); return; diff --git a/crates/skate-game/src/retail_exposure.wgsl b/crates/skate-game/src/retail_exposure.wgsl index 0b49d7a2..018cbab1 100644 --- a/crates/skate-game/src/retail_exposure.wgsl +++ b/crates/skate-game/src/retail_exposure.wgsl @@ -8,6 +8,11 @@ struct Settings { tuning: vec4, timing: vec4 } var sums: array; @compute @workgroup_size(256) fn meter(@builtin(local_invocation_index) id: u32) { + if id == 0u && state.z != settings.timing.y { + state.x = clamp(2.5, settings.tuning.y, settings.tuning.z); + state.z = settings.timing.y; + } + storageBarrier(); let uv=(vec2(f32(id%16u),f32(id/16u))+0.5)/16.0; let xy=uv*2.0-1.0; let weight=pow(1.0-abs(xy.x*xy.y),2.0); diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index 71f28f9e..66a5266b 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -76,6 +76,17 @@ pub(crate) use shadow::ShadowState; #[derive(Resource)] pub(crate) struct RetailScene(pub bool); +impl RetailScene { + /// Package evidence, never the editable map name. Older Skate 2 exports + /// can retain native provenance/sky/collision without material definitions. + pub(crate) fn for_map(map: &skate_data::skate_map::SkateMap) -> bool { + map.materials.iter().any(|m| m.retail_definition.is_some()) + || map.extensions.iter().any(|e| matches!(&e.tag, b"WMET" | b"RWCM" | b"SKYB")) + || map.geometry.collision.iter().any(|c| c.native_edges.is_some()) + || map.rails.iter().any(|r| r.native.is_some()) + } +} + #[path = "retail_sky.rs"] mod sky; pub(crate) use sky::{spawn_sky, RetailSkyMaterial}; diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index 597b71e0..0cfde404 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -322,19 +322,28 @@ pub(crate) fn collision_world( .ok_or("Invalid SKATE collision volume")?, ); } - let bounds = Bounds::from_points(triangles.iter().flat_map(|t| t.triangle.vertices)) - .ok_or("SKATE collision bounds empty")?; - let metadata = QueryMetadata { - packed_surfaces, - meshes: vec![QueryMesh { + // Portable maps have no native cluster hierarchy. Bound contiguous ranges + // once at load time so the existing BVH can reject distant geometry. Keep + // triangle order and mesh identity/filter values: contact tie-breaking, + // packed surfaces and adjacency must not change with this acceleration. + let mut meshes = Vec::new(); + for start in (0..triangles.len()).step_by(64) { + let end = (start + 64).min(triangles.len()); + let bounds = Bounds::from_points(triangles[start..end].iter().flat_map(|t| t.triangle.vertices)) + .ok_or("SKATE collision bounds empty")?; + meshes.push(QueryMesh { geometry: 0, rejection_flags: 0, - triangle_range: 0..triangles.len(), + triangle_range: start..end, local_to_world: RetailAffineTransform::IDENTITY, world_to_local: RetailAffineTransform::IDENTITY, local_bounds: bounds, matching_group: -1, pool: QueryPool::Ground, - }], + }); + } + let metadata = QueryMetadata { + packed_surfaces, + meshes, static_edges: vec![], island_flags: 0, }; @@ -676,6 +685,61 @@ pub(crate) fn spawn( #[cfg(test)] mod tests { use super::*; + fn compare_cluster_queries(world: &BoardWorld) -> (usize, usize) { + let mut metadata = world.query_metadata().unwrap().clone(); + let mut single = metadata.meshes[0].clone(); + single.triangle_range = 0..world.triangles().len(); + single.local_bounds = Bounds::from_points(world.triangles().iter().flat_map(|t| t.triangle.vertices)).unwrap(); + metadata.meshes = vec![single]; + let baseline = BoardWorld::with_query_metadata(world.triangles().to_vec(), metadata).unwrap(); + let mut visited = [0usize; 2]; + let mut times = [std::time::Duration::ZERO; 2]; + for triangle in world.triangles().iter().step_by((world.triangles().len() / 256).max(1)) { + let p = triangle.triangle.vertices[0]; + let start = Vector3::new(p.x, p.y + 1., p.z); + let end = Vector3::new(p.x, p.y - 1., p.z); + let mut candidates = Vec::new(); + for (i, scene) in [&baseline, world].into_iter().enumerate() { + let bounds = scene.line_candidate_bounds(start, end, 0.1); + visited[i] += scene.candidate_ranges(bounds).iter().map(|r| r.len()).sum::(); + let now = std::time::Instant::now(); + candidates.push(scene.line_candidates(start, end, 0.1).map(|(id, _)| id).collect::>()); + times[i] += now.elapsed(); + } + assert_eq!(candidates[0], candidates[1], "candidate identity/order changed"); + } + eprintln!("CUSTOM_QUERY_BENCH baseline_triangles={} clustered_triangles={} baseline_ms={} clustered_ms={} clusters={}", + visited[0], visited[1], times[0].as_secs_f64()*1000., times[1].as_secs_f64()*1000., + world.query_metadata().unwrap().meshes.len()); + (visited[0], visited[1]) + } + + #[test] + fn portable_collision_clusters_preserve_queries_and_cull_distant_geometry() { + let mut map = demo(); + let source = map.geometry.collision.remove(0); + map.geometry.collision.clear(); + for i in 0..256 { + map.geometry.collision.push(skate_data::skate_map::Collision { + points: source.points.map(|p| [p[0] + i as f32 * 100., p[1], p[2]]), + surface: source.surface, material: source.material, native_edges: source.native_edges, + }); + } + let world = collision_world(&map, material()).unwrap(); + assert_eq!(world.query_metadata().unwrap().meshes.len(), 4); + let (before, after) = compare_cluster_queries(&world); + assert!(after < before / 2); + } + + #[test] + #[ignore = "requires a supplied map; static collision-query benchmark only"] + fn supplied_custom_map_collision_query_benchmark() { + let path = std::env::var("SKATE_TEST_CUSTOM_MAP").unwrap(); + let map = SkateMap::load(std::path::Path::new(&path)).unwrap(); + let world = collision_world(&map, material()).unwrap(); + let (before, after) = compare_cluster_queries(&world); + assert!(after < before); + } fn demo() -> SkateMap { SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap() } diff --git a/docs/SKATE-MAPS.md b/docs/SKATE-MAPS.md index 67d68614..bdcc063b 100644 --- a/docs/SKATE-MAPS.md +++ b/docs/SKATE-MAPS.md @@ -1,10 +1,9 @@ # .skate maps -Run `PLAY-MAP.bat` and enter the map number. Alternatively drag a `.skate` -file onto `PLAY.bat` or `PLAY-MAP.bat`. Command-line equivalent: +Drag a `.skate` file onto `PLAY.bat`. Command-line equivalent: ```powershell -.\Launch.ps1 -Map 'C:\path with spaces\park.skate' +.\scripts\Launch.ps1 -Map 'C:\path with spaces\park.skate' ``` The original test environment remains available through PLAY.bat without a map. @@ -15,10 +14,10 @@ error and do not silently load the default level instead. | Data | Current support | | --- | --- | -| SKATE01–14 records | Reader supports the documented layouts and raw, zlib and Zstandard blocks. | +| SKATE01–15 records | Reader supports raw, zlib and Zstandard blocks, plus v15 compressed materials, filtered RGBA, vertex streams, delta indices, indexed collision and backward texture references. | | SKATE15+ | Rejected with a version error. V15's additional storage transforms are not implemented. | | Visual geometry | Authored positions, triangle winding, normals, UV0/UV1 and material groups. Packed v12+ tangent frames are reconstructed. | -| Embedded materials | Albedo, normal, ORM, emissive, alpha mask/blend and indirect lightmaps. Lightmap RGB is decoded as `encoded² * 4` into half-float textures. | +| Embedded materials | Albedo, normal, ORM, emissive, alpha mask/blend and indirect lightmaps. Lightmap RGB is decoded as `encoded² * 4` into half-float textures. | | Collision | Separate authored collision mesh, reference 1 mm vertex welding, adjacent-edge and coplanar-vertex metadata. No collision inferred from visible triangles. | | Surface types | Exact audio/physics/pattern packing from `EncodeRwSurfaceId`. Authored friction is retained; the original imported static-world contact combine remains in charge. | | Spawn | Package position interpreted as the wheel-ground anchor, with the supplied wheel/body offset. Heading rotates about Y; coordinates stay Y-up metres. | @@ -131,7 +130,7 @@ cargo test -p skate-core --lib board_world --locked cargo run -p skate-data --example inspect_skate -- path/to/map.skate ``` -Reader tests cover versions 1–14 and all three supported compression methods, +Reader tests cover versions 1–14 and all three supported compression methods, every truncated prefix of the v8 fixture, invalid references, non-finite input, oversized counts, wrong byte order and unknown versions. Adapter tests check separate collision ownership, packed surfaces, flat seam handling and embedded diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md new file mode 100644 index 00000000..b553f40c --- /dev/null +++ b/docs/custom-map-lighting.md @@ -0,0 +1,188 @@ +# Custom map lighting + +Custom `.skate` maps use the existing StandardMaterial PBR path: albedo, normal, +ORM, emissive, alpha mask/blend, and UV1 lightmaps remain intact. A scene-owned +shadowed directional light and ambient light now illuminate those surfaces and +PBR characters. Shadow cascades cover 100 metres; local authored point/spot +lights retain their existing settings. Area lights remain unsupported. + +The environment header supplies start hour and orbit azimuth; extended headers +also supply sun/moon colour, intensity and day/night ambient. The adapter maps +unit sun intensity to 10,000 lux and unit ambient to 1,000 brightness. These are +portable rendering choices, not recovered retail constants. The pause menu now provides a Day & night submenu with a saved start hour +(default noon) and cycle speed (default 60x, a 24-minute day). Existing +baked diffuse on lightmapped custom surfaces is retained. The package horizon +colour supplies the daytime background; native sky sidecars are only loaded for retail +scenes. Zero authored light intensity is respected. + +## Provenance and ambiguity + +Startup and in-process loading share RetailScene::for_map. It checks embedded +retail material definitions, WMET retail-manifest metadata, RWCM collision, +SKYB retail sky, native collision edges and native rails. It never classifies +from a filename, map title or ordinary lightmap presence. The Blender exporter +writes WMET only for its retained SKATE3_RETAIL_MANIFEST; the retail pipeline +also writes WMET/RWCM. This conservatively protects imported Skate 2 scenes +whose material definitions may be absent, and edited retail-derived maps. +WCFG, BMAT and MOBJ are general authoring records, not retail evidence. + +A legacy retail export stripped of *all* native evidence is indistinguishable +from custom PBR geometry. A custom rebuild retaining native records is treated +as retail. No unreliable name heuristic or new authoring UI is introduced. +Retain provenance when re-exporting retail maps. This classification protects +the existing rendering path; it does not add support for missing retail shader +families or missing sky/irradiance sidecars. + +## Scene lifetime + +Directional/local lights, sky and backdrop are MapEntity-owned and retire with +their scene. Ambient/background are replaced on publication. The gameplay +camera switches retail HDR/tone on and off; the GPU exposure meter resets on a +new scene generation. Retail character conversion retains original material +handles and layers, restores them on each transition, and binds fresh authored +lighting only in a retail scene. Dedicated zero-illuminance retail shadow maps +are removed/recreated, preserving the separate player-only baked-world map. +Character conversion is restricted to PlayerRoot descendants; only the gameplay +camera acquires shadow layer 28. Scoring layer 31 and session overlays keep +their existing cameras/materials. Custom character PBR materials respond to the +custom scene lights without a separate character light rig. + +## Manual visual checks + +- Open an ordinary custom map: compare upward and vertical surfaces, moving + player shadows, normal maps, transparent textures and emissive surfaces. +- Check a map with local lights and/or baked indirect lighting; confirm those + remain visible. Check an authored night start and zero-intensity sun. +- Open vanilla Skate 3 and imported Skate 2 maps: verify baked scenery remains + unchanged and the dedicated player shadow remains visible where supported. +- Switch vanilla -> custom -> vanilla several times without restarting. Check + sky/background, exposure, character shading and shadows for stale state or + increasing brightness. Repeat with a custom character and clothing changes. +- Open scoring HUD, session marker and customization overlays on both map + types; verify no world/player silhouettes or lighting appear in overlays. +- Rename a custom package to a retail-like name: it must still get custom + lighting. A retail-derived package retaining provenance must stay retail. + +No game, recomp, Steam or gameplay automation was launched for this change. + +## Automated validation + +`cargo check -p skate-game --tests --no-default-features --release --offline` +passed with existing warnings. A static-CRT release test harness was linked +directly into the worktree's ignored private output using the shared Cargo +cache. All six focused CPU tests passed: `map_render::tests` (3), +`retail_character::tests` (2), and `camera::environment_tests` (1). These checks +do not validate GPU appearance; use the visual checklist above. + +SKATE15 loading is also covered by ten data-reader tests (including legacy +versions, both compression codecs, all storage transforms, texture references +and malformed input). A user-supplied v15 custom map passed the ignored CPU +`supplied_custom_map_prepares_and_retires` check: decoding, runtime validation, +material/mesh preparation, publication, custom-light classification and scene +retirement. Supply `SKATE_TEST_CUSTOM_MAP` and `SKATE_TEST_CUSTOM_ASSETS` to run +that check on another local custom map; no user map bytes are committed. + +A subsequent offboard-query exit was traced to validating the unused fourth +SIMD lane as a position coordinate. Static swept-line queries now validate +only XYZ and radius, matching their dot3/Vector3 collision math. The regression +checks identical hits with infinite/NaN fourth lanes and rejection of invalid +XYZ/radius. Six contact-toolkit tests pass. The existing +`embedded_static_rwcm_hits_distinct_actor_query_ids` test fails its metadata +expectation (`matching_group == -1 && geometry == 0`) before executing the +changed query path; that unrelated fixture expectation remains unchanged. + +## Custom collision performance + +Portable map collision previously occupied one query mesh, so nearby queries +scanned the entire map. It now uses contiguous 64-triangle bounds in the +existing static hierarchy, built once during loading. Triangle order, geometry +IDs, filters, packed surfaces and adjacency remain unchanged. Native RWCM +clusters retain their existing path. + +A static query benchmark on the supplied custom map (89,613 triangles) sampled +257 local segments with identical candidate IDs/order before and after. +Triangle checks fell from 23,030,541 to 4,198,157; measured candidate-query time +fell from 16.51 ms to 3.82 ms total (about 4.3x). A 16-triangle partition was +also tried, but had higher query time (4.69 ms) despite fewer triangle checks. +These are CPU microbenchmark results, not gameplay FPS or GPU measurements. +Ten map tests passed. The supplied-map benchmark is opt-in via +`SKATE_TEST_CUSTOM_MAP`. Frame timing can be captured with the existing +`SKATE_PERF_REPORT` environment setting (10-second warmup, report/exit after +25 seconds); no automated gameplay was run. + +## Frame capture: mesh binding preparation + +The user-run capture after collision clustering measured 83.5 FPS / 11.98 ms +mean frame time. Main schedule time was 2.80 ms, physics 1.55 ms/frame, and the +render mesh-bind-group preparation section 9.75 ms. CPU query improvements +therefore did not address the dominant measured render preparation cost. + +The vendored renderer now caches complete per-phase model/skin/morph/lightmap +binding sets, retaining at most four rotating allocation combinations per +phase. Buffer contents can update without recreating descriptors. Keys include +model allocation/offset/size, current/previous skin and morph buffer IDs, morph +mesh/texture/skin-layout identities, lightmap revision and layout identity. +Pipeline changes clear the cache; removed phases and replaced map resources +retire their entries. The existing opt-in GPU cache test covers rotating +allocations, reuse, size changes, skin/morph replacement and bounded retention. +A new gameplay capture is required to quantify the resulting FPS change. + +Validation: release build check passed, and the explicit Vulkan GPU cache +regression test passed. No gameplay was launched for this change. + +## Remaining hitch investigation + +The next user capture measured 525.1 FPS, 1.90 ms mean frame time and 0.126 ms +mesh binding preparation. Two clusters of slow frames remained; the maximum +was 26.71 ms, with only 4.01 ms in the main schedule and 2.71 ms in physics. +Another 25.53 ms frame contained no physics tick. The aggregate render metrics +cannot identify those individual stalls. No specific hitch cause is confirmed. + +The opt-in profiler now retains timestamped main/render samples, pending +pipeline counts, and bounded slow-section events (at least 2 ms, at most 256). +Main/render sample buffers are preallocated. Reports still write only at the +end of the capture; normal launches do not enable this instrumentation. The +release compile check passed. Another user-run capture is required to locate +the remaining stalls; no game or gameplay automation was launched. + +## Day/night controls + +Day & night in the pause menu offers 15-minute time steps and speeds Frozen, +1x, 10x, 30x, 60x, 120x, 360x and 720x (two minutes per day). Click or Right +advances; Left reverses. Both settings persist in settings/graphics.json. +The running clock wraps at midnight and pauses with gameplay. Menu adjustments +preview immediately. The cycle affects only custom map scene-owned lights; +retail and the test world keep their existing lighting. Local lights and baked +lightmaps retain their authored values, so baked surfaces may remain bright at +night. The existing background colour blends toward dark blue at night; this +is not a new sky dome or star renderer. No assets are rebuilt each frame. + +CPU tests cover wraparound, pause/freeze, sun orbit, zero authored intensity, +settings compatibility and custom/retail scene retirement. Visual appearance +and runtime FPS require a manual run; the assistant does not launch gameplay. + +Validation: seven focused CPU tests passed (graphics_menu::tests and +map_render::tests); the user-map preparation test was not rerun. + +Ambient light in the same submenu defaults to Auto (the day/night ambient). +Left/Right or click cycles Auto and 0–100% in 5% steps. Manual levels override +the cycle's ambient brightness, mapping 0% to zero and 100% to 1,000 brightness. +They preview while paused and persist with graphics settings. Direct sun/moon, +local lights, emissive materials and baked lightmaps are independent, so zero +ambient does not make the whole scene black. Retail lighting remains authored. + +## Visible sun and moon + +Custom maps now have warm sun and cool full-moon discs following the same orbit +as their directional lighting. They follow the gameplay camera without parallax, +are hidden below the horizon, and use normal depth testing for scenery occlusion. +They are placed just inside the camera far plane and have fixed angular sizes. +These are lightweight unlit meshes, not extra lights or shadow casters. They +remain visible with zero ambient. Their scene-owned meshes/materials retire on +map switches; retail skies are unchanged. Moon phases are not added. + +The sun and moon now have roughly 3.3x their original apparent diameter. +Two scene-owned 256px procedural textures add a feathered golden solar corona +and cool lunar halo with crater rims and mottled surface detail. The solar +corona rotates slowly with a subtle pulse while gameplay runs. Texture creation +stays in map preparation; runtime still uses two mesh draws and transform updates. diff --git a/vendor/bevy_pbr/src/lightmap/cache_tests.rs b/vendor/bevy_pbr/src/lightmap/cache_tests.rs index 87b97368..c40ef91c 100644 --- a/vendor/bevy_pbr/src/lightmap/cache_tests.rs +++ b/vendor/bevy_pbr/src/lightmap/cache_tests.rs @@ -99,6 +99,7 @@ fn cache_tracks_binding_identity_and_texture_replacement() { mip_level_count: 1, had_data: false, }; + crate::render::mesh::check_phase_mesh_cache(&device, &a, &b, &image.texture_view); let fallback = FallbackImage { d1: image.clone(), d2: image.clone(), diff --git a/vendor/bevy_pbr/src/render/mesh.rs b/vendor/bevy_pbr/src/render/mesh.rs index 3a254197..e98b3c86 100644 --- a/vendor/bevy_pbr/src/render/mesh.rs +++ b/vendor/bevy_pbr/src/render/mesh.rs @@ -2745,7 +2745,7 @@ impl SpecializedMeshPipeline for MeshPipeline { /// /// If GPU mesh preprocessing isn't in use, these are global to the scene. If /// GPU mesh preprocessing is in use, these are specific to a single phase. -#[derive(Default)] +#[derive(Default, Clone)] pub struct MeshPhaseBindGroups { model_only: Option, skinned: Option, @@ -2843,11 +2843,96 @@ impl PhaseLightmapCache { } } +#[derive(Clone)] pub struct MeshBindGroupPair { motion_vectors: BindGroup, no_motion_vectors: BindGroup, } +/// Bindings refer to allocations, not their current animated contents. Keep a +/// bounded set for the rotating current/previous skin and morph buffers. +#[derive(Default)] +pub struct PhaseMeshCache { + entries: std::collections::VecDeque<(LightmapTableKey, MeshBindingSignature, MeshPhaseBindGroups)>, +} + +#[derive(PartialEq, Eq)] +struct MeshBindingSignature { + skins: [BufferId; 2], + weights: [Option; 2], + morphs: Vec<(AssetId, TextureViewId, bool)>, + lightmaps: u64, + layout: BindGroupLayoutId, +} + +impl PhaseMeshCache { + fn get(&mut self, model: &BindingResource, signature: &MeshBindingSignature) -> Option { + let BindingResource::Buffer(model) = model else { return None; }; + let index = self.entries.iter().position(|(key, old, _)| { + &*key.buffer == model.buffer && key.offset == model.offset && key.size == model.size && old == signature + })?; + let entry = self.entries.remove(index).unwrap(); + let groups = entry.2.clone(); + self.entries.push_front(entry); + Some(groups) + } + + fn insert(&mut self, model: &BindingResource, signature: MeshBindingSignature, groups: MeshPhaseBindGroups) { + let BindingResource::Buffer(model) = model else { return; }; + // Old map resources must not be held by the rotating-buffer cache. + self.entries.retain(|(_, old, _)| old.morphs == signature.morphs && old.lightmaps == signature.lightmaps && old.layout == signature.layout); + self.entries.push_front((LightmapTableKey { + buffer: Buffer::from(model.buffer.clone()), offset: model.offset, size: model.size, + layout: signature.layout, revision: signature.lightmaps, + }, signature, groups)); + self.entries.truncate(4); + } +} + +#[cfg(test)] +pub(crate) fn check_phase_mesh_cache(device: &RenderDevice, a: &Buffer, b: &Buffer, target: &TextureView) { + let layout = device.create_bind_group_layout(None, &[]); + let group = device.create_bind_group(None, &layout, &[]); + let model = |size| BindingResource::Buffer(BufferBinding { buffer: a, offset: 0, size: BufferSize::new(size) }); + let signature = |reverse| MeshBindingSignature { + skins: if reverse { [b.id(), a.id()] } else { [a.id(), b.id()] }, + weights: [Some(a.id()), Some(b.id())], + morphs: vec![(AssetId::default(), target.id(), true)], + lightmaps: 1, layout: layout.id(), + }; + let groups = || MeshPhaseBindGroups { model_only: Some(group.clone()), ..default() }; + let mut cache = PhaseMeshCache::default(); + for reverse in [false, true] { cache.insert(&model(256), signature(reverse), groups()); } + for _ in 0..16 { + for reverse in [true, false] { + assert_eq!(cache.get(&model(256), &signature(reverse)).unwrap().model_only.unwrap().id(), group.id()); + } + } + assert!(cache.get(&model(512), &signature(false)).is_none()); + let mut changed = signature(false); + changed.weights.swap(0, 1); + assert!(cache.get(&model(256), &changed).is_none()); + changed = signature(false); + changed.skins = [a.id(), a.id()]; + assert!(cache.get(&model(256), &changed).is_none()); + changed = signature(false); + changed.morphs[0].2 = false; + assert!(cache.get(&model(256), &changed).is_none()); + changed = signature(false); + changed.morphs.clear(); + assert!(cache.get(&model(256), &changed).is_none()); + cache.insert(&model(256), changed, groups()); + assert_eq!(cache.entries.len(), 1); + assert!(cache.get(&model(256), &signature(false)).is_none()); + for size in 1..16 { cache.insert(&model(size * 16), signature(false), groups()); } + assert_eq!(cache.entries.len(), 4); + changed = signature(false); + changed.lightmaps += 1; + assert!(cache.get(&model(256), &changed).is_none()); + cache.insert(&model(256), changed, groups()); + assert_eq!(cache.entries.len(), 1); +} + /// All bind groups for meshes currently loaded. #[derive(Resource)] pub enum MeshBindGroups { @@ -2919,7 +3004,9 @@ pub fn prepare_mesh_bind_groups( mut render_lightmaps: ResMut, mut lightmap_tables: Local>, mut morph_meshes: Local, + mut mesh_tables: Local>, ) { + if mesh_pipeline.is_changed() { mesh_tables.clear(); } if meshes.is_changed() { morph_meshes.refresh(&meshes); } @@ -2929,6 +3016,7 @@ pub fn prepare_mesh_bind_groups( .into_inner() .instance_data_binding() { + mesh_tables.retain(|phase, _| *phase == TypeId::of::<()>()); // In this path, we only have a single set of bind groups for all phases. let cpu_preprocessing_mesh_bind_groups = prepare_mesh_bind_groups_for_phase( instance_data_binding, @@ -2941,6 +3029,7 @@ pub fn prepare_mesh_bind_groups( &mut render_lightmaps, lightmap_tables.entry(TypeId::of::<()>()).or_default(), &morph_meshes.0, + mesh_tables.entry(TypeId::of::<()>()).or_default(), ); commands.insert_resource(MeshBindGroups::CpuPreprocessing( @@ -2974,11 +3063,13 @@ pub fn prepare_mesh_bind_groups( &mut render_lightmaps, lightmap_tables.entry(*phase_type_id).or_default(), &morph_meshes.0, + mesh_tables.entry(*phase_type_id).or_default(), ); gpu_preprocessing_mesh_bind_groups.insert(*phase_type_id, mesh_phase_bind_groups); } + mesh_tables.retain(|phase, _| gpu_batched_instance_buffers.phase_instance_buffers.contains_key(phase)); lightmap_tables.retain(|phase, _| { gpu_batched_instance_buffers .phase_instance_buffers @@ -3002,10 +3093,20 @@ fn prepare_mesh_bind_groups_for_phase( render_lightmaps: &mut RenderLightmaps, lightmap_table: &mut PhaseLightmapCache, morph_meshes: &[AssetId], + cache: &mut PhaseMeshCache, ) -> MeshPhaseBindGroups { let layouts = &mesh_pipeline.mesh_layouts; + let signature = MeshBindingSignature { + skins: [skins_uniform.current_buffer.id(), skins_uniform.prev_buffer.id()], + weights: [weights_uniform.current_buffer.buffer().map(Buffer::id), weights_uniform.prev_buffer.buffer().map(Buffer::id)], + morphs: morph_meshes.iter().filter_map(|&id| meshes.get(id).and_then(|mesh| + mesh.morph_targets.as_ref().map(|target| (id, target.id(), is_skinned(&mesh.layout))))).collect(), + lightmaps: render_lightmaps.binding_revision, + layout: pipeline_cache.get_bind_group_layout(&layouts.lightmapped).id(), + }; + if let Some(groups) = cache.get(&model, &signature) { return groups; } - // TODO: Reuse allocations. + // Buffer contents change each frame; bindings only change with resources. let mut groups = MeshPhaseBindGroups { model_only: Some(layouts.model_only(render_device, pipeline_cache, &model)), ..default() @@ -3103,6 +3204,7 @@ fn prepare_mesh_bind_groups_for_phase( lightmap_table.groups = std::sync::Arc::new(bindings); } groups.lightmaps = lightmap_table.groups.clone(); + cache.insert(&model, signature, groups.clone()); groups }