This commit is contained in:
Leon Liu 2025-08-14 10:00:31 +09:00
parent 1e6d352e00
commit 432e5a737a
4 changed files with 268 additions and 43 deletions

54
Cargo.lock generated
View File

@ -154,6 +154,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_log-sys"
version = "0.3.2"
@ -1704,6 +1710,18 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
@ -2663,6 +2681,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "image"
version = "0.25.6"
@ -2756,6 +2798,17 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "iyes_perf_ui"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e4468c51a47d2422a3a3e01b45cb47ed0fbce520495dd7de495bcfe8ce0f856"
dependencies = [
"bevy",
"chrono",
"num-traits",
]
[[package]]
name = "jni"
version = "0.21.1"
@ -4204,6 +4257,7 @@ dependencies = [
"bevy",
"bevy-inspector-egui",
"bevy_panorbit_camera",
"iyes_perf_ui",
]
[[package]]

View File

@ -7,6 +7,7 @@ edition = "2024"
bevy = "0.16"
bevy-inspector-egui = "0.33.1"
bevy_panorbit_camera = "0.26"
iyes_perf_ui = "0.5.0"
# Enable a small amount of optimization in the dev profile.
[profile.dev]

87
examples/cam_follow.rs Normal file
View File

@ -0,0 +1,87 @@
//! Demonstrates how to have the camera follow a target object
use bevy::prelude::*;
use bevy_panorbit_camera::{PanOrbitCamera, PanOrbitCameraPlugin};
use std::f32::consts::TAU;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(PanOrbitCameraPlugin)
.add_systems(Startup, setup)
.add_systems(Update, (animate_cube, cam_follow).chain())
.run();
}
#[derive(Component)]
struct Cube;
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// Ground
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(0.5, 0.5))),
MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
));
// Cube
commands
.spawn((
Mesh3d(meshes.add(Cuboid::new(0.1, 0.1, 0.1))),
MeshMaterial3d(materials.add(Color::srgb(0.8, 0.7, 0.6))),
Transform::from_xyz(0.0, 0.5, 0.0),
))
.insert(Cube);
// Light
commands.spawn((
PointLight {
shadows_enabled: true,
..default()
},
Transform::from_xyz(4.0, 8.0, 4.0),
));
// Camera
commands.spawn((
Transform::from_translation(Vec3::new(0.0, 0.15, 0.5)),
PanOrbitCamera {
// Panning the camera changes the focus, and so you most likely want to disable
// panning when setting the focus manually
pan_sensitivity: 0.0,
// If you want to fully control the camera's focus, set smoothness to 0 so it
// immediately snaps to that location. If you want the 'follow' to be smoothed,
// leave this at default or set it to something between 0 and 1.
pan_smoothness: 0.0,
..default()
},
));
}
/// Move the cube in a circle around the Y axis
fn animate_cube(
time: Res<Time>,
mut cube_q: Query<&mut Transform, With<Cube>>,
mut angle: Local<f32>,
) {
if let Ok(mut cube_tfm) = cube_q.single_mut() {
// Rotate 20 degrees a second, wrapping around to 0 after a full rotation
*angle += 20f32.to_radians() * time.delta_secs() % TAU;
// Convert angle to position
let pos = Vec3::new(angle.sin() * 0.15, 0.05, angle.cos() * 0.15);
cube_tfm.translation = pos;
}
}
/// Set the camera's focus to the cube's position
fn cam_follow(mut pan_orbit_q: Query<&mut PanOrbitCamera>, cube_q: Query<&Transform, With<Cube>>) {
if let Ok(mut pan_orbit) = pan_orbit_q.single_mut() {
if let Ok(cube_tfm) = cube_q.single() {
pan_orbit.target_focus = cube_tfm.translation;
// Whenever changing properties manually like this, it's necessary to force
// PanOrbitCamera to update this frame (by default it only updates when there are
// input events).
pan_orbit.force_update = true;
}
}
}

View File

@ -1,11 +1,12 @@
use bevy::{
core_pipeline::{bloom::Bloom, tonemapping::Tonemapping},
input::mouse::{MouseScrollUnit, MouseWheel},
math::DVec3,
prelude::*,
window::WindowMode,
window::{WindowMode, WindowResolution},
};
use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};
use bevy_panorbit_camera::{PanOrbitCamera, PanOrbitCameraPlugin};
use iyes_perf_ui::{PerfUiPlugin, prelude::PerfUiAllEntries};
// Scaling factor to convert AU to game units
// Neptune is approximately 30.1 astronomical units (AU) from the Sun
@ -55,6 +56,8 @@ struct ObjectBundle {
#[derive(Component)]
struct Star;
#[derive(Component)]
struct Earth;
// Component for UI name labels
#[derive(Component)]
@ -66,20 +69,33 @@ struct ObjectLabel {
#[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.add_systems(Startup, (setup_rendering, setup_ui))
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(Update, (update_label_positions, handle_label_clicks));
.add_systems(PostUpdate, update_label_positions)
.add_systems(Update, (handle_label_clicks, handle_scroll_zoom));
}
}
@ -93,8 +109,6 @@ fn sync_radius_to_mesh(
// Convert AU to game units for rendering
let render_radius = radius.0.0 * AU_TO_GAME_UNITS;
println!("render_radius: {:?}", render_radius);
// Create or update sphere mesh
let sphere_mesh = meshes.add(Sphere::new(render_radius as f32));
let material = materials.add(if star.is_none() {
@ -129,7 +143,6 @@ fn sync_position_to_transform(
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;
println!("scaled_position: {:?}", scaled_position);
match transform {
Some(mut t) => {
// Update existing transform
@ -161,13 +174,14 @@ fn setup_rendering(mut commands: Commands) {
}),
Tonemapping::TonyMcMapface,
Transform::from_translation(Vec3::new(0., 0., 10.0)),
Bloom::NATURAL,
PanOrbitCamera {
pan_sensitivity: 0.0, // Disable panning by setting sensitivity to 0
focus: Vec3::ZERO,
zoom_lower_limit: 1e-8,
..default()
},
// Bloom::NATURAL,
// PanOrbitCamera {
// pan_sensitivity: 0.0, // Disable panning by setting sensitivity to 0
// focus: Vec3::ZERO,
// zoom_lower_limit: 1e-8,
// allow_upside_down: true,
// ..default()
// },
));
commands.spawn(PointLight {
@ -175,6 +189,7 @@ fn setup_rendering(mut commands: Commands) {
shadows_enabled: true,
..default()
});
commands.spawn(PerfUiAllEntries::default());
}
fn setup_ui(mut commands: Commands) {
@ -225,7 +240,7 @@ fn sync_name_labels(
fn update_label_positions(
mut label_query: Query<(&mut Node, &ObjectLabel, &mut Text), With<ObjectLabel>>,
objects_query: Query<(&Position, &Name, &Radius)>,
objects_query: Query<(&GlobalTransform, &Name, &Radius)>,
camera_query: Query<(&Camera, &GlobalTransform)>,
) {
let Ok((camera, camera_transform)) = camera_query.single() else {
@ -233,14 +248,12 @@ fn update_label_positions(
};
for (mut node, label, mut text) in label_query.iter_mut() {
if let Ok((position, name, _radius)) = objects_query.get(label.target_entity) {
let world_pos = position.0.0 * AU_TO_GAME_UNITS;
if let Ok(screen_pos) = camera.world_to_viewport(camera_transform, world_pos.as_vec3())
{
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) {
// Position the label on screen, offset slightly to avoid overlapping the object
node.left = Val::Px(screen_pos.x + 10.0);
node.top = Val::Px(screen_pos.y - 10.0);
node.left = Val::Px(screen_pos.x);
node.top = Val::Px(screen_pos.y);
// Update text in case name changed
text.0 = name.0.0.clone();
@ -261,26 +274,87 @@ fn handle_label_clicks(
(&Interaction, &ObjectLabel),
(Changed<Interaction>, With<ObjectLabel>),
>,
trackable_objects: Query<(&Position, &Trackable, &Radius)>,
mut camera_query: Query<&mut PanOrbitCamera>,
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((position, _trackable, radius)) = trackable_objects.get(label.target_entity) {
if let Ok(mut pan_orbit) = camera_query.single_mut() {
// Focus camera on the clicked object using target values for smooth transitions
pan_orbit.target_focus = (position.0.0 * AU_TO_GAME_UNITS).as_vec3();
pan_orbit.target_radius = (radius.0.0 * AU_TO_GAME_UNITS) as f32 * 4.;
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;
// Also set the immediate values to ensure it takes effect
// pan_orbit.focus = position.0.0.as_vec3();
// pan_orbit.radius = Some(trackable.zoom_distance);
// 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 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) {
// Sun - From NASA JPL Horizons data (J2000.0 epoch)
// Physical properties: Mass = 1988410 x 10^24 kg, Radius = 695700 km = 0.00465 AU
@ -316,6 +390,7 @@ fn setup_solar_system(mut commands: Commands) {
radius: Radius(DistanceAu(0.0000426)), // Mean radius in AU (6371.01 km / 149597870.691)
},
Trackable {},
Earth,
));
// Moon - From NASA JPL Horizons data (A.D. 2000-Jan-01 12:00:00.0000 TDB)
@ -415,15 +490,23 @@ fn main() {
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(PanOrbitCameraPlugin)
.add_plugins(EguiPlugin::default())
.add_plugins(WorldInspectorPlugin::new())
.add_plugins(SolarRenderingPlugin)
.add_systems(Startup, setup_solar_system)
.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();
}