mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Switch maps in process with asynchronous loading and parallel decoding
This commit is contained in:
1 parent
da364c924f
commit
43943a71bc
20 files changed
+1074
-137
No files matched your search
@@ -0,0 +1,21 @@
|
||||
//! Data-only package timing tool. Does not initialize the engine or simulate.
|
||||
use std::{path::Path, time::Instant};
|
||||
fn main() {
|
||||
let compare = std::env::args().any(|arg| arg == "--compare");
|
||||
for path in std::env::args().skip(1).filter(|arg| arg != "--compare") {
|
||||
for pass in 0..if compare { 1 } else { 3 } {
|
||||
let start = Instant::now();
|
||||
let bytes = std::fs::read(Path::new(&path)).expect("read map");
|
||||
let read = start.elapsed();
|
||||
let start = Instant::now();
|
||||
let map = skate_data::skate_map::SkateMap::parse(&bytes).expect("decode map");
|
||||
println!("DATA_DECODE name={:?} pass={} read_ms={} parse_ms={} textures={} vertices={}",
|
||||
map.name, pass, read.as_millis(), start.elapsed().as_millis(), map.textures.len(), map.geometry.vertices.len());
|
||||
if compare {
|
||||
let serial = skate_data::skate_map::SkateMap::parse_with_decode_workers(&bytes, 1).expect("serial decode");
|
||||
assert!(map == serial, "parallel decoding changed package contents");
|
||||
println!("DATA_EQUAL name={:?} all_fields=true", map.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Little-endian SKATE01..14 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};
|
||||
use std::{io::Read, path::Path, time::Instant};
|
||||
mod texture_decode;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct SkateMap {
|
||||
pub version: u8,
|
||||
pub name: String,
|
||||
@@ -20,7 +21,7 @@ pub struct SkateMap {
|
||||
pub routes: Vec<Route>,
|
||||
pub extensions: Vec<Extension>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Material {
|
||||
pub name: String,
|
||||
pub flags: u32,
|
||||
@@ -41,7 +42,7 @@ pub struct Material {
|
||||
/// Complete v12+ retail definition bytes; distinct from portable PBR fields.
|
||||
pub retail_definition: Option<Vec<u8>>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Texture {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
@@ -49,7 +50,7 @@ pub struct Texture {
|
||||
pub color_space: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Vertex {
|
||||
pub position: [f32; 3],
|
||||
pub normal: [f32; 3],
|
||||
@@ -59,27 +60,27 @@ pub struct Vertex {
|
||||
pub decal_uv: Option<[f32; 2]>,
|
||||
pub tangent_frame: Option<[u8; 4]>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Collision {
|
||||
pub points: [[f32; 3]; 3],
|
||||
pub surface: u32,
|
||||
pub material: u32,
|
||||
pub native_edges: Option<[u8; 3]>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub vertices: Vec<Vertex>,
|
||||
pub indices: Vec<u32>,
|
||||
pub collision: Vec<Collision>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Rail {
|
||||
pub name: String,
|
||||
pub closed: bool,
|
||||
pub points: Vec<[f32; 3]>,
|
||||
pub native: Option<Vec<u8>>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Door {
|
||||
pub name: String,
|
||||
/// Hinge position/axis, closed width/depth axes, local min/max.
|
||||
@@ -93,7 +94,7 @@ pub struct Door {
|
||||
pub surface: u32,
|
||||
pub geometry: Geometry,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Light {
|
||||
pub name: String,
|
||||
pub kind: u32,
|
||||
@@ -106,14 +107,14 @@ pub struct Light {
|
||||
pub inner_cos: f32,
|
||||
pub outer_cos: f32,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Route {
|
||||
pub rail: Rail,
|
||||
pub skaters: u32,
|
||||
pub speed: f32,
|
||||
pub spacing: f32,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Extension {
|
||||
pub tag: [u8; 4],
|
||||
pub schema: u32,
|
||||
@@ -204,43 +205,17 @@ impl<'a> Reader<'a> {
|
||||
self.check_count(n, 12)?;
|
||||
(0..n).map(|_| self.floats()).collect()
|
||||
}
|
||||
fn stored(&mut self, expected: usize) -> Result<Vec<u8>, String> {
|
||||
fn stored_block(&mut self, expected: usize) -> Result<StoredBlock<'a>, String> {
|
||||
if expected > 2_147_483_648 {
|
||||
return Err("SKATE decoded block exceeds 2 GiB reader limit".into());
|
||||
}
|
||||
let method = self.u()?;
|
||||
let n = self.u()? as usize;
|
||||
let bytes = self.take(n)?;
|
||||
let decoded = match method {
|
||||
0 => bytes.to_vec(),
|
||||
1 => {
|
||||
let mut result = Vec::new();
|
||||
let mut decoder = flate2::read::ZlibDecoder::new(bytes);
|
||||
decoder
|
||||
.by_ref()
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut result)
|
||||
.map_err(|e| format!("SKATE DEFLATE: {e}"))?;
|
||||
if decoder.total_in() as usize != bytes.len() {
|
||||
return Err("SKATE compressed block has unused input".into());
|
||||
}
|
||||
result
|
||||
}
|
||||
2 => {
|
||||
let mut result = Vec::new();
|
||||
zstd::stream::read::Decoder::new(bytes)
|
||||
.map_err(|e| e.to_string())?
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut result)
|
||||
.map_err(|e| format!("SKATE Zstandard: {e}"))?;
|
||||
result
|
||||
}
|
||||
_ => return Err(format!("Unsupported SKATE storage method {method}")),
|
||||
};
|
||||
if decoded.len() != expected {
|
||||
return Err("SKATE decoded block size mismatch".into());
|
||||
}
|
||||
Ok(decoded)
|
||||
Ok(StoredBlock { expected, method, bytes })
|
||||
}
|
||||
fn stored(&mut self, expected: usize) -> Result<Vec<u8>, String> {
|
||||
self.stored_block(expected)?.decode()
|
||||
}
|
||||
fn geometry(
|
||||
&mut self,
|
||||
@@ -337,12 +312,63 @@ impl<'a> Reader<'a> {
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Borrow compressed payloads while scanning; decompression does not mutate
|
||||
/// the package or depend on any other texture.
|
||||
struct StoredBlock<'a> { expected: usize, method: u32, bytes: &'a [u8] }
|
||||
impl StoredBlock<'_> {
|
||||
fn decode(&self) -> Result<Vec<u8>, String> {
|
||||
let Self { expected, method, bytes } = *self;
|
||||
let decoded = match method {
|
||||
0 => bytes.to_vec(),
|
||||
1 => {
|
||||
let mut result = Vec::with_capacity(expected.min(8 * 1024 * 1024));
|
||||
let mut decoder = flate2::read::ZlibDecoder::new(bytes);
|
||||
decoder
|
||||
.by_ref()
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut result)
|
||||
.map_err(|e| format!("SKATE DEFLATE: {e}"))?;
|
||||
if decoder.total_in() as usize != bytes.len() {
|
||||
return Err("SKATE compressed block has unused input".into());
|
||||
}
|
||||
result
|
||||
}
|
||||
2 => {
|
||||
let mut result = Vec::with_capacity(expected.min(8 * 1024 * 1024));
|
||||
zstd::stream::read::Decoder::new(bytes)
|
||||
.map_err(|e| e.to_string())?
|
||||
.take(expected as u64 + 1)
|
||||
.read_to_end(&mut result)
|
||||
.map_err(|e| format!("SKATE Zstandard: {e}"))?;
|
||||
result
|
||||
}
|
||||
_ => return Err(format!("Unsupported SKATE storage method {method}")),
|
||||
};
|
||||
if decoded.len() != expected {
|
||||
return Err("SKATE decoded block size mismatch".into());
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
}
|
||||
impl SkateMap {
|
||||
pub fn load(path: &Path) -> Result<Self, String> {
|
||||
let started = Instant::now();
|
||||
let data = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&data).map_err(|e| format!("{}: {e}", path.display()))
|
||||
let disk = started.elapsed();
|
||||
let parse_started = Instant::now();
|
||||
let map = Self::parse(&data).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
eprintln!("MAP_READ_TIMING name={:?} disk_ms={} parse_ms={} file_bytes={}",
|
||||
map.name, disk.as_millis(), parse_started.elapsed().as_millis(), data.len());
|
||||
Ok(map)
|
||||
}
|
||||
pub fn parse(data: &[u8]) -> Result<Self, String> {
|
||||
let workers = std::thread::available_parallelism().map_or(1, |n| n.get().min(4));
|
||||
Self::parse_with_decode_workers(data, workers)
|
||||
}
|
||||
/// Data tools can request one worker for reproducible serial comparisons.
|
||||
/// The decoder caps concurrency at eight and skips threading for small maps.
|
||||
pub fn parse_with_decode_workers(data: &[u8], workers: usize) -> Result<Self, String> {
|
||||
let parse_started = Instant::now();
|
||||
let mut r = Reader { bytes: data, at: 0 };
|
||||
let magic = r.take(8)?;
|
||||
if &magic[..5] != b"SKATE"
|
||||
@@ -470,8 +496,10 @@ impl SkateMap {
|
||||
}
|
||||
materials.push(m);
|
||||
}
|
||||
let texture_started = Instant::now();
|
||||
r.check_count(counts[1], 20)?;
|
||||
let mut textures = Vec::new();
|
||||
let mut texture_blocks = Vec::new();
|
||||
for _ in 0..counts[1] {
|
||||
let name = r.string()?;
|
||||
let width = r.u()?;
|
||||
@@ -481,23 +509,27 @@ impl SkateMap {
|
||||
return Err("Invalid SKATE embedded texture".into());
|
||||
}
|
||||
let expected = width as usize * height as usize * 4;
|
||||
let rgba = if version >= 9 {
|
||||
r.stored(expected)?
|
||||
let block = if version >= 9 {
|
||||
r.stored_block(expected)?
|
||||
} else {
|
||||
let bytes = r.u()? as usize;
|
||||
if bytes != expected {
|
||||
return Err("Invalid SKATE embedded texture size".into());
|
||||
}
|
||||
r.take(bytes)?.to_vec()
|
||||
StoredBlock { expected, method: 0, bytes: r.take(bytes)? }
|
||||
};
|
||||
texture_blocks.push(block);
|
||||
textures.push(Texture {
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
color_space,
|
||||
rgba,
|
||||
rgba: Vec::new(),
|
||||
});
|
||||
}
|
||||
let texture_workers = texture_decode::decode(&mut textures, &texture_blocks, workers)?;
|
||||
let texture_time = texture_started.elapsed();
|
||||
let geometry_started = Instant::now();
|
||||
let geometry_counts = [counts[2], counts[3], counts[4]];
|
||||
let geometry = if version >= 9 {
|
||||
let mut bytes = r.stored(counts[2] as usize * if version >= 12 { 56 } else { 44 })?;
|
||||
@@ -511,6 +543,7 @@ impl SkateMap {
|
||||
} else {
|
||||
r.geometry(geometry_counts, materials.len(), version)?
|
||||
};
|
||||
let geometry_time = geometry_started.elapsed();
|
||||
if materials.is_empty()
|
||||
|| geometry.vertices.is_empty()
|
||||
|| geometry.indices.is_empty()
|
||||
@@ -622,6 +655,7 @@ impl SkateMap {
|
||||
spacing,
|
||||
});
|
||||
}
|
||||
let extension_started = Instant::now();
|
||||
let mut extensions = Vec::new();
|
||||
if version >= 12 {
|
||||
let n = r.u()?;
|
||||
@@ -646,6 +680,9 @@ impl SkateMap {
|
||||
data.len() - r.at
|
||||
));
|
||||
}
|
||||
eprintln!("MAP_DECODE_TIMING name={:?} textures_ms={} texture_workers={} geometry_ms={} extensions_ms={} total_ms={}",
|
||||
name, texture_time.as_millis(), texture_workers, geometry_time.as_millis(),
|
||||
extension_started.elapsed().as_millis(), parse_started.elapsed().as_millis());
|
||||
Ok(Self {
|
||||
version,
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Bounded parallel decompression with deterministic texture indices.
|
||||
use super::{StoredBlock, Texture};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
pub(super) fn decode(textures: &mut [Texture], blocks: &[StoredBlock<'_>], workers: usize) -> Result<usize, String> {
|
||||
let bytes = blocks.iter().fold(0usize, |sum, block| sum.saturating_add(block.expected));
|
||||
let workers = if bytes < 1024 * 1024 { 1 } else { workers.clamp(1, 8).min(blocks.len().max(1)) };
|
||||
if workers == 1 {
|
||||
for (texture, block) in textures.iter_mut().zip(blocks) { texture.rgba = block.decode()?; }
|
||||
return Ok(1);
|
||||
}
|
||||
// Dynamic assignment balances many tiny textures against a few large ones.
|
||||
// Compressed slices borrow the original file; no second package copy exists.
|
||||
let next = AtomicUsize::new(0);
|
||||
let decode_batch = || -> Result<Vec<(usize, Vec<u8>)>, String> {
|
||||
let mut decoded = Vec::new();
|
||||
loop {
|
||||
let index = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(block) = blocks.get(index) else { break; };
|
||||
decoded.push((index, block.decode().map_err(|e| format!("Texture {index}: {e}"))?));
|
||||
}
|
||||
Ok(decoded)
|
||||
};
|
||||
let decoded = std::thread::scope(|scope| -> Result<_, String> {
|
||||
let mut jobs = Vec::new();
|
||||
for _ in 1..workers {
|
||||
jobs.push(std::thread::Builder::new().name("map-texture-decode".into())
|
||||
.spawn_scoped(scope, &decode_batch).map_err(|e| format!("Texture worker: {e}"))?);
|
||||
}
|
||||
let local = decode_batch();
|
||||
// Join every worker before returning either success or an error.
|
||||
let batches: Vec<_> = jobs.into_iter().map(|job| job.join()
|
||||
.unwrap_or_else(|_| Err("Texture decoder failed unexpectedly".into()))).collect();
|
||||
let mut decoded = local?;
|
||||
for batch in batches { decoded.extend(batch?); }
|
||||
Ok(decoded)
|
||||
})?;
|
||||
for (index, rgba) in decoded { textures[index].rgba = rgba; }
|
||||
Ok(workers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn mixed_storage_keeps_order_and_rejects_corruption_in_parallel() {
|
||||
let raw: Vec<Vec<u8>> = (0..16).map(|i| vec![i as u8; (i + 1) * 32 * 1024]).collect();
|
||||
let mut encoded: Vec<Vec<u8>> = raw.iter().enumerate().map(|(i, bytes)| match i % 3 {
|
||||
0 => bytes.clone(),
|
||||
1 => {
|
||||
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
encoder.write_all(bytes).unwrap();
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
_ => zstd::stream::encode_all(bytes.as_slice(), 1).unwrap(),
|
||||
}).collect();
|
||||
let make_textures = || (0..raw.len()).map(|i| Texture {
|
||||
name: format!("texture-{i}"), width: 1, height: 1, color_space: 0, rgba: Vec::new(),
|
||||
}).collect::<Vec<_>>();
|
||||
{
|
||||
let blocks: Vec<_> = encoded.iter().enumerate().map(|(i, bytes)| StoredBlock {
|
||||
bytes, method: (i % 3) as u32, expected: raw[i].len(),
|
||||
}).collect();
|
||||
let mut serial = make_textures();
|
||||
decode(&mut serial, &blocks, 1).unwrap();
|
||||
for workers in [2, 4, usize::MAX] {
|
||||
let mut parallel = make_textures();
|
||||
assert_eq!(decode(&mut parallel, &blocks, workers).unwrap(), workers.min(8));
|
||||
assert_eq!(serial, parallel);
|
||||
for (texture, bytes) in parallel.iter().zip(&raw) { assert_eq!(&texture.rgba, bytes); }
|
||||
}
|
||||
}
|
||||
encoded[5].truncate(3);
|
||||
let blocks: Vec<_> = encoded.iter().enumerate().map(|(i, bytes)| StoredBlock {
|
||||
bytes, method: (i % 3) as u32, expected: raw[i].len(),
|
||||
}).collect();
|
||||
assert!(decode(&mut make_textures(), &blocks, 4).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_block_retains_size_and_deflate_tail_validation() {
|
||||
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
encoder.write_all(&[7; 64]).unwrap();
|
||||
let mut bytes = encoder.finish().unwrap();
|
||||
assert!(StoredBlock { expected: 63, method: 1, bytes: &bytes }.decode().is_err());
|
||||
bytes.push(42);
|
||||
assert!(StoredBlock { expected: 64, method: 1, bytes: &bytes }.decode().is_err());
|
||||
assert!(StoredBlock { expected: 1, method: 0, bytes: &[] }.decode().is_err());
|
||||
assert!(StoredBlock { expected: 0, method: 99, bytes: &[] }.decode().is_err());
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn build(
|
||||
crate::grind_world::GrindGeometryPlugin,
|
||||
camera::CameraPlugin,
|
||||
crate::graphics_menu::GraphicsMenuPlugin,
|
||||
crate::map_transition::MapTransitionPlugin,
|
||||
crate::render_capacity::RenderCapacityPlugin,
|
||||
verification::VerificationPlugin,
|
||||
crate::performance::PerformancePlugin,
|
||||
|
||||
@@ -68,3 +68,43 @@ fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
||||
camera.is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the presentation camera/target/settings while replacing world-specific
|
||||
/// postprocessing. A procedural scene must not inherit native HDR/tone state.
|
||||
pub(crate) fn set_world_environment(world: &mut World, retail: bool) {
|
||||
let cameras: Vec<_> = world.query_filtered::<Entity, With<GameplayCamera>>().iter(world).collect();
|
||||
for id in cameras {
|
||||
let mut camera = world.entity_mut(id);
|
||||
if retail {
|
||||
camera.insert((bevy::render::view::Hdr, bevy::core_pipeline::tonemapping::Tonemapping::None,
|
||||
crate::retail_render::RetailTone::default()));
|
||||
} else {
|
||||
camera.remove::<(bevy::render::view::Hdr, crate::retail_render::RetailTone)>();
|
||||
camera.insert(bevy::core_pipeline::tonemapping::Tonemapping::default());
|
||||
}
|
||||
camera.get_mut::<Camera>().unwrap().is_active = false;
|
||||
*camera.get_mut::<Transform>().unwrap() = Transform::default();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod environment_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn native_procedural_switch_clears_tone_hdr_and_retains_camera_settings() {
|
||||
let mut world = World::new();
|
||||
let id = world.spawn((GameplayCamera, Camera3d::default(), Msaa::Sample4,
|
||||
bevy::camera::RenderTarget::default())).id();
|
||||
for retail in [true, false, true, false] {
|
||||
set_world_environment(&mut world, retail);
|
||||
let entity = world.entity(id);
|
||||
assert_eq!(entity.contains::<bevy::render::view::Hdr>(), retail);
|
||||
assert_eq!(entity.contains::<crate::retail_render::RetailTone>(), retail);
|
||||
assert_eq!(*entity.get::<Msaa>().unwrap(), Msaa::Sample4);
|
||||
assert!(!entity.get::<Camera>().unwrap().is_active);
|
||||
assert_eq!(*entity.get::<bevy::core_pipeline::tonemapping::Tonemapping>().unwrap(),
|
||||
if retail { bevy::core_pipeline::tonemapping::Tonemapping::None }
|
||||
else { bevy::core_pipeline::tonemapping::Tonemapping::default() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub(crate) struct Config {
|
||||
pub map_path: Option<PathBuf>,
|
||||
pub difficulty: crate::difficulty::Difficulty,
|
||||
pub check_assets: bool,
|
||||
pub start_paused: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -20,6 +21,7 @@ impl Config {
|
||||
map_path: None,
|
||||
difficulty: crate::difficulty::Difficulty::default(),
|
||||
check_assets: false,
|
||||
start_paused: false,
|
||||
};
|
||||
let mut difficulty_override = None;
|
||||
let mut explicit_map = false;
|
||||
@@ -37,6 +39,7 @@ impl Config {
|
||||
}
|
||||
Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; }
|
||||
Some("--check-assets") => config.check_assets = true,
|
||||
Some("--start-paused") => config.start_paused = true,
|
||||
Some("--difficulty") => {
|
||||
let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?;
|
||||
difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?);
|
||||
@@ -50,7 +53,7 @@ impl Config {
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png] [--check-assets]"
|
||||
"Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png] [--check-assets] [--start-paused]"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,12 @@ pub(crate) struct Menu {
|
||||
maps: Vec<crate::map_library::Entry>,
|
||||
selected_map: usize,
|
||||
}
|
||||
impl Menu {
|
||||
pub(crate) fn transition_finished(&mut self, status: String, resume: bool) {
|
||||
self.status = status;
|
||||
self.open = !resume;
|
||||
}
|
||||
}
|
||||
pub(crate) fn gameplay_active(menu: Option<Res<Menu>>) -> bool {
|
||||
menu.is_none_or(|m| !m.open)
|
||||
}
|
||||
@@ -101,12 +107,16 @@ struct MenuLabel(usize);
|
||||
#[derive(Component)]
|
||||
struct StatusLabel;
|
||||
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct MenuInput;
|
||||
|
||||
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(PreUpdate, interact.in_set(MenuInput).after(bevy::input::InputSystems))
|
||||
.add_systems(Update, (apply, labels).chain())
|
||||
.add_systems(Last, pace);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +127,7 @@ fn setup(
|
||||
mut window: Single<&mut Window, With<PrimaryWindow>>,
|
||||
cameras: Query<Entity, With<Camera3d>>,
|
||||
adapter: Res<RenderAdapter>,
|
||||
mut time: ResMut<Time<Virtual>>,
|
||||
) {
|
||||
let path = config
|
||||
.asset_root
|
||||
@@ -211,8 +222,9 @@ fn setup(
|
||||
commands.insert_resource(SceneTarget(target));
|
||||
let maps = crate::map_library::discover(&config.asset_root);
|
||||
let selected_map = maps.iter().position(|m| m.path.as_ref() == config.map_path.as_ref()).unwrap_or(0);
|
||||
if config.start_paused { time.pause(); }
|
||||
commands.insert_resource(Menu {
|
||||
open: false,
|
||||
open: config.start_paused,
|
||||
selected: 0,
|
||||
settings,
|
||||
path,
|
||||
@@ -236,7 +248,8 @@ fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
|
||||
values[(index + direction).rem_euclid(values.len() as i32) as usize]
|
||||
}
|
||||
fn interact(
|
||||
config: Res<crate::config::Config>,
|
||||
mut config: ResMut<crate::config::Config>,
|
||||
mut transition: ResMut<crate::map_transition::MapTransition>,
|
||||
mut physics: ResMut<crate::physics::GamePhysics>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut menu: ResMut<Menu>,
|
||||
@@ -244,6 +257,11 @@ fn interact(
|
||||
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
if transition.busy() {
|
||||
menu.open = true;
|
||||
time.pause();
|
||||
return;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
menu.open = !menu.open;
|
||||
}
|
||||
@@ -288,6 +306,7 @@ fn interact(
|
||||
5 => {
|
||||
menu.difficulty = cycle(&Difficulty::ALL, menu.difficulty, direction);
|
||||
physics.set_difficulty(menu.difficulty);
|
||||
config.difficulty = menu.difficulty;
|
||||
menu.status = match menu.difficulty.save(&config.asset_root) {
|
||||
Ok(()) => "Difficulty saved".into(),
|
||||
Err(e) => format!("Applied, but could not save: {e}"),
|
||||
@@ -299,10 +318,8 @@ fn interact(
|
||||
menu.status = "Choose Load map to switch".into();
|
||||
}
|
||||
7 => {
|
||||
match crate::map_library::switch(&config.asset_root, &menu.maps[menu.selected_map]) {
|
||||
Ok(()) => { exit.write(AppExit::Success); }
|
||||
Err(e) => menu.status = e,
|
||||
}
|
||||
transition.request(menu.maps[menu.selected_map].clone());
|
||||
menu.status = "Loading map...".into();
|
||||
}
|
||||
8 => menu.open = false,
|
||||
9 => {
|
||||
@@ -380,6 +397,8 @@ fn apply(
|
||||
}
|
||||
fn labels(
|
||||
menu: Res<Menu>,
|
||||
transition: Res<crate::map_transition::MapTransition>,
|
||||
time: Res<Time<Real>>,
|
||||
window: Single<&Window, With<PrimaryWindow>>,
|
||||
mut root: Single<&mut Node, With<MenuRoot>>,
|
||||
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
|
||||
@@ -422,12 +441,14 @@ fn labels(
|
||||
4 => format!("Occlusion culling {}", if s.occlusion { "On" } else { "Off" }),
|
||||
5 => format!("Difficulty {}", menu.difficulty.label()),
|
||||
6 => format!("Map {}", menu.maps[menu.selected_map].label),
|
||||
7 => "Load map (restarts session)".into(),
|
||||
7 => if transition.busy() { "Loading map...".into() } else { "Load map".into() },
|
||||
8 => "Resume".into(),
|
||||
_ => "Quit game".into(),
|
||||
};
|
||||
}
|
||||
***status = menu.status.clone();
|
||||
***status = if transition.busy() {
|
||||
format!("{} {}\nGameplay is paused. Please wait.", ["|", "/", "-", "\\"][(time.elapsed_secs() * 4.) as usize % 4], transition.label())
|
||||
} else { 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)
|
||||
@@ -462,6 +483,7 @@ 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(),
|
||||
maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0,
|
||||
})
|
||||
.add_systems(Update, apply);
|
||||
app.world_mut().spawn((Window::default(), PrimaryWindow));
|
||||
|
||||
@@ -15,11 +15,7 @@ pub(crate) struct GrindGeometryPlugin;
|
||||
impl Plugin for GrindGeometryPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let test_world = app.world().resource::<crate::config::Config>().map.is_none();
|
||||
let geometry = if test_world {
|
||||
GrindGeometry { rails: rails().to_vec(), native_blob: spline_blob() }
|
||||
} else {
|
||||
GrindGeometry { rails: Vec::new(), native_blob: Vec::new() }
|
||||
};
|
||||
let geometry = GrindGeometry::for_world(test_world);
|
||||
if test_world {
|
||||
info!("GRIND_GEOMETRY rails={} bytes={} native_acquisition=paired_trucks_50_50",
|
||||
geometry.rails.len(), geometry.native_blob.len());
|
||||
@@ -32,6 +28,13 @@ impl Plugin for GrindGeometryPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
impl GrindGeometry {
|
||||
pub(crate) fn for_world(test_world: bool) -> Self {
|
||||
if test_world { Self { rails: rails().to_vec(), native_blob: spline_blob() } }
|
||||
else { Self { rails: Vec::new(), native_blob: Vec::new() } }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Rail {
|
||||
pub id: u64,
|
||||
|
||||
@@ -6,6 +6,8 @@ mod camera;
|
||||
mod config;
|
||||
mod setup;
|
||||
mod map_library;
|
||||
mod map_render;
|
||||
mod map_transition;
|
||||
mod difficulty;
|
||||
mod graph_host;
|
||||
mod graph_runtime;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Installed maps are selected by path; switching restarts the whole simulation.
|
||||
//! This prevents old collision bodies, graph state and GPU assets surviving a change.
|
||||
use std::{path::{Path, PathBuf}, process::Command};
|
||||
//! Installed map discovery and the default pointer, shared by startup and the menu.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Entry {
|
||||
pub label: String,
|
||||
pub path: Option<PathBuf>,
|
||||
@@ -17,7 +17,7 @@ pub(crate) fn discover(assets: &Path) -> Vec<Entry> {
|
||||
if path.is_file() && path.extension().is_some_and(|e| e.eq_ignore_ascii_case("skate")) {
|
||||
maps.push(Entry {
|
||||
label: path.file_stem().unwrap().to_string_lossy().replace('_', " "),
|
||||
path: Some(path),
|
||||
path: Some(path.canonicalize().unwrap_or(path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ pub(crate) fn default_map(assets: &Path) -> Result<Option<PathBuf>, String> {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(format!("{}: {e}", pointer.display())),
|
||||
};
|
||||
let relative: String = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
let relative: Option<String> = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
let Some(relative) = relative else { return Ok(None); };
|
||||
let relative = Path::new(&relative);
|
||||
if relative.is_absolute() || relative.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
|
||||
return Err("Invalid installed default map path".into());
|
||||
@@ -45,18 +46,34 @@ pub(crate) fn default_map(assets: &Path) -> Result<Option<PathBuf>, String> {
|
||||
Ok(Some(assets.parent().unwrap_or(assets).join(relative)))
|
||||
}
|
||||
|
||||
pub(crate) fn switch(assets: &Path, entry: &Entry) -> Result<(), String> {
|
||||
if let Some(path) = &entry.path {
|
||||
let map = skate_data::skate_map::SkateMap::load(path)?;
|
||||
crate::skate_world::validate_runtime(&map)?;
|
||||
/// Save only a successfully activated map. Null explicitly selects the test world.
|
||||
pub(crate) fn save_default(assets: &Path, map: Option<&Path>) -> Result<(), String> {
|
||||
let root = assets.parent().unwrap_or(assets);
|
||||
let relative = map.map(|path| path.strip_prefix(root)
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.map_err(|_| "Map is outside this installation; default map unchanged".to_string())).transpose()?;
|
||||
let directory = root.join("settings");
|
||||
std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
|
||||
let temporary = directory.join("default-map.json.tmp");
|
||||
std::fs::write(&temporary, serde_json::to_vec_pretty(&relative).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::rename(temporary, directory.join("default-map.json")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn default_pointer_round_trips_map_and_explicit_test_world() {
|
||||
let root = std::env::temp_dir().join(format!("skate-map-pointer-{}", std::process::id()));
|
||||
let assets = root.join("assets");
|
||||
let map = root.join("maps/example.skate");
|
||||
save_default(&assets, Some(&map)).unwrap();
|
||||
assert_eq!(default_map(&assets).unwrap(), Some(map));
|
||||
save_default(&assets, None).unwrap();
|
||||
assert_eq!(default_map(&assets).unwrap(), None);
|
||||
std::fs::write(root.join("settings/default-map.json"), br#""../outside.skate""#).unwrap();
|
||||
assert!(default_map(&assets).is_err());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
|
||||
command.arg("--assets").arg(assets);
|
||||
if let Some(path) = &entry.path {
|
||||
command.arg("--map").arg(path);
|
||||
} else {
|
||||
command.arg("--test-world");
|
||||
}
|
||||
command.spawn().map_err(|e| format!("Could not open map: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! CPU scene construction can run off-thread using the live asset allocators.
|
||||
//! Only publishing assets/entities and retiring the previous scene touch the ECS.
|
||||
use bevy::{asset::{AssetHandleProvider, AssetId}, ecs::world::CommandQueue, prelude::*};
|
||||
use crate::retail_render::{RetailSkyMaterial, RetailWorldMaterial};
|
||||
|
||||
#[derive(Component)]
|
||||
pub(crate) struct MapEntity;
|
||||
|
||||
pub(crate) trait AssetSink<A: Asset> {
|
||||
fn add(&mut self, asset: A) -> Handle<A>;
|
||||
}
|
||||
impl<A: Asset> AssetSink<A> for Assets<A> {
|
||||
fn add(&mut self, asset: A) -> Handle<A> { Assets::add(self, asset) }
|
||||
}
|
||||
|
||||
pub(crate) struct StagedAssets<A: Asset> {
|
||||
provider: AssetHandleProvider,
|
||||
values: Vec<(Handle<A>, A)>,
|
||||
}
|
||||
impl<A: Asset> StagedAssets<A> {
|
||||
fn new(world: &World) -> Self {
|
||||
Self { provider: world.resource::<Assets<A>>().get_handle_provider(), values: Vec::new() }
|
||||
}
|
||||
fn publish(self, world: &mut World) -> Vec<AssetId<A>> {
|
||||
let mut assets = world.resource_mut::<Assets<A>>();
|
||||
self.values.into_iter().map(|(handle, asset)| {
|
||||
let id = handle.id();
|
||||
// Reserved from this Assets' allocator; the staged strong handle is
|
||||
// alive throughout preparation, so its generation cannot be reused.
|
||||
assets.insert(id, asset).expect("reserved map asset generation");
|
||||
id
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
impl<A: Asset> AssetSink<A> for StagedAssets<A> {
|
||||
fn add(&mut self, asset: A) -> Handle<A> {
|
||||
let handle = self.provider.reserve_handle().typed::<A>();
|
||||
self.values.push((handle.clone(), asset));
|
||||
handle
|
||||
}
|
||||
}
|
||||
|
||||
/// Deferred spawns allocate their ECS identities only when the scene commits.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SceneCommands(CommandQueue);
|
||||
pub(crate) struct SceneEntity<'a> {
|
||||
commands: &'a mut CommandQueue,
|
||||
id: std::sync::Arc<std::sync::OnceLock<Entity>>,
|
||||
}
|
||||
impl SceneCommands {
|
||||
pub fn spawn<B: Bundle>(&mut self, bundle: B) -> SceneEntity<'_> {
|
||||
let id = std::sync::Arc::new(std::sync::OnceLock::new());
|
||||
let spawned = id.clone();
|
||||
self.0.push(move |world: &mut World| {
|
||||
spawned.set(world.spawn((MapEntity, bundle)).id()).unwrap();
|
||||
});
|
||||
SceneEntity { commands: &mut self.0, id }
|
||||
}
|
||||
pub fn insert_resource<R: Resource>(&mut self, resource: R) {
|
||||
self.0.push(move |world: &mut World| { world.insert_resource(resource); });
|
||||
}
|
||||
}
|
||||
impl SceneEntity<'_> {
|
||||
pub fn insert<B: Bundle>(&mut self, bundle: B) {
|
||||
let id = self.id.clone();
|
||||
self.commands.push(move |world: &mut World| {
|
||||
world.entity_mut(*id.get().expect("scene spawn precedes insert")).insert(bundle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedScene {
|
||||
commands: SceneCommands,
|
||||
meshes: StagedAssets<Mesh>,
|
||||
materials: StagedAssets<StandardMaterial>,
|
||||
retail: StagedAssets<RetailWorldMaterial>,
|
||||
sky: StagedAssets<RetailSkyMaterial>,
|
||||
images: StagedAssets<Image>,
|
||||
}
|
||||
impl PreparedScene {
|
||||
pub fn new(world: &World) -> Self {
|
||||
Self { commands: Default::default(), meshes: StagedAssets::new(world),
|
||||
materials: StagedAssets::new(world), retail: StagedAssets::new(world),
|
||||
sky: StagedAssets::new(world), images: StagedAssets::new(world) }
|
||||
}
|
||||
pub fn prepare(&mut self, map: Option<&skate_data::skate_map::SkateMap>, root: &std::path::Path) {
|
||||
// Every scene starts with the same environment defaults. Map-specific
|
||||
// resources below overwrite these, including after a native scene.
|
||||
self.commands.insert_resource(ClearColor(Color::srgb(0.065, 0.08, 0.10)));
|
||||
self.commands.insert_resource(GlobalAmbientLight {
|
||||
color: Color::WHITE, brightness: 350., ..default()
|
||||
});
|
||||
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::spawn_sky(&map.name, root, &mut self.commands,
|
||||
&mut self.meshes, &mut self.images, &mut self.sky);
|
||||
} else {
|
||||
crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials);
|
||||
}
|
||||
}
|
||||
pub fn publish(mut self, world: &mut World) {
|
||||
let owned = MapAssets {
|
||||
meshes: self.meshes.publish(world), materials: self.materials.publish(world),
|
||||
retail: self.retail.publish(world), sky: self.sky.publish(world),
|
||||
images: self.images.publish(world),
|
||||
};
|
||||
self.commands.0.apply(world);
|
||||
world.insert_resource(owned);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MapAssets {
|
||||
meshes: Vec<AssetId<Mesh>>,
|
||||
materials: Vec<AssetId<StandardMaterial>>,
|
||||
retail: Vec<AssetId<RetailWorldMaterial>>,
|
||||
sky: Vec<AssetId<RetailSkyMaterial>>,
|
||||
images: Vec<AssetId<Image>>,
|
||||
}
|
||||
impl MapAssets {
|
||||
pub fn retire(world: &mut World) {
|
||||
let entities: Vec<_> = world.query_filtered::<Entity, With<MapEntity>>().iter(world).collect();
|
||||
for entity in entities { world.despawn(entity); }
|
||||
if let Some(owned) = world.remove_resource::<Self>() {
|
||||
fn remove<A: Asset>(world: &mut World, ids: Vec<AssetId<A>>) {
|
||||
let mut assets = world.resource_mut::<Assets<A>>();
|
||||
for id in ids { assets.remove(id); }
|
||||
}
|
||||
remove(world, owned.meshes);
|
||||
remove(world, owned.materials);
|
||||
remove(world, owned.retail);
|
||||
remove(world, owned.sky);
|
||||
remove(world, owned.images);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Assets<Mesh>>();
|
||||
world.init_resource::<Assets<StandardMaterial>>();
|
||||
world.init_resource::<Assets<RetailWorldMaterial>>();
|
||||
world.init_resource::<Assets<RetailSkyMaterial>>();
|
||||
world.init_resource::<Assets<Image>>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_scene_retirement_releases_owned_assets_and_preserves_character() {
|
||||
let mut world = world();
|
||||
let character_mesh = world.resource_mut::<Assets<Mesh>>().add(Cuboid::default());
|
||||
let character = world.spawn((crate::world::PlayerRoot, Mesh3d(character_mesh.clone()))).id();
|
||||
let map = skate_data::skate_map::SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap();
|
||||
for iteration in 0..6 {
|
||||
let mut scene = PreparedScene::new(&world);
|
||||
scene.prepare((iteration % 2 == 0).then_some(&map), std::path::Path::new("unused"));
|
||||
// Preparation has not published any live entities or assets.
|
||||
assert_eq!(world.resource::<Assets<Mesh>>().len(), 1);
|
||||
scene.publish(&mut world);
|
||||
assert!(world.query_filtered::<Entity, With<MapEntity>>().iter(&world).count() > 0);
|
||||
let old: Vec<_> = world.query_filtered::<Entity, With<MapEntity>>().iter(&world).collect();
|
||||
MapAssets::retire(&mut world);
|
||||
assert!(old.into_iter().all(|id| world.get_entity(id).is_err()));
|
||||
assert!(world.get_entity(character).is_ok());
|
||||
assert_eq!(world.resource::<Assets<Mesh>>().len(), 1);
|
||||
assert!(world.resource::<Assets<Mesh>>().contains(&character_mesh));
|
||||
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 0);
|
||||
assert_eq!(world.resource::<Assets<RetailWorldMaterial>>().len(), 0);
|
||||
assert_eq!(world.resource::<Assets<RetailSkyMaterial>>().len(), 0);
|
||||
assert_eq!(world.resource::<Assets<Image>>().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_preparation_does_not_replace_live_scene() {
|
||||
let mut world = world();
|
||||
let mut active = PreparedScene::new(&world);
|
||||
active.prepare(None, std::path::Path::new("unused"));
|
||||
active.publish(&mut world);
|
||||
let entities: Vec<_> = world.query_filtered::<Entity, With<MapEntity>>().iter(&world).collect();
|
||||
let meshes = world.resource::<Assets<Mesh>>().len();
|
||||
let mut abandoned = PreparedScene::new(&world);
|
||||
abandoned.prepare(None, std::path::Path::new("unused"));
|
||||
drop(abandoned);
|
||||
assert_eq!(world.resource::<Assets<Mesh>>().len(), meshes);
|
||||
assert!(entities.into_iter().all(|id| world.get_entity(id).is_ok()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Transactional world replacement: prepare without mutating the live session,
|
||||
//! then commit at a schedule boundary with gameplay suspended.
|
||||
use bevy::prelude::*;
|
||||
use std::{path::PathBuf, sync::{Arc, atomic::{AtomicU8, Ordering}}, thread::JoinHandle, time::Instant};
|
||||
use crate::{config::Config, map_library::Entry, map_render::PreparedScene,
|
||||
physics::{GamePhysics, PlayerControls, SkaterRuntime}};
|
||||
|
||||
/// Persistent character customisation can reapply after this set, while the
|
||||
/// transition still holds the loading overlay and gameplay remains suspended.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct MapTransitionSet;
|
||||
#[derive(Message)]
|
||||
pub(crate) struct WorldChanged { pub generation: u64 }
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/map_transition.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Resource, Debug)]
|
||||
pub(crate) struct CurrentMap {
|
||||
pub path: Option<PathBuf>,
|
||||
pub name: String,
|
||||
pub spawn: [f32; 3],
|
||||
pub heading: f32,
|
||||
pub generation: u64,
|
||||
}
|
||||
impl CurrentMap {
|
||||
fn from_package(path: Option<PathBuf>, map: Option<&skate_data::skate_map::SkateMap>) -> Self {
|
||||
Self { path, name: map.map_or_else(|| "Test world".into(), |m| m.name.clone()),
|
||||
spawn: map.map_or([0., crate::physics::ground::HEIGHT, 0.], |m| m.spawn),
|
||||
heading: map.map_or(0., |m| m.heading), generation: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedWorld {
|
||||
scene: PreparedScene,
|
||||
physics: GamePhysics,
|
||||
skater: SkaterRuntime,
|
||||
controls: PlayerControls,
|
||||
camera: crate::camera::CameraRuntime,
|
||||
metadata: CurrentMap,
|
||||
retail: bool,
|
||||
difficulty: crate::difficulty::Difficulty,
|
||||
}
|
||||
|
||||
enum Phase {
|
||||
Idle,
|
||||
Requested(Entry),
|
||||
Loading { entry: Entry, progress: Arc<AtomicU8>, job: JoinHandle<Result<PreparedWorld, String>> },
|
||||
// Let extraction see the committed scene before releasing the pause menu.
|
||||
Publishing { frames: u8, notice: String },
|
||||
}
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct MapTransition { phase: Phase }
|
||||
impl Default for MapTransition {
|
||||
fn default() -> Self { Self { phase: Phase::Idle } }
|
||||
}
|
||||
impl MapTransition {
|
||||
pub fn busy(&self) -> bool { !matches!(self.phase, Phase::Idle) }
|
||||
pub fn request(&mut self, entry: Entry) {
|
||||
if !self.busy() { self.phase = Phase::Requested(entry); }
|
||||
}
|
||||
pub fn label(&self) -> String {
|
||||
match &self.phase {
|
||||
Phase::Idle => String::new(),
|
||||
Phase::Requested(entry) => format!("Loading {} — preparing…", entry.label),
|
||||
Phase::Loading { entry, progress, .. } => format!("Loading {} — {}…", entry.label,
|
||||
match progress.load(Ordering::Relaxed) {
|
||||
0 => "reading and validating map", 1 => "building collision and rendering",
|
||||
2 => "initializing skater, camera and rendering", _ => "finishing meshes, textures and sky",
|
||||
}),
|
||||
Phase::Publishing { .. } => "Loading — publishing the new world…".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MapTransitionPlugin;
|
||||
impl Plugin for MapTransitionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let config = app.world().resource::<Config>();
|
||||
let current = CurrentMap::from_package(config.map_path.clone(), config.map.as_ref());
|
||||
app.insert_resource(current).init_resource::<MapTransition>().add_message::<WorldChanged>()
|
||||
.add_systems(PreUpdate, poll.in_set(MapTransitionSet).after(crate::graphics_menu::MenuInput)
|
||||
.before(crate::input::poll_controllers))
|
||||
.configure_sets(FixedUpdate, (
|
||||
crate::app::SimulationSet::Input, crate::app::SimulationSet::Controls,
|
||||
crate::app::SimulationSet::Physics,
|
||||
).run_if(crate::graphics_menu::gameplay_active));
|
||||
}
|
||||
}
|
||||
|
||||
fn start(world: &World, entry: Entry) -> Result<Phase, String> {
|
||||
let config = world.resource::<Config>();
|
||||
let root = config.asset_root.clone();
|
||||
let difficulty = config.difficulty;
|
||||
let graphs = world.resource::<crate::graph_runtime::StockGraphs>().clone();
|
||||
let source = world.resource::<SkaterRuntime>().animation.source.clone();
|
||||
let preferences = world.resource::<PlayerControls>().preferences;
|
||||
let mut scene = PreparedScene::new(world);
|
||||
let selected = entry.clone();
|
||||
let progress = Arc::new(AtomicU8::new(0));
|
||||
let stage = progress.clone();
|
||||
let job = std::thread::Builder::new().name("map-loader".into()).spawn(move || {
|
||||
let started = Instant::now();
|
||||
let map = selected.path.as_deref().map(skate_data::skate_map::SkateMap::load).transpose()?;
|
||||
let read_time = started.elapsed();
|
||||
let validation_started = Instant::now();
|
||||
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()));
|
||||
// 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.
|
||||
let (physics, skater, controls, camera, simulation_time, render_time) =
|
||||
std::thread::scope(|scope| -> Result<_, String> {
|
||||
let rendering = std::thread::Builder::new().name("map-render-loader".into())
|
||||
.spawn_scoped(scope, || {
|
||||
let render_started = Instant::now();
|
||||
scene.prepare(map.as_ref(), &root);
|
||||
render_started.elapsed()
|
||||
}).map_err(|e| format!("Could not start render loader: {e}"))?;
|
||||
let simulation_started = Instant::now();
|
||||
let simulation = (|| -> Result<_, String> {
|
||||
stage.store(1, Ordering::Relaxed);
|
||||
let physics = GamePhysics::load_with_difficulty(&root, map.as_ref(), difficulty)?;
|
||||
stage.store(2, Ordering::Relaxed);
|
||||
let skater = SkaterRuntime::load_for_world(&root, &graphs, &physics, difficulty.key(), Some(source))?;
|
||||
let mut controls = PlayerControls::load(&root)?;
|
||||
controls.preferences = preferences;
|
||||
let camera = crate::camera::CameraRuntime::load(&root)?;
|
||||
Ok((physics, skater, controls, camera))
|
||||
})();
|
||||
let simulation_time = simulation_started.elapsed();
|
||||
stage.store(3, Ordering::Relaxed);
|
||||
// Explicitly join even on a simulation error: no background
|
||||
// render work or reserved scene can outlive a failed request.
|
||||
let render_time = rendering.join()
|
||||
.map_err(|_| "Map render preparation failed unexpectedly".to_string())?;
|
||||
let (physics, skater, controls, camera) = simulation?;
|
||||
Ok((physics, skater, controls, camera, simulation_time, render_time))
|
||||
})?;
|
||||
eprintln!("MAP_LOAD_TIMING name={:?} read_ms={} validation_ms={} simulation_ms={} render_ms={} prepare_ms={} parallel=true",
|
||||
metadata.name, read_time.as_millis(), validation_time.as_millis(),
|
||||
simulation_time.as_millis(), render_time.as_millis(), started.elapsed().as_millis());
|
||||
// Drop the decoded package on this worker. Physics and rendering now
|
||||
// own their data; retaining it would double large-city CPU memory.
|
||||
Ok(PreparedWorld { scene, physics, skater, controls, camera, metadata, retail, difficulty })
|
||||
}).map_err(|e| format!("Could not start map loader: {e}"))?;
|
||||
Ok(Phase::Loading { entry, progress, job })
|
||||
}
|
||||
|
||||
fn poll(world: &mut World) {
|
||||
let ready = match &world.resource::<MapTransition>().phase {
|
||||
Phase::Idle => false,
|
||||
Phase::Loading { job, .. } => job.is_finished(),
|
||||
_ => true,
|
||||
};
|
||||
if !ready { return; }
|
||||
let phase = std::mem::replace(&mut world.resource_mut::<MapTransition>().phase, Phase::Idle);
|
||||
let next = match phase {
|
||||
Phase::Requested(entry) => match start(world, entry) {
|
||||
Ok(phase) => phase,
|
||||
Err(error) => { failed(world, error); Phase::Idle }
|
||||
},
|
||||
Phase::Loading { job, .. } => match job.join().unwrap_or_else(|_| Err("Map loader failed unexpectedly".into())) {
|
||||
Ok(prepared) => {
|
||||
let mut notice = commit(world, prepared);
|
||||
let config = world.resource::<Config>();
|
||||
match crate::map_library::save_default(&config.asset_root, config.map_path.as_deref()) {
|
||||
Ok(()) => notice.push_str(" Default map saved."),
|
||||
Err(error) => notice.push_str(&format!(" Could not save default: {error}")),
|
||||
}
|
||||
Phase::Publishing { frames: 3, notice }
|
||||
}
|
||||
Err(error) => { failed(world, error); Phase::Idle }
|
||||
},
|
||||
Phase::Publishing { frames, notice } if frames > 0 => Phase::Publishing { frames: frames - 1, notice },
|
||||
Phase::Publishing { notice, .. } => {
|
||||
world.resource_mut::<crate::graphics_menu::Menu>().transition_finished(notice, true);
|
||||
world.resource_mut::<Time<Virtual>>().unpause();
|
||||
Phase::Idle
|
||||
}
|
||||
Phase::Idle => Phase::Idle,
|
||||
};
|
||||
world.resource_mut::<MapTransition>().phase = next;
|
||||
}
|
||||
|
||||
fn failed(world: &mut World, error: String) {
|
||||
warn!("MAP_TRANSITION_FAILED {error}");
|
||||
world.resource_mut::<crate::graphics_menu::Menu>()
|
||||
.transition_finished(format!("Could not load map: {error}\nPrevious world retained. Choose another map or Resume."), false);
|
||||
}
|
||||
|
||||
fn commit(world: &mut World, mut prepared: PreparedWorld) -> String {
|
||||
let publication_started = Instant::now();
|
||||
prepared.metadata.generation = world.resource::<CurrentMap>().generation + 1;
|
||||
// All fallible decoding/validation/construction finished before this point.
|
||||
// No simulation system can observe a mixture of the two worlds.
|
||||
crate::map_render::MapAssets::retire(world);
|
||||
prepared.scene.publish(world);
|
||||
crate::camera::set_world_environment(world, prepared.retail);
|
||||
world.insert_resource(crate::retail_render::RetailScene(prepared.retail));
|
||||
world.insert_resource(crate::grind_world::GrindGeometry::for_world(prepared.metadata.path.is_none()));
|
||||
world.insert_resource(Time::<Fixed>::from_duration(prepared.physics.period()));
|
||||
let root_transform = Transform::from_matrix(crate::animation::native_matrix(
|
||||
prepared.skater.animated_skeleton.roots.animation_to_world));
|
||||
for mut transform in world.query_filtered::<&mut Transform, With<crate::world::PlayerRoot>>().iter_mut(world) {
|
||||
*transform = root_transform;
|
||||
}
|
||||
world.insert_resource(prepared.physics);
|
||||
world.insert_resource(prepared.skater);
|
||||
world.insert_resource(prepared.controls);
|
||||
world.insert_resource(prepared.camera);
|
||||
world.insert_resource(crate::presentation::Presentation::default());
|
||||
world.insert_resource(crate::replay::Replay::default());
|
||||
world.insert_resource(crate::input::ControllerInput::default());
|
||||
world.insert_resource(crate::input::PublishedTickInput::default());
|
||||
world.resource_mut::<ButtonInput<KeyCode>>().reset_all();
|
||||
world.resource_mut::<ButtonInput<MouseButton>>().reset_all();
|
||||
let mut config = world.resource_mut::<Config>();
|
||||
config.map_path = prepared.metadata.path.clone();
|
||||
config.difficulty = prepared.difficulty;
|
||||
let notice = format!("Loaded {}.", prepared.metadata.name);
|
||||
info!("MAP_TRANSITION_COMMITTED generation={} name={:?} spawn={:?} heading={} pid={}",
|
||||
prepared.metadata.generation, prepared.metadata.name, prepared.metadata.spawn,
|
||||
prepared.metadata.heading, std::process::id());
|
||||
if let Some(mut messages) = world.get_resource_mut::<Messages<WorldChanged>>() {
|
||||
messages.write(WorldChanged { generation: prepared.metadata.generation });
|
||||
}
|
||||
world.insert_resource(prepared.metadata);
|
||||
eprintln!("MAP_PUBLISH_TIMING cpu_ms={}", publication_started.elapsed().as_millis());
|
||||
notice
|
||||
}
|
||||
@@ -175,6 +175,8 @@ impl GamePhysics {
|
||||
self.animation_profile.physics_mode = difficulty as u32;
|
||||
}
|
||||
|
||||
pub(crate) fn period(&self) -> std::time::Duration { self.clock.period() }
|
||||
|
||||
pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode }
|
||||
|
||||
pub(crate) fn world(&self) -> &BoardWorld {
|
||||
|
||||
@@ -87,6 +87,13 @@ impl SkaterRuntime {
|
||||
graphs: &StockGraphs,
|
||||
physics: &GamePhysics,
|
||||
mode: &str,
|
||||
) -> Result<Self, String> {
|
||||
Self::load_for_world(asset_root, graphs, physics, mode, None)
|
||||
}
|
||||
|
||||
pub(crate) fn load_for_world(
|
||||
asset_root: &Path, graphs: &StockGraphs, physics: &GamePhysics, mode: &str,
|
||||
source: Option<std::sync::Arc<crate::skater_animation::AnimationSource>>,
|
||||
) -> Result<Self, String> {
|
||||
let data = Collections::load(asset_root)?;
|
||||
let ground_profiles = super::ground_runtime::GroundProfiles::load(&data)?;
|
||||
@@ -94,7 +101,10 @@ impl SkaterRuntime {
|
||||
.ok_or_else(|| format!("Invalid skater mode {mode}"))? as u32;
|
||||
// The host's current character is a custom skater with no pro selector
|
||||
// or equipped physical hat. These are profile choices, not force values.
|
||||
let mut animation = SkaterAnimation::load(asset_root, &data, graphs, b"")?;
|
||||
let mut animation = match source {
|
||||
Some(source) => SkaterAnimation::from_source(&data, graphs, b"", source)?,
|
||||
None => SkaterAnimation::load(asset_root, &data, graphs, b"")?,
|
||||
};
|
||||
let initial_hierarchy = animation.evaluate_initial_pose()?;
|
||||
let offboard = super::offboard::runtime::Runtime::load(&data, animation.motion.animation.metadata())?;
|
||||
let mut animated_skeleton =
|
||||
|
||||
@@ -71,10 +71,10 @@ impl Material for RetailSkyMaterial {
|
||||
pub(crate) fn spawn_sky(
|
||||
name: &str,
|
||||
root: &std::path::Path,
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
images: &mut Assets<Image>,
|
||||
materials: &mut Assets<RetailSkyMaterial>,
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
materials: &mut impl crate::map_render::AssetSink<RetailSkyMaterial>,
|
||||
) {
|
||||
// Parks need their authored sky selection from the environment controller;
|
||||
// only these three district identities are currently resolved.
|
||||
|
||||
@@ -422,11 +422,11 @@ fn render_groups(
|
||||
|
||||
pub(crate) fn spawn(
|
||||
map: &SkateMap,
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
retail_materials: &mut Assets<crate::retail_render::RetailWorldMaterial>,
|
||||
images: &mut Assets<Image>,
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
|
||||
retail_materials: &mut impl crate::map_render::AssetSink<crate::retail_render::RetailWorldMaterial>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
) {
|
||||
// Texture roles have different transfer functions even when sharing a record.
|
||||
let texture_ids = render_texture_ids(&map.textures);
|
||||
@@ -850,27 +850,23 @@ mod tests {
|
||||
fn render_adapter_creates_mesh_and_decoded_lightmap_without_a_window() {
|
||||
let map = demo();
|
||||
let mut world = World::new();
|
||||
let mut queue = bevy::ecs::world::CommandQueue::default();
|
||||
let mut meshes = Assets::<Mesh>::default();
|
||||
let mut materials = Assets::<StandardMaterial>::default();
|
||||
let mut images = Assets::<Image>::default();
|
||||
spawn(
|
||||
&map,
|
||||
&mut Commands::new(&mut queue, &world),
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&mut images,
|
||||
);
|
||||
queue.apply(&mut world);
|
||||
assert_eq!(meshes.len(), 1);
|
||||
assert_eq!(materials.len(), 1);
|
||||
assert_eq!(images.len(), 2);
|
||||
world.init_resource::<Assets<Mesh>>();
|
||||
world.init_resource::<Assets<StandardMaterial>>();
|
||||
world.init_resource::<Assets<crate::retail_render::RetailWorldMaterial>>();
|
||||
world.init_resource::<Assets<crate::retail_render::RetailSkyMaterial>>();
|
||||
world.init_resource::<Assets<Image>>();
|
||||
let mut scene = crate::map_render::PreparedScene::new(&world);
|
||||
scene.prepare(Some(&map), std::path::Path::new("unused"));
|
||||
scene.publish(&mut world);
|
||||
assert_eq!(world.resource::<Assets<Mesh>>().len(), 1);
|
||||
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 1);
|
||||
assert_eq!(world.resource::<Assets<Image>>().len(), 2);
|
||||
assert_eq!(world.query::<&Mesh3d>().iter(&world).count(), 1);
|
||||
let lightmap = world
|
||||
.query::<&bevy::pbr::Lightmap>()
|
||||
.single(&world)
|
||||
.unwrap();
|
||||
let image = images.get(&lightmap.image).unwrap();
|
||||
let image = world.resource::<Assets<Image>>().get(&lightmap.image).unwrap();
|
||||
assert_eq!(image.texture_descriptor.format, TextureFormat::Rgba16Float);
|
||||
let data = image.data.as_ref().unwrap();
|
||||
let value = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
|
||||
|
||||
@@ -30,7 +30,21 @@ use skate_core::{
|
||||
riding::push_behaviors::PushFootFrame,
|
||||
};
|
||||
use skate_data::collections::Collections;
|
||||
use std::path::Path;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
/// Immutable bank bytes and decoded poses survive world changes.
|
||||
pub(crate) struct AnimationSource {
|
||||
banks: skate_data::animation_banks::AnimationBanks,
|
||||
evaluator: Arc<PoseEvaluator>,
|
||||
}
|
||||
impl AnimationSource {
|
||||
fn load(root: &Path) -> Result<Arc<Self>, String> {
|
||||
let banks = skate_data::animation_banks::AnimationBanks::load(root)?;
|
||||
let mut evaluator = PoseEvaluator::from_banks(&banks)?;
|
||||
evaluator.load_authored_clips(root)?;
|
||||
Ok(Arc::new(Self { banks, evaluator: Arc::new(evaluator) }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Completed native publications, never inferred from a render transform.
|
||||
pub(crate) struct AnimationPhysical {
|
||||
@@ -51,7 +65,8 @@ pub(crate) struct AnimationPhysical {
|
||||
pub(crate) struct SkaterAnimation {
|
||||
pub action: ActionHost,
|
||||
pub motion: MotionHost,
|
||||
pub evaluator: PoseEvaluator,
|
||||
pub evaluator: Arc<PoseEvaluator>,
|
||||
pub source: Arc<AnimationSource>,
|
||||
pub pose: Vec<Sqt>,
|
||||
pub packet: PhysicsPosePacket,
|
||||
pub attributes: PacketAttributes,
|
||||
@@ -82,15 +97,19 @@ impl SkaterAnimation {
|
||||
graphs: &StockGraphs,
|
||||
pro_skater: &[u8],
|
||||
) -> Result<Self, String> {
|
||||
let banks = skate_data::animation_banks::AnimationBanks::load(root)?;
|
||||
let mut evaluator = PoseEvaluator::from_banks(&banks)?;
|
||||
evaluator.load_authored_clips(root)?;
|
||||
Self::from_source(data, graphs, pro_skater, AnimationSource::load(root)?)
|
||||
}
|
||||
|
||||
pub fn from_source(
|
||||
data: &Collections, graphs: &StockGraphs, pro_skater: &[u8], source: Arc<AnimationSource>,
|
||||
) -> Result<Self, String> {
|
||||
let evaluator = source.evaluator.clone();
|
||||
let state = state::AnimationState::new(true);
|
||||
let count = evaluator.frames.bone_names.len();
|
||||
let mut motion = MotionHost::from_graph(
|
||||
&graphs.motion,
|
||||
data,
|
||||
banks.metadata()?,
|
||||
source.banks.metadata()?,
|
||||
PlaybackContext {
|
||||
is_switch: Some(state.switch()),
|
||||
is_mirrored: Some(state.mirrored()),
|
||||
@@ -123,6 +142,7 @@ impl SkaterAnimation {
|
||||
.map_err(|error| format!("Stock ActionGraph initialization: {error}"))?,
|
||||
motion,
|
||||
evaluator,
|
||||
source,
|
||||
pose: Vec::new(),
|
||||
attributes: PacketAttributes::default(),
|
||||
state,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Construction/lifetime checks only. Never advance physics or drive gameplay.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pending_request_cannot_be_replaced_by_another_menu_action() {
|
||||
let mut transition = MapTransition::default();
|
||||
transition.request(Entry { label: "first".into(), path: None });
|
||||
transition.request(Entry { label: "second".into(), path: Some("other.skate".into()) });
|
||||
assert!(transition.busy());
|
||||
assert!(matches!(&transition.phase, Phase::Requested(entry) if entry.label == "first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires private installed assets; CPU lifecycle only, no gameplay"]
|
||||
fn installed_worlds_prepare_commit_and_retire_without_simulation() {
|
||||
let root = PathBuf::from(std::env::var("SKATE_TRANSITION_TEST_ASSETS").unwrap()).canonicalize().unwrap();
|
||||
let manifest = skate_data::GameAssets::load(&root).unwrap();
|
||||
let graphs = crate::graph_runtime::StockGraphs::load(&root, &manifest).unwrap();
|
||||
let difficulty = crate::difficulty::Difficulty::Hardcore;
|
||||
let physics = GamePhysics::load_with_difficulty(&root, None, difficulty).unwrap();
|
||||
let skater = SkaterRuntime::load(&root, &graphs, &physics, difficulty.key()).unwrap();
|
||||
let source = skater.animation.source.clone();
|
||||
let evaluator = skater.animation.evaluator.clone();
|
||||
let mut controls = PlayerControls::load(&root).unwrap();
|
||||
controls.preferences = skate_core::input::riding_intentions::PushPreferences {
|
||||
automatic_push_enabled: true, automatic_push_right: true
|
||||
};
|
||||
let mut world = World::new();
|
||||
world.insert_resource(Config { asset_root: root.clone(), verification_capture: None,
|
||||
map: None, map_path: None, difficulty, check_assets: false, start_paused: false });
|
||||
world.insert_resource(CurrentMap::from_package(None, None));
|
||||
world.insert_resource(graphs);
|
||||
world.insert_resource(physics);
|
||||
world.insert_resource(skater);
|
||||
world.insert_resource(controls);
|
||||
world.init_resource::<ButtonInput<KeyCode>>();
|
||||
world.init_resource::<ButtonInput<MouseButton>>();
|
||||
world.init_resource::<Assets<Mesh>>();
|
||||
world.init_resource::<Assets<Image>>();
|
||||
world.init_resource::<Assets<StandardMaterial>>();
|
||||
world.init_resource::<Assets<crate::retail_render::RetailWorldMaterial>>();
|
||||
world.init_resource::<Assets<crate::retail_render::RetailSkyMaterial>>();
|
||||
let character = world.spawn((crate::world::PlayerRoot, Transform::default())).id();
|
||||
let mut initial = PreparedScene::new(&world);
|
||||
initial.prepare(None, &root);
|
||||
initial.publish(&mut world);
|
||||
let procedural_meshes = world.resource::<Assets<Mesh>>().len();
|
||||
let procedural_entities = world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).count();
|
||||
|
||||
for (iteration, file) in [Some("BlackBoxPark.skate"), None, Some("University.skate"), None,
|
||||
Some("BlackBoxPark.skate"), None].into_iter().enumerate() {
|
||||
let entry = Entry { label: file.unwrap_or("Test world").into(),
|
||||
path: file.map(|name| root.parent().unwrap().join("maps").join(name)) };
|
||||
let old: Vec<_> = world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).collect();
|
||||
world.resource_mut::<GamePhysics>().contact_count = 123;
|
||||
world.resource_mut::<SkaterRuntime>().pose_generation = 999;
|
||||
let Phase::Loading { job, .. } = start(&world, entry).unwrap() else { panic!("expected worker"); };
|
||||
let prepared = job.join().unwrap().unwrap();
|
||||
assert!(old.iter().all(|&id| world.get_entity(id).is_ok()), "preparation changed live scene");
|
||||
assert_eq!(world.resource::<GamePhysics>().contact_count, 123);
|
||||
let triangles = prepared.physics.world_triangles().len();
|
||||
let spawn = prepared.metadata.spawn;
|
||||
assert!(Arc::ptr_eq(&prepared.skater.animation.source, &source));
|
||||
assert!(Arc::ptr_eq(&prepared.skater.animation.evaluator, &evaluator));
|
||||
commit(&mut world, prepared);
|
||||
assert!(old.into_iter().all(|id| world.get_entity(id).is_err()));
|
||||
assert!(world.get_entity(character).is_ok());
|
||||
assert_eq!(world.resource::<CurrentMap>().generation, iteration as u64 + 1);
|
||||
assert_eq!(world.resource::<CurrentMap>().spawn, spawn);
|
||||
let physics = world.resource::<GamePhysics>();
|
||||
assert_eq!(physics.world_triangles().len(), triangles);
|
||||
assert_eq!(physics.contact_count, 0);
|
||||
assert_eq!(physics.ticks, 0);
|
||||
assert!(!physics.failed);
|
||||
assert_eq!(physics.difficulty_index(), difficulty as u32);
|
||||
assert_eq!(world.resource::<SkaterRuntime>().pose_generation, 0);
|
||||
assert_eq!(world.resource::<PlayerControls>().ticks, 0);
|
||||
assert!(world.resource::<PlayerControls>().preferences.automatic_push_enabled);
|
||||
assert!(world.resource::<PlayerControls>().preferences.automatic_push_right);
|
||||
assert!(world.resource::<crate::presentation::Presentation>().pair().is_none());
|
||||
assert!(!world.resource::<crate::replay::Replay>().active);
|
||||
assert!(world.resource::<crate::camera::CameraRuntime>().frame.is_none());
|
||||
if file.is_none() {
|
||||
assert_eq!(world.resource::<Assets<Mesh>>().len(), procedural_meshes);
|
||||
assert_eq!(world.resource::<Assets<Image>>().len(), 0);
|
||||
assert_eq!(world.resource::<Assets<crate::retail_render::RetailSkyMaterial>>().len(), 0);
|
||||
assert_eq!(world.resource::<Assets<crate::retail_render::RetailWorldMaterial>>().len(), 0);
|
||||
assert_eq!(world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).count(), procedural_entities);
|
||||
}
|
||||
eprintln!("LIFECYCLE_CHECK generation={} map={file:?} triangles={triangles} entities={} meshes={} images={}",
|
||||
iteration + 1, world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).count(), world.resource::<Assets<Mesh>>().len(), world.resource::<Assets<Image>>().len());
|
||||
}
|
||||
let missing = Entry { label: "missing".into(), path: Some(root.join("nonexistent-transition-check.skate")) };
|
||||
let Phase::Loading { job, .. } = start(&world, missing).unwrap() else { panic!("expected worker"); };
|
||||
assert!(job.join().unwrap().is_err());
|
||||
assert_eq!(world.resource::<CurrentMap>().generation, 6);
|
||||
assert_eq!(world.resource::<Assets<Mesh>>().len(), procedural_meshes);
|
||||
assert!(world.get_entity(character).is_ok());
|
||||
}
|
||||
@@ -17,31 +17,28 @@ impl Plugin for WorldPlugin {
|
||||
.add_systems(Startup, spawn);
|
||||
}
|
||||
}
|
||||
fn spawn(
|
||||
mut commands: Commands,
|
||||
server: Res<AssetServer>,
|
||||
manifest: Res<AssetManifest>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
mut retail_materials: ResMut<Assets<crate::retail_render::RetailWorldMaterial>>,
|
||||
mut sky_materials: ResMut<Assets<crate::retail_render::RetailSkyMaterial>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut config: ResMut<crate::config::Config>,
|
||||
fn spawn(world: &mut World) {
|
||||
let scene = {
|
||||
let server = world.resource::<AssetServer>();
|
||||
let manifest = world.resource::<AssetManifest>();
|
||||
server.load(GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()))
|
||||
};
|
||||
world.spawn((PlayerRoot, Transform::default(), Visibility::default()))
|
||||
.with_children(|parent| { parent.spawn(SceneRoot(scene)); });
|
||||
let mut prepared = crate::map_render::PreparedScene::new(world);
|
||||
let (map, root) = {
|
||||
let mut config = world.resource_mut::<crate::config::Config>();
|
||||
(config.map.take(), config.asset_root.clone())
|
||||
};
|
||||
prepared.prepare(map.as_ref(), &root);
|
||||
prepared.publish(world);
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_test_world(
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
|
||||
) {
|
||||
commands
|
||||
.spawn((PlayerRoot, Transform::default(), Visibility::default()))
|
||||
.with_children(|parent| {
|
||||
parent.spawn(SceneRoot(server.load(
|
||||
GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()),
|
||||
)));
|
||||
});
|
||||
if let Some(map) = config.map.take() {
|
||||
// Physics has already consumed the package. Render assets own their
|
||||
// uploaded data; keeping another full city package wastes gigabytes.
|
||||
crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images);
|
||||
crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials);
|
||||
return;
|
||||
}
|
||||
let colors = [
|
||||
Color::srgb(0.16, 0.19, 0.21),
|
||||
Color::srgb(0.48, 0.35, 0.22),
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# In-process map transitions
|
||||
|
||||
Escape → Map selects an installed package; Load map changes the world in the
|
||||
current process and resumes at the package spawn/heading. Loading shows its
|
||||
current stage and suspends gameplay/menu actions until publication completes.
|
||||
A failed preparation leaves the previous world available through Resume.
|
||||
|
||||
Successful changes save `settings/default-map.json`. Existing string pointers
|
||||
remain compatible; JSON `null` explicitly selects the procedural test world.
|
||||
Explicit `--map` and `--test-world` still override this saved default at launch.
|
||||
`--start-paused` opens the menu before any gameplay ticks.
|
||||
|
||||
## Ownership and integration
|
||||
|
||||
- `map_transition.rs`: worker lifecycle, fresh simulation construction, atomic
|
||||
ECS replacement, error handling and compact `CurrentMap` metadata. No process
|
||||
spawn or restart path remains in the map selector.
|
||||
- `map_render.rs`: `PreparedScene`, live-allocator asset reservations,
|
||||
`SceneCommands`, `MapEntity` and explicit `MapAssets` retirement. CPU map
|
||||
decoding, collision/query construction, mesh batching, textures and sky
|
||||
preparation happen on the worker. Only publication touches live ECS/assets.
|
||||
- `world.rs`, `skate_world.rs`, `retail_render.rs`: initial startup and transitions
|
||||
share the scene builder. Rendering calculations are unchanged. All world/sky
|
||||
meshes, materials, images and lights have map ownership.
|
||||
- `skater_animation.rs`, `physics/skater.rs`: immutable animation banks and
|
||||
`Arc<PoseEvaluator>` survive replacements, while graph/controller/skeleton
|
||||
state is constructed afresh. Existing character entities and bone bindings
|
||||
remain alive.
|
||||
- `camera.rs`: camera runtime/history resets, and native HDR/tone components are
|
||||
installed or removed without replacing the presentation target or graphics
|
||||
configuration. Scene publication restores environment defaults first.
|
||||
- `graphics_menu.rs`, `config.rs`, `map_library.rs`: asynchronous request UI,
|
||||
pause, current difficulty and default-map persistence.
|
||||
- `physics.rs`, `grind_world.rs`, `app.rs`, `main.rs`: schedule/cadence, grind
|
||||
geometry and plugin wiring. Board collision/contact/query/grind state, input
|
||||
history, replay and presentation snapshots are replaced together.
|
||||
|
||||
Shared-file integration notes:
|
||||
|
||||
1. Renderer work splitting `retail_sky.rs` must retain the `SceneCommands` and
|
||||
generic `AssetSink` interfaces. Its additional world-material argument belongs
|
||||
in `PreparedScene::prepare` after world construction. If sky setup mutates
|
||||
materials, add a mutable iteration operation to both asset sink implementations.
|
||||
2. Customiser state stored in its own resource survives map changes. The
|
||||
`WorldChanged { generation }` message and `MapTransitionSet` allow reapplying
|
||||
selection in PreUpdate after commit, while the loading overlay remains up.
|
||||
This branch does not add a row referencing the separate customiser module.
|
||||
3. Trick changes should preserve the `SkaterRuntime::load_for_world` wrapper and
|
||||
shared animation source; its normal constructor still initializes all fields.
|
||||
|
||||
## Validation recorded on 2026-09-08
|
||||
|
||||
Release build passed with Bevy 0.18.1 and local patches:
|
||||
|
||||
```text
|
||||
cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --no-default-features
|
||||
```
|
||||
|
||||
Used the existing shared target directory and static CRT target flags. The
|
||||
executable was immediately copied into this task's own output directory.
|
||||
|
||||
Seven focused checks passed: repeated scene/asset retirement, abandoned scene
|
||||
preparation, native/procedural camera components, saved-default round trips,
|
||||
overlapping requests, render/lightmap construction and retained graphics controls.
|
||||
|
||||
A CPU-only installed-content lifecycle check completed six replacements:
|
||||
BlackBoxPark → test world → University → test world → BlackBoxPark → test world.
|
||||
University constructed 1,133,649 collision triangles, 4,866 meshes including sky,
|
||||
and 2,057 images. Every return to the procedural scene restored four meshes,
|
||||
zero map images and zero native materials. Old map entity IDs were invalidated;
|
||||
character identity and animation Arc identities survived; contacts, ticks,
|
||||
camera/replay/presentation history reset. Difficulty and push preferences survived.
|
||||
A missing package failed without changing the current world. No simulation tick
|
||||
was advanced by this check, and it did not write installed settings or maps.
|
||||
|
||||
A paused startup capture exited successfully with zero input polls, zero physics
|
||||
ticks and zero animation ticks; the pause menu and 3,324 stock clips initialized.
|
||||
This ran before the later instruction restricting runtime checks to the user.
|
||||
Subsequent work used only compilation/static checks and prepared user launchers.
|
||||
|
||||
## Limits of the evidence
|
||||
|
||||
Repeated rendered/gameplay switches have not been exercised. GPU resource
|
||||
retirement, driver memory behavior and playability after transitions require user
|
||||
testing. ECS publication and GPU uploads/pipeline creation can still cause a
|
||||
short hitch; the large CPU preparation phase stays off the UI thread. Retaining
|
||||
the previous map until preparation succeeds temporarily needs memory for both
|
||||
worlds. The loading overlay remains for several publication frames; this is not
|
||||
a claim that every shader pipeline has finished compiling. Native sky availability
|
||||
matches this branch's baseline renderer and must be integrated with the separate
|
||||
authored-sky work. No map/character content is committed or bundled.
|
||||
|
||||
## Loading overlap follow-up
|
||||
|
||||
After the user confirmed the initial implementation works, the loader was changed
|
||||
to prepare rendering concurrently with collision/skater/camera construction.
|
||||
One additional scoped worker reads the same immutable decoded map; it does not
|
||||
copy the package or cache old worlds. Both builders must finish before commit,
|
||||
and the render worker is joined even if simulation construction fails. The
|
||||
existing publication delay, validation and rollback behavior remain in place.
|
||||
|
||||
`MAP_LOAD_TIMING` in stderr reports file read/decode, validation, simulation,
|
||||
render preparation and aggregate preparation milliseconds. Simulation and render
|
||||
durations overlap, so they must not be added to estimate elapsed time.
|
||||
`MAP_PUBLISH_TIMING` reports main-world publication CPU time, excluding subsequent
|
||||
GPU work and the menu's publication-frame delay.
|
||||
|
||||
The follow-up is compile-checked only; no runtime checks or benchmarks were run.
|
||||
Its actual speed improvement is unmeasured. Independent work can overlap, but
|
||||
CPU/memory contention and the fraction spent reading/decoding or uploading to the
|
||||
GPU determine the benefit on a particular map and machine. The earlier runtime
|
||||
validation above describes the original sequential preparation build.
|
||||
|
||||
## Parallel package decoding follow-up
|
||||
|
||||
The user's timing logs showed Downtown spending 3,041 ms in combined read/parse,
|
||||
compared with 1,304 ms render preparation and 490 ms simulation construction.
|
||||
A standalone `skate-data` example isolated actual file reads at roughly 60 ms.
|
||||
This tool only parses packages; it does not initialize Bevy, physics or gameplay.
|
||||
|
||||
The reader now scans texture descriptors and borrows their compressed payloads,
|
||||
then decodes them with up to four workers by default. Dynamic assignment handles
|
||||
unequal texture sizes; original indices determine the final texture order.
|
||||
The workers finish before subsequent map processing or any world publication.
|
||||
Maps with less than 1 MiB of decoded textures stay serial. Existing storage
|
||||
methods, output-size bounds, DEFLATE input-consumption checks and format validation
|
||||
are retained. Decompression buffers reserve a bounded initial capacity to reduce
|
||||
reallocation. No content conversion, on-disk cache or persistent map cache is added.
|
||||
|
||||
Three-pass data measurements on the installed packages (milliseconds):
|
||||
|
||||
| Map | Previous parse times | Parallel parse times |
|
||||
| --- | --- | --- |
|
||||
| Downtown | 2934, 2929, 2777 | 1440, 1418, 1481 |
|
||||
| Industrial | 1526, 1520, 1504 | 744, 679, 682 |
|
||||
|
||||
Separate full-field serial/parallel comparisons passed for Downtown, Industrial
|
||||
and University. Two decoder tests passed for mixed raw/zlib/zstd storage, output
|
||||
order at 2/4/8 workers, corrupt payloads, wrong sizes, unknown methods and DEFLATE
|
||||
trailing bytes. All four existing format integration tests passed, including
|
||||
documented versions and malformed/truncated input. These are data checks only.
|
||||
|
||||
`MAP_READ_TIMING` now separates `disk_ms` and `parse_ms`.
|
||||
`MAP_DECODE_TIMING` divides parsing into textures, geometry and extensions, and
|
||||
reports actual texture worker count. The existing total preparation/publication
|
||||
timings remain available. End-to-end game loading improvement remains for the
|
||||
user to test; the approximately halved decode time is not a claim of halved total
|
||||
loading time. Game code was compiled; the new game build was not launched.
|
||||
Reference in new issue
Block a user