use bevy::{ core_pipeline::{bloom::Bloom, tonemapping::Tonemapping}, input::mouse::{MouseScrollUnit, MouseWheel}, math::DVec3, prelude::*, window::{WindowMode, WindowResolution}, }; use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin}; use iyes_perf_ui::{PerfUiPlugin, prelude::*}; use serde::Deserialize; mod jpl_horizon; // Data structures for deserializing initial_state.ron #[derive(Deserialize)] struct InitialState { bodies: Vec, } #[derive(Deserialize)] struct CelestialBodyData { name: String, mass: f64, // kg radius: f64, // km position: (f64, f64, f64), // AU velocity: (f64, f64, f64), // AU/day } // Scaling factor to convert AU to game units // Neptune is approximately 30.1 astronomical units (AU) from the Sun // Point light effective range is about 10 game units, so 10 game units = ~30 AU const AU_TO_GAME_UNITS: f64 = 0.3; // Unit wrapper types - all distances in AU #[derive(Clone, Copy, Debug, PartialEq)] pub struct DistanceAu(pub f64); #[derive(Clone, Copy, Debug, PartialEq)] pub struct PositionAu(pub DVec3); #[derive(Clone, Copy, Debug, PartialEq)] pub struct VelocityAuPerDay(pub DVec3); #[derive(Clone, Copy, Debug, PartialEq)] pub struct MassKg(pub f64); #[derive(Clone, Debug, PartialEq)] pub struct ObjectName(pub String); // Component wrappers #[derive(Component)] struct Position(PositionAu); #[derive(Component)] struct Velocity(VelocityAuPerDay); #[derive(Component)] struct Mass(MassKg); #[derive(Component)] struct Radius(DistanceAu); #[derive(Component)] struct Name(ObjectName); #[derive(Bundle)] struct ObjectBundle { name: Name, position: Position, mass: Mass, radius: Radius, velocity: Velocity, } #[derive(Component)] struct Star; #[derive(Component)] struct Earth; // Component for UI name labels #[derive(Component)] struct ObjectLabel { target_entity: Entity, } // Component to mark objects that can be focused on with proper zoom levels #[derive(Component)] struct Trackable {} // Resource to track which entity the camera should follow #[derive(Resource)] struct CameraFollow { target: Option, distance: f32, // Current zoom distance from target } pub struct SolarRenderingPlugin; impl Plugin for SolarRenderingPlugin { fn build(&self, app: &mut App) { app.insert_resource(CameraFollow { target: None, distance: 10.0, }) .add_systems(Startup, (setup_rendering, setup_ui)) .add_systems( FixedPostUpdate, ( sync_radius_to_mesh, sync_position_to_transform, sync_name_labels, camera_follow_system.after(sync_position_to_transform), ), ) .add_systems(PostUpdate, update_label_positions) .add_systems( Update, ( handle_label_clicks, handle_scroll_zoom, handle_speed_controls, ), ); } } fn sync_radius_to_mesh( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, query: Query<(Entity, &Radius, Option<&Star>, Option<&Mesh3d>), Changed>, ) { for (entity, radius, star, existing_mesh) in query.iter() { // Convert AU to game units for rendering let render_radius = radius.0.0 * AU_TO_GAME_UNITS; // Create or update sphere mesh let sphere_mesh = meshes.add(Sphere::new(render_radius as f32)); let material = materials.add(if star.is_none() { StandardMaterial { base_color: Color::WHITE, ..default() } } else { StandardMaterial { base_color: Color::WHITE, // Sun-like color for stars emissive: LinearRgba::rgb(200.0, 200.0, 200.0), ..default() } }); if existing_mesh.is_none() { // Add mesh and material components if they don't exist commands .entity(entity) .insert((Mesh3d(sphere_mesh), MeshMaterial3d(material))); } else { // Update existing mesh commands.entity(entity).insert(Mesh3d(sphere_mesh)); } } } fn sync_position_to_transform( mut commands: Commands, mut query: Query<(Entity, &Position, Option<&mut Transform>), Changed>, ) { for (entity, position, transform) in query.iter_mut() { // Convert AU to game units for rendering let scaled_position = position.0.0 * AU_TO_GAME_UNITS; match transform { Some(mut t) => { // Update existing transform t.translation = scaled_position.as_vec3(); } None => { // Insert a new Transform if it doesn't exist commands .entity(entity) .insert(Transform::from_translation(scaled_position.as_vec3())); } }; } } fn setup_rendering(mut commands: Commands) { // Spawn camera with pan/orbit/zoom controls // Place it at a good distance to view the solar system commands.spawn(( Camera3d::default(), Camera { hdr: true, clear_color: ClearColorConfig::Custom(Color::BLACK), ..default() }, Projection::Perspective(PerspectiveProjection { near: 1e-9, // Very close near plane for extreme zooming ..default() }), Tonemapping::TonyMcMapface, Transform::from_translation(Vec3::new(0., 0., 10.0)), Bloom::NATURAL, )); commands.spawn(PointLight { color: Color::WHITE, shadows_enabled: true, ..default() }); commands.spawn(PerfUiAllEntries::default()); } fn setup_ui(mut commands: Commands) { // UI root node for labels commands.spawn(Node { width: Val::Percent(100.0), height: Val::Percent(100.0), position_type: PositionType::Absolute, ..default() }); } fn sync_name_labels( mut commands: Commands, objects_with_names: Query<(Entity, &Name, &Position), Changed>, existing_labels: Query<&ObjectLabel>, ) { for (entity, name, _position) in objects_with_names.iter() { // Check if label already exists for this entity let has_label = existing_labels .iter() .any(|label| label.target_entity == entity); if !has_label { // Create new label with scaled font size commands.spawn(( Text::new(name.0.0.clone()), TextColor(Color::WHITE), TextFont { font_size: 16.0, ..default() }, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), // Will be updated by update_label_positions top: Val::Px(0.0), // Will be updated by update_label_positions ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)), Interaction::default(), // Make label clickable ObjectLabel { target_entity: entity, }, )); } } } fn update_label_positions( mut label_query: Query<(&mut Node, &ObjectLabel, &mut Text), With>, objects_query: Query<(&GlobalTransform, &Name, &Radius)>, camera_query: Query<(&Camera, &GlobalTransform)>, ) { let Ok((camera, camera_transform)) = camera_query.single() else { return; }; for (mut node, label, mut text) in label_query.iter_mut() { if let Ok((global_transform, name, _radius)) = objects_query.get(label.target_entity) { let world_pos = global_transform.translation(); if let Ok(screen_pos) = camera.world_to_viewport(camera_transform, world_pos) { // Apply scale factor to label offset to maintain relative positioning let scaled_offset = 10.0; // Position the label on screen, offset slightly to avoid overlapping the object node.left = Val::Px(screen_pos.x + scaled_offset); node.top = Val::Px(screen_pos.y - scaled_offset); // Update text in case name changed text.0 = name.0.0.clone(); } else { // Object is off-screen, hide label by moving it off-screen node.left = Val::Px(-1000.0); node.top = Val::Px(-1000.0); } } else { // Target entity no longer exists, remove label // Note: In a more complex system, you might want to handle this in a separate cleanup system } } } fn handle_label_clicks( interaction_query: Query< (&Interaction, &ObjectLabel), (Changed, With), >, trackable_objects: Query<(&Transform, &Trackable, &Radius)>, mut camera_query: Query<&mut Transform, (With, Without)>, mut camera_follow: ResMut, ) { for (interaction, label) in interaction_query.iter() { if *interaction == Interaction::Pressed { if let Ok((target_transform, _trackable, radius)) = trackable_objects.get(label.target_entity) { if let Ok(mut camera_transform) = camera_query.single_mut() { let target_world_pos = target_transform.translation; // Calculate appropriate distance based on entity radius (with some padding) let entity_radius = (radius.0.0 * AU_TO_GAME_UNITS) as f32; let desired_distance = entity_radius * 16.0; // 8x radius // Position camera at desired distance from target let camera_direction = (camera_transform.translation - target_world_pos).normalize(); let new_camera_pos = target_world_pos + camera_direction * desired_distance; camera_transform.translation = new_camera_pos; camera_transform.look_at(target_world_pos, Vec3::Y); // Set the follow target and initial distance camera_follow.target = Some(label.target_entity); camera_follow.distance = desired_distance; } } } } } fn camera_follow_system( camera_follow: Res, objects_query: Query<&Transform, With>, mut camera_query: Query<&mut Transform, (With, Without)>, ) { if let Some(target_entity) = camera_follow.target { if let Ok(target_transform) = objects_query.get(target_entity) { if let Ok(mut camera_transform) = camera_query.single_mut() { // Fixed camera direction relative to tracked object (stationary relative position) let fixed_direction = Vec3::new(0.0, 0.0, 1.0); // Position camera at the specified distance from target in fixed direction let new_camera_pos = target_transform.translation + fixed_direction * camera_follow.distance; camera_transform.translation = new_camera_pos; // Keep camera looking at the target entity camera_transform.look_at(target_transform.translation, Vec3::Y); } } } } fn handle_scroll_zoom( mut scroll_events: EventReader, mut camera_follow: ResMut, ) { for event in scroll_events.read() { let zoom_delta = match event.unit { MouseScrollUnit::Line => event.y * 0.1, MouseScrollUnit::Pixel => event.y * 0.01, }; // Update zoom distance with limits camera_follow.distance = (camera_follow.distance * (1.0 - zoom_delta)).clamp(1e-9, 100.0); } } fn handle_speed_controls( keyboard_input: Res>, mut time: ResMut>, ) { // Pause/Unpause with SPACE if keyboard_input.just_pressed(KeyCode::Space) { if time.is_paused() { time.unpause(); println!("Simulation RESUMED (Speed: {}x)", time.relative_speed()); } else { time.pause(); println!("Simulation PAUSED"); } } // Speed up with + or = key (double current speed, max 64x) if keyboard_input.just_pressed(KeyCode::Equal) || keyboard_input.just_pressed(KeyCode::NumpadAdd) { if !time.is_paused() { let current_speed = time.relative_speed(); let new_speed = (current_speed * 2.0).min(64.0); time.set_relative_speed(new_speed); println!("Speed: {}x", new_speed); } } // Speed down with - key (halve current speed, min 1.0x) if keyboard_input.just_pressed(KeyCode::Minus) || keyboard_input.just_pressed(KeyCode::NumpadSubtract) { if !time.is_paused() { let current_speed = time.relative_speed(); let new_speed = (current_speed * 0.5).max(1.0); time.set_relative_speed(new_speed); println!("Speed: {}x", new_speed); } } // Reset to normal speed with R if keyboard_input.just_pressed(KeyCode::KeyR) { time.set_relative_speed(1.0); time.unpause(); println!("Speed reset to 1x"); } } fn initialize_camera_follow( earth_query: Query, With)>, mut camera_follow: ResMut, ) { if let Ok(earth) = earth_query.single() { camera_follow.target = Some(earth); camera_follow.distance = 1e-3; } } fn setup_solar_system(mut commands: Commands) { // Load initial state from RON file let initial_state_content = match std::fs::read_to_string("assets/initial_state.ron") { Ok(content) => content, Err(err) => { error!("Failed to read initial_state.ron: {}", err); return; } }; let initial_state: InitialState = match ron::from_str(&initial_state_content) { Ok(state) => state, Err(err) => { error!("Failed to parse initial_state.ron: {}", err); return; } }; // Spawn all celestial bodies from the initial state for body_data in initial_state.bodies { // Convert radius from km to AU let radius_au = body_data.radius / 149597870.691; // km to AU conversion // Create base bundle let mut entity_commands = commands.spawn(( ObjectBundle { name: Name(ObjectName(body_data.name.clone())), position: Position(PositionAu(DVec3::new( body_data.position.0, body_data.position.1, body_data.position.2, ))), velocity: Velocity(VelocityAuPerDay(DVec3::new( body_data.velocity.0, body_data.velocity.1, body_data.velocity.2, ))), mass: Mass(MassKg(body_data.mass)), radius: Radius(DistanceAu(radius_au)), }, Trackable {}, )); // Add special components for Sun and Earth match body_data.name.as_str() { "Sun" => { entity_commands.insert(Star); } "Earth" => { entity_commands.insert(Earth); } _ => {} } info!("Spawned celestial body: {}", body_data.name); } } // High precision constants const G_SI: f64 = 6.67430e-11; // m^3 / (kg s^2) const AU_TO_M: f64 = 149_597_870_691.0; // m const DAY_TO_S: f64 = 86_400.0; // s // G in AU^3 / (kg day^2) const G_AU: f64 = G_SI * (DAY_TO_S * DAY_TO_S) / (AU_TO_M * AU_TO_M * AU_TO_M); const STEPS: usize = 100; // If FixedUpdate is 64 Hz and you want 1 day per game second: DT = 1/(64*STEPS) day/step const DT: f64 = 1.0 / (64.0 * STEPS as f64); fn n_body(mut query: Query<(&Mass, &mut Position, &mut Velocity)>) { // Pull data out once. This copy lets us integrate freely without borrow issues. // (If you want zero copies, use a Resource scratch buffer and reuse it across frames.) let mut bodies: Vec<(f64, DVec3, DVec3)> = query .iter() .map(|(mass, pos, vel)| (mass.0.0, pos.0.0, vel.0.0)) .collect(); let n = bodies.len(); if n < 2 { // Nothing to do; write back if needed and return for (i, (_m, mut pos, mut vel)) in query.iter_mut().enumerate() { if let Some(b) = bodies.get(i) { pos.0.0 = b.1; vel.0.0 = b.2; } } return; } // Single reusable acceleration buffer let mut acc: Vec = vec![DVec3::ZERO; n]; // Pairwise acceleration accumulation (O(n^2)/2) with Newton's 3rd law let compute_accelerations = |bodies: &[(f64, DVec3, DVec3)], acc: &mut [DVec3]| { acc.fill(DVec3::ZERO); for i in 0..n - 1 { let (mi, pi, _) = bodies[i]; for j in (i + 1)..n { let (mj, pj, _) = bodies[j]; let r = pj - pi; // AU let r2 = r.length_squared(); // AU^2 // Tighter epsilon in AU^2; avoids division by ~0 without killing legit close passes if r2 > 1e-20 { // a_i = G * m_j * r / r^3, a_j = -G * m_i * r / r^3 let inv_r = r2.sqrt().recip(); // 1 / r let inv_r3 = inv_r * inv_r * inv_r; // 1 / r^3 let a_i = r * (G_AU * mj * inv_r3); let a_j = r * (G_AU * mi * inv_r3); acc[i] += a_i; acc[j] -= a_j; // opposite direction } } } }; // Leapfrog / velocity-Verlet for _ in 0..STEPS { // v += a(q) * dt/2 compute_accelerations(&bodies, &mut acc); for i in 0..n { bodies[i].2 += acc[i] * (DT * 0.5); } // q += v * dt for i in 0..n { let vel = bodies[i].2; // copy DVec3 (cheap, it's just 3 f64s) bodies[i].1 += vel * DT; } // v += a(q_new) * dt/2 compute_accelerations(&bodies, &mut acc); for i in 0..n { bodies[i].2 += acc[i] * (DT * 0.5); } } // Write back for (i, (_mass, mut pos, mut vel)) in query.iter_mut().enumerate() { let (_, p, v) = bodies[i]; pos.0.0 = p; vel.0.0 = v; } } fn main() { App::new() .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { title: "Solar Sim".to_string(), mode: WindowMode::BorderlessFullscreen(MonitorSelection::Primary), resolution: WindowResolution::default().with_scale_factor_override(2.0), ..default() }), ..default() })) .add_plugins(EguiPlugin::default()) .add_plugins(WorldInspectorPlugin::new()) .add_plugins(SolarRenderingPlugin) // we want Bevy to measure these values for us: .add_plugins(bevy::diagnostic::FrameTimeDiagnosticsPlugin::default()) .add_plugins(bevy::diagnostic::EntityCountDiagnosticsPlugin) .add_plugins(bevy::diagnostic::SystemInformationDiagnosticsPlugin) .add_plugins(bevy::render::diagnostic::RenderDiagnosticsPlugin) .add_plugins(PerfUiPlugin) .add_systems( Startup, (setup_solar_system, initialize_camera_follow).chain(), ) .add_systems(FixedUpdate, n_body) .run(); }