From 3c62473fd41c77a0ab9ab2f9c4e40770ae18c6fa Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 01:56:48 +0100 Subject: [PATCH] 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.