solar-sim/src/main.rs
2025-08-15 09:59:59 +09:00

601 lines
20 KiB
Rust

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<CelestialBodyData>,
}
#[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<Entity>,
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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
query: Query<(Entity, &Radius, Option<&Star>, Option<&Mesh3d>), Changed<Radius>>,
) {
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<Position>>,
) {
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<Name>>,
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<ObjectLabel>>,
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<Interaction>, With<ObjectLabel>),
>,
trackable_objects: Query<(&Transform, &Trackable, &Radius)>,
mut camera_query: Query<&mut Transform, (With<Camera>, Without<Trackable>)>,
mut camera_follow: ResMut<CameraFollow>,
) {
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<CameraFollow>,
objects_query: Query<&Transform, With<Trackable>>,
mut camera_query: Query<&mut Transform, (With<Camera>, Without<Trackable>)>,
) {
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<MouseWheel>,
mut camera_follow: ResMut<CameraFollow>,
) {
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<ButtonInput<KeyCode>>,
mut time: ResMut<Time<Virtual>>,
) {
// 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<Entity, (With<Earth>, With<Trackable>)>,
mut camera_follow: ResMut<CameraFollow>,
) {
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<DVec3> = 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();
}