From e6047b7073b1d8fd736a9652897f7fe271a2563b Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:40:19 +0100 Subject: [PATCH 01/13] Light custom map scenes using authored environment and native provenance --- crates/skate-game/src/app.rs | 2 +- crates/skate-game/src/map_render.rs | 71 +++++++++++++++++++++++-- crates/skate-game/src/map_transition.rs | 2 +- crates/skate-game/src/retail_render.rs | 11 ++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index edae5339..882fd81b 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.add_plugins( diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 84fd2487..5a0ee8d2 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -96,9 +96,13 @@ 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); + } } else { crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials); } @@ -122,6 +126,41 @@ pub(crate) struct MapAssets { sky: Vec>, images: Vec>, } + +/// Fixed authored start hour; dynamic refers to real-time surface lighting and +/// shadows, not a new day/night simulation. All lights are scene-owned. +fn custom_lighting(map: &skate_data::skate_map::SkateMap, commands: &mut SceneCommands) { + let e = &map.environment; + let hour = e[10].rem_euclid(24.); + let elevation = ((hour - 6.) * std::f32::consts::PI / 12.).sin(); + let daylight = elevation.max(0.); + let azimuth = e[11]; + let extended = e.len() == 45; + let rgb = |i: usize| Color::linear_rgb(e[i].max(0.), e[i + 1].max(0.), e[i + 2].max(0.)); + let night = elevation < 0.; + let color = if extended { rgb(if night { 35 } else { 32 }) } else { Color::WHITE }; + let strength = if extended { e[if night { 39 } else { 38 }].max(0.) } else { 1. }; + let ambient = if extended { e[41] + (e[40] - e[41]) * daylight } else { 0.32 }; + commands.insert_resource(GlobalAmbientLight { + color: Color::WHITE, brightness: ambient.max(0.) * 1000., ..default() + }); + let horizontal = (1. - elevation * elevation).max(0.).sqrt(); + let direction = Vec3::new(azimuth.cos() * horizontal, elevation.abs().max(0.001), azimuth.sin() * horizontal).normalize(); + commands.spawn(( + Name::new("Custom map sun/moon"), + DirectionalLight { + color, illuminance: 10_000. * strength * elevation.abs(), + shadows_enabled: true, + // Preserve authored indirect/baked diffuse on custom lightmapped meshes. + affects_lightmapped_mesh_diffuse: false, + ..default() + }, + Transform::default().looking_to(-direction, Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + maximum_distance: 100., first_cascade_far_bound: 10., ..default() + }.build(), + )); +} impl MapAssets { pub fn retire(world: &mut World) { let entities: Vec<_> = world.query_filtered::>().iter(world).collect(); @@ -143,6 +182,32 @@ impl MapAssets { #[cfg(test)] mod tests { use super::*; + #[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!(world.resource::>().iter().all(|(_, m)| !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/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}; From c2d67bab00ed78936338e2f4df9ada68b3876bda Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:41:20 +0100 Subject: [PATCH 02/13] Restore character materials and isolate shadow and exposure state across maps --- crates/skate-game/src/camera.rs | 2 +- crates/skate-game/src/map_render.rs | 2 +- crates/skate-game/src/retail_character.rs | 64 +++++++++++++++++++-- crates/skate-game/src/retail_exposure.rs | 5 +- crates/skate-game/src/retail_exposure.wgsl | 5 ++ docs/custom-map-lighting.md | 66 ++++++++++++++++++++++ 6 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 docs/custom-map-lighting.md diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index 9664d5f7..14cc32e0 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -24,7 +24,7 @@ use bevy::prelude::*; use crate::{app::FrameSet, config::Config}; #[derive(Component)] -struct GameplayCamera; +pub(crate) struct GameplayCamera; pub(crate) struct CameraPlugin; impl Plugin for CameraPlugin { diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 5a0ee8d2..759ec7e8 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -155,7 +155,7 @@ fn custom_lighting(map: &skate_data::skate_map::SkateMap, commands: &mut SceneCo affects_lightmapped_mesh_diffuse: false, ..default() }, - Transform::default().looking_to(-direction, Vec3::Y), + Transform::default().looking_to(-direction, if direction.y > 0.99 { Vec3::Z } else { Vec3::Y }), bevy::light::CascadeShadowConfigBuilder { maximum_distance: 100., first_cascade_far_bound: 10., ..default() }.build(), 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/docs/custom-map-lighting.md b/docs/custom-map-lighting.md new file mode 100644 index 00000000..681dc573 --- /dev/null +++ b/docs/custom-map-lighting.md @@ -0,0 +1,66 @@ +# 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 start hour is +fixed: there is no new day/night controller or per-frame light update. Existing +baked diffuse on lightmapped custom surfaces is retained. The package horizon +colour remains the 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. From 499183265389a1e4723cc291b5cfd2023daef2c0 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:44:42 +0100 Subject: [PATCH 03/13] Record custom lighting lifecycle validation and manual checks --- docs/custom-map-lighting.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index 681dc573..a97ae0b2 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -64,3 +64,12 @@ custom scene lights without a separate character light rig. 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. From a4666424cf6dc52ce758918a48f11c4c3f7183dc Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:54:12 +0100 Subject: [PATCH 04/13] Read SKATE15 custom maps with lossless compressed storage transforms --- crates/skate-data/src/skate_map.rs | 31 +++- .../skate-data/src/skate_map/storage_v15.rs | 152 ++++++++++++++++++ crates/skate-data/tests/skate_map.rs | 38 ++++- 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 crates/skate-data/src/skate_map/storage_v15.rs 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); From 284a5cad50c8645a9b086ee9cd91a4023ceab41c Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:55:01 +0100 Subject: [PATCH 05/13] Verify supplied v15 custom map scene preparation and document format support --- crates/skate-game/src/map_render.rs | 16 ++++++++++++++++ docs/SKATE-MAPS.md | 2 +- docs/custom-map-lighting.md | 8 ++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 759ec7e8..7b0f8976 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -183,6 +183,22 @@ impl MapAssets { mod tests { use super::*; #[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(); diff --git a/docs/SKATE-MAPS.md b/docs/SKATE-MAPS.md index 67d68614..560692be 100644 --- a/docs/SKATE-MAPS.md +++ b/docs/SKATE-MAPS.md @@ -15,7 +15,7 @@ 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. | diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index a97ae0b2..3b9789e9 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -73,3 +73,11 @@ 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. From 52295937df9e25a1a54ac49bc4d270286580fb79 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 23:58:24 +0100 Subject: [PATCH 06/13] Validate offboard sweep spatial lanes without rejecting unused SIMD values --- .../physics/offboard/contact_toolkit/tests.rs | 34 +++++++++++++++++++ .../physics/offboard/contact_toolkit/world.rs | 12 +++---- docs/custom-map-lighting.md | 9 +++++ 3 files changed, 49 insertions(+), 6 deletions(-) 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/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index 3b9789e9..665c520f 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -81,3 +81,12 @@ and malformed input). A user-supplied v15 custom map passed the ignored CPU 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. From 8d35f5267be43689625ad9ec15192d9131880d0a Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 00:05:41 +0100 Subject: [PATCH 07/13] Accelerate custom map collision with bounded ordered query clusters --- crates/skate-game/src/skate_world.rs | 78 +++++++++++++++++++++++++--- docs/custom-map-lighting.md | 19 +++++++ 2 files changed, 90 insertions(+), 7 deletions(-) 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/custom-map-lighting.md b/docs/custom-map-lighting.md index 665c520f..2ef4128c 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -90,3 +90,22 @@ 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. From ea7c52707370f42aefb35d436bbfb47e082d4669 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 01:27:04 +0100 Subject: [PATCH 08/13] Cache rotating mesh binding sets to remove measured render preparation overhead --- docs/custom-map-lighting.md | 20 ++++ vendor/bevy_pbr/src/lightmap/cache_tests.rs | 1 + vendor/bevy_pbr/src/render/mesh.rs | 106 +++++++++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index 2ef4128c..0b64698f 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -109,3 +109,23 @@ 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. 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 } From d9ed38f9cc2b975cb3e97c68fd2be9283760dd2d Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 01:33:23 +0100 Subject: [PATCH 09/13] Capture timestamped render-stage spikes for intermittent hitch diagnosis --- crates/skate-game/src/performance.rs | 37 +++++++++++++++++++++++----- docs/custom-map-lighting.md | 15 +++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) 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/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index 0b64698f..62f464db 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -129,3 +129,18 @@ 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. From f94fbdd1534559978691a962055185c5b1437752 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 01:44:05 +0100 Subject: [PATCH 10/13] Add custom-map day night cycle and saved menu controls --- crates/skate-game/src/graphics_menu.rs | 60 +++++++++++++++--- crates/skate-game/src/map_render.rs | 85 ++++++++++++++++++-------- docs/custom-map-lighting.md | 25 +++++++- 3 files changed, 131 insertions(+), 39 deletions(-) diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 9c53d0fe..df7d68b8 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,8 @@ struct GraphicsSettings { samples: u32, fps: u32, occlusion: bool, + hour: f32, + day_speed: u32, } impl Default for GraphicsSettings { fn default() -> Self { @@ -50,11 +53,15 @@ impl Default for GraphicsSettings { samples: 4, fps: 0, occlusion: true, + hour: 12., + day_speed: 60, } } } impl GraphicsSettings { fn validated(mut self) -> Self { + 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 +93,15 @@ pub(crate) struct Menu { selected_map: usize, multiplayer: bool, browser: bool, + daylight: bool, } impl Menu { + 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 +139,7 @@ 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(Last, pace); } } @@ -218,8 +232,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..15 { - 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..16 { + 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 +258,7 @@ fn setup( selected_map, multiplayer: false, browser: false, + daylight: false, }); } fn msaa(samples: u32) -> Msaa { @@ -306,7 +321,7 @@ pub(crate) fn interact( } } if menu.open { - let rows = if menu.multiplayer { 11 } else { 15 }; + let rows = if menu.daylight { 3 } else if menu.multiplayer { 11 } else { 16 }; if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { menu.selected = (menu.selected + rows - 1) % rows; } @@ -327,7 +342,14 @@ 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), + _ => { menu.daylight = false; menu.selected = 15; } + } + } else if menu.browser { match row { 0 => net.browse(0), 1..=5 => net.join_row(row - 1), @@ -427,10 +449,11 @@ pub(crate) fn interact( 12 => custom_models.begin(), 13 => menu.status = updater.open(false), 14 => travel.open = true, + 15 => { menu.daylight = true; menu.selected = 0; menu.status = "Custom maps: change time and cycle speed. Retail lighting stays authored.".into(); }, _ => {} } } - if row < 5 && !menu.multiplayer { + if (row < 5 && !menu.multiplayer && !menu.daylight && !day_action) || (day_action && row < 2) { let save = (|| -> Result<(), String> { std::fs::create_dir_all(menu.path.parent().unwrap()).map_err(|e| e.to_string())?; std::fs::write( @@ -530,7 +553,14 @@ 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 => "Back".into(), + _ => String::new(), + } + } else if menu.browser { match label.0 { 0 => "Refresh public Steam lobbies".into(), 1..=5 => net @@ -614,6 +644,7 @@ fn labels( 12 => "Custom models".into(), 13 => "Updates".into(), 14 => "Teleport…".into(), + 15 => "Day & night…".into(), _ => "Multiplayer".into(), } }; @@ -636,7 +667,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 >= 3) || (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 { @@ -675,10 +706,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 7b0f8976..9e29f9d0 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -127,40 +127,57 @@ pub(crate) struct MapAssets { images: Vec>, } -/// Fixed authored start hour; dynamic refers to real-time surface lighting and -/// shadows, not a new day/night simulation. All lights are scene-owned. -fn custom_lighting(map: &skate_data::skate_map::SkateMap, commands: &mut SceneCommands) { - let e = &map.environment; - let hour = e[10].rem_euclid(24.); - let elevation = ((hour - 6.) * std::f32::consts::PI / 12.).sin(); +/// Scene-owned environment; retired with its sun when switching maps. +#[derive(Component)] +pub(crate) struct DayEnvironment { + values: Vec, + sky: Option, +} +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 azimuth = e[11]; let extended = e.len() == 45; - let rgb = |i: usize| Color::linear_rgb(e[i].max(0.), e[i + 1].max(0.), e[i + 2].max(0.)); let night = elevation < 0.; - let color = if extended { rgb(if night { 35 } else { 32 }) } else { Color::WHITE }; - let strength = if extended { e[if night { 39 } else { 38 }].max(0.) } else { 1. }; - let ambient = if extended { e[41] + (e[40] - e[41]) * daylight } else { 0.32 }; - commands.insert_resource(GlobalAmbientLight { - color: Color::WHITE, brightness: ambient.max(0.) * 1000., ..default() - }); - let horizontal = (1. - elevation * elevation).max(0.).sqrt(); - let direction = Vec3::new(azimuth.cos() * horizontal, elevation.abs().max(0.001), azimuth.sin() * horizontal).normalize(); + 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"), - DirectionalLight { - color, illuminance: 10_000. * strength * elevation.abs(), - shadows_enabled: true, - // Preserve authored indirect/baked diffuse on custom lightmapped meshes. - affects_lightmapped_mesh_diffuse: false, - ..default() - }, - Transform::default().looking_to(-direction, if direction.y > 0.99 { Vec3::Z } else { Vec3::Y }), - bevy::light::CascadeShadowConfigBuilder { - maximum_distance: 100., first_cascade_far_bound: 10., ..default() - }.build(), + DayEnvironment { values: map.environment.to_vec(), sky: None }, + 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); + light.color = color; + light.illuminance = illuminance; + *transform = pose; + 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(); @@ -183,6 +200,20 @@ impl MapAssets { 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(); diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index 62f464db..dca9a9fa 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -9,10 +9,10 @@ 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 start hour is -fixed: there is no new day/night controller or per-frame light update. Existing +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 remains the background; native sky sidecars are only loaded for retail +colour supplies the daytime background; native sky sidecars are only loaded for retail scenes. Zero authored light intensity is respected. ## Provenance and ambiguity @@ -144,3 +144,22 @@ 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. From a1dd5eabbaf263f923731fb57ac00c91279a1711 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 01:52:35 +0100 Subject: [PATCH 11/13] Add saved ambient light override to day night menu --- crates/skate-game/src/graphics_menu.rs | 26 +++++++++++++++++++++----- crates/skate-game/src/map_render.rs | 2 +- docs/custom-map-lighting.md | 7 +++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index df7d68b8..c4bf8891 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -43,6 +43,7 @@ struct GraphicsSettings { occlusion: bool, hour: f32, day_speed: u32, + ambient_level: Option, } impl Default for GraphicsSettings { fn default() -> Self { @@ -55,11 +56,13 @@ impl Default for GraphicsSettings { 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)) { @@ -96,6 +99,9 @@ pub(crate) struct Menu { 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.); @@ -321,7 +327,7 @@ pub(crate) fn interact( } } if menu.open { - let rows = if menu.daylight { 3 } else if menu.multiplayer { 11 } else { 16 }; + let rows = if menu.daylight { 4 } else if menu.multiplayer { 11 } else { 16 }; if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { menu.selected = (menu.selected + rows - 1) % rows; } @@ -347,6 +353,12 @@ pub(crate) fn interact( 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 = 15; } } } else if menu.browser { @@ -449,11 +461,11 @@ pub(crate) fn interact( 12 => custom_models.begin(), 13 => menu.status = updater.open(false), 14 => travel.open = true, - 15 => { menu.daylight = true; menu.selected = 0; menu.status = "Custom maps: change time and cycle speed. Retail lighting stays authored.".into(); }, + 15 => { 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 && !menu.daylight && !day_action) || (day_action && row < 2) { + 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( @@ -557,7 +569,11 @@ fn labels( 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 => "Back".into(), + 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 { @@ -667,7 +683,7 @@ fn labels( menu.status.clone() }; for (row, interaction, mut color, mut node) in &mut buttons { - node.display = if (menu.daylight && row.0 >= 3) || (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 { diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 9e29f9d0..bc8c8639 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -174,7 +174,7 @@ pub(crate) fn advance_day( light.color = color; light.illuminance = illuminance; *transform = pose; - ambient.brightness = brightness; + ambient.brightness = menu.ambient_brightness(brightness); clear.0 = Color::srgb(0.003, 0.005, 0.015).mix(&base, daylight.sqrt()); } } diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index dca9a9fa..abaf0ac8 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -163,3 +163,10 @@ 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. From 3c62473fd41c77a0ab9ab2f9c4e40770ae18c6fa Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 01:56:48 +0100 Subject: [PATCH 12/13] Add scene-owned sun and moon discs following the daylight orbit --- crates/skate-game/src/graphics_menu.rs | 1 + crates/skate-game/src/map_render.rs | 51 +++++++++++++++++++++++++- docs/custom-map-lighting.md | 10 +++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index c4bf8891..21382ad1 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -146,6 +146,7 @@ impl Plugin for GraphicsMenuPlugin { .add_systems(PostStartup, setup.in_set(PresentationSetup)) .add_systems(PreUpdate, interact.in_set(MenuInput).after(bevy::input::InputSystems)) .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); } } diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index bc8c8639..5c094851 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -102,6 +102,7 @@ impl PreparedScene { &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); } } else { crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials); @@ -132,6 +133,47 @@ pub(crate) struct MapAssets { 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) { + let disc = meshes.add(Circle::new(1.).mesh().resolution(64).build()); + for moon in [false, true] { + let color = if moon { Color::srgb(0.78, 0.84, 0.96) } else { Color::srgb(1., 0.9, 0.55) }; + commands.spawn(( + Name::new(if moon { "Custom moon" } else { "Custom sun" }), + CelestialBody { moon }, Mesh3d(disc.clone()), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: color, unlit: true, cull_mode: None, ..default() + })), + Transform::default(), Visibility::Hidden, + bevy::light::NotShadowCaster, bevy::light::NotShadowReceiver, + )); + } +} +/// 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( + 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.014 } else { 0.012 })); + } } fn daylight_values(e: &[f32], hour: f32) -> (Color, f32, f32, Transform, f32) { let angle = (hour - 6.) * std::f32::consts::PI / 12.; @@ -155,7 +197,7 @@ fn custom_lighting(map: &skate_data::skate_map::SkateMap, commands: &mut SceneCo 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 }, + 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(), @@ -171,6 +213,7 @@ pub(crate) fn advance_day( 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; @@ -249,7 +292,11 @@ mod tests { 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!(world.resource::>().iter().all(|(_, m)| !m.unlit)); + 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); } diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index abaf0ac8..1abaedea 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -170,3 +170,13 @@ 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 and textures are not added. From a46bd3fa9f85f273e30af80a0214f86d90d6aad0 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 02:02:53 +0100 Subject: [PATCH 13/13] Enlarge celestial bodies and add solar corona and lunar surface detail --- crates/skate-game/src/map_render.rs | 71 ++++++++++++++++++++++++++--- docs/custom-map-lighting.md | 8 +++- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/crates/skate-game/src/map_render.rs b/crates/skate-game/src/map_render.rs index 5c094851..858ab4ad 100644 --- a/crates/skate-game/src/map_render.rs +++ b/crates/skate-game/src/map_render.rs @@ -102,7 +102,7 @@ impl PreparedScene { &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); + 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); @@ -142,24 +142,79 @@ fn orbit(azimuth: f32, hour: f32) -> Vec3 { #[derive(Component)] pub(crate) struct CelestialBody { moon: bool } -fn celestial_bodies(commands: &mut SceneCommands, meshes: &mut StagedAssets, materials: &mut StagedAssets) { - let disc = meshes.add(Circle::new(1.).mesh().resolution(64).build()); +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 color = if moon { Color::srgb(0.78, 0.84, 0.96) } else { Color::srgb(1., 0.9, 0.55) }; + 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: color, unlit: true, cull_mode: None, ..default() + 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>, @@ -172,7 +227,11 @@ pub(crate) fn position_celestial_bodies( *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.014 } else { 0.012 })); + .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) { diff --git a/docs/custom-map-lighting.md b/docs/custom-map-lighting.md index 1abaedea..b553f40c 100644 --- a/docs/custom-map-lighting.md +++ b/docs/custom-map-lighting.md @@ -179,4 +179,10 @@ 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 and textures are not added. +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.