//! Orbit camera for inspecting 3D assets. //! //! Controls: //! - Left-click + drag → orbit //! - Right-click + drag → pan //! - Scroll wheel → zoom //! - R → reset to default view use bevy::prelude::*; use bevy::input::mouse::{MouseMotion, MouseWheel}; use bevy::render::render_asset::RenderAssetUsages; use bevy::render::render_resource::{ Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension, }; pub struct OrbitCameraPlugin; impl Plugin for OrbitCameraPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, spawn_camera) .add_systems(Update, orbit_camera); } } /// State for the orbit camera controller. #[derive(Component)] pub struct OrbitCamera { /// Distance from the focus point pub radius: f32, /// Rotation around the vertical axis (azimuth) in radians pub yaw: f32, /// Rotation around the horizontal axis (elevation) in radians pub pitch: f32, /// The point the camera orbits around pub focus: Vec3, pub orbit_sensitivity: f32, pub zoom_sensitivity: f32, pub pan_sensitivity: f32, /// Zoom limits — set when framing a model so large scenes can be pulled back /// far enough (the old fixed 0.5..50 cap trapped the camera inside big stages). pub min_radius: f32, pub max_radius: f32, } impl Default for OrbitCamera { fn default() -> Self { Self { radius: 5.0, yaw: std::f32::consts::FRAC_PI_4, pitch: std::f32::consts::FRAC_PI_6, focus: Vec3::ZERO, orbit_sensitivity: 0.005, zoom_sensitivity: 0.3, pan_sensitivity: 0.003, min_radius: 0.05, max_radius: 500.0, } } } fn spawn_camera(mut commands: Commands, mut images: ResMut>) { let orbit = OrbitCamera::default(); let transform = orbit_transform(&orbit); // The game's ship shader reflects a shared HDR environment cubemap off the // hull (three cube samples — see the shader RE). Reproduce that "look" with a // procedural sky cube feeding Bevy's image-based lighting, so metallic // surfaces reflect an environment instead of reading flat. Procedural (not a // bundled KTX2) so it also works on WASM with no extra assets. let env = make_env_cubemap(&mut images); commands.spawn(( Camera3d::default(), // Wide near/far so both tiny weapons and multi-thousand-unit stages fit; // the planes are re-scaled to the zoom distance each frame (below). Projection::Perspective(PerspectiveProjection { near: 0.05, far: 100_000.0, ..default() }), transform, orbit, EnvironmentMapLight { diffuse_map: env.clone(), specular_map: env, intensity: 900.0, ..default() }, )); } /// Build a small procedural sky cubemap (6 faces) for image-based lighting. /// /// A vertical gradient (bright zenith → warm horizon → dark ground) plus a warm /// "sun" highlight roughly where the game's key light sits — enough for a /// metallic hull to read as reflective rather than flat. Stored as an sRGB cube /// (filterable, no half-float encoding needed); a single mip means reflections /// stay sharp (fine for a shiny ship). fn make_env_cubemap(images: &mut Assets) -> Handle { let size: i32 = 64; // Approx key-light direction from the RE (PS c32 ≈ (0,-0.76,-0.65), i.e. light // arriving from top-front); place the bright spot there. let sun = Vec3::new(0.0, 0.85, 0.5).normalize(); let zenith = Vec3::new(0.70, 0.80, 0.95); let horizon = Vec3::new(0.42, 0.45, 0.50); let ground = Vec3::new(0.09, 0.09, 0.11); let srgb = |c: f32| (c.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8; let mut data: Vec = Vec::with_capacity((size * size * 6 * 4) as usize); for face in 0..6 { for y in 0..size { for x in 0..size { let u = (x as f32 + 0.5) / size as f32 * 2.0 - 1.0; let v = (y as f32 + 0.5) / size as f32 * 2.0 - 1.0; // Standard cube-face → direction mapping. let d = match face { 0 => Vec3::new(1.0, -v, -u), 1 => Vec3::new(-1.0, -v, u), 2 => Vec3::new(u, 1.0, v), 3 => Vec3::new(u, -1.0, -v), 4 => Vec3::new(u, -v, 1.0), _ => Vec3::new(-u, -v, -1.0), } .normalize(); let mut col = if d.y >= 0.0 { horizon.lerp(zenith, (d.y).powf(0.6)) } else { horizon.lerp(ground, (-d.y).powf(0.5)) }; let s = d.dot(sun).max(0.0).powf(60.0); col += Vec3::new(1.0, 0.85, 0.6) * s * 1.5; data.extend_from_slice(&[srgb(col.x), srgb(col.y), srgb(col.z), 255]); } } } let mut image = Image::new( Extent3d { width: size as u32, height: size as u32, depth_or_array_layers: 6, }, TextureDimension::D2, data, TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::RENDER_WORLD, ); image.texture_view_descriptor = Some(TextureViewDescriptor { dimension: Some(TextureViewDimension::Cube), ..default() }); images.add(image) } fn orbit_camera( mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>, mouse_buttons: Res>, keys: Res>, mut mouse_motion: EventReader, mut scroll: EventReader, ) { let Ok((mut cam, mut transform, mut projection)) = query.get_single_mut() else { return }; let mut delta_motion = Vec2::ZERO; for ev in mouse_motion.read() { delta_motion += ev.delta; } let mut scroll_delta = 0.0f32; for ev in scroll.read() { scroll_delta += ev.y; } // Orbit (left mouse drag) if mouse_buttons.pressed(MouseButton::Left) { cam.yaw -= delta_motion.x * cam.orbit_sensitivity; cam.pitch -= delta_motion.y * cam.orbit_sensitivity; // Clamp pitch to avoid gimbal lock cam.pitch = cam.pitch.clamp(-1.5, 1.5); } // Pan (right mouse drag) if mouse_buttons.pressed(MouseButton::Right) { let right = transform.rotation * Vec3::X; let up = transform.rotation * Vec3::Y; // Copy fields before mutably borrowing `cam.focus` let pan_sens = cam.pan_sensitivity; let radius = cam.radius; cam.focus -= right * delta_motion.x * pan_sens * radius; cam.focus += up * delta_motion.y * pan_sens * radius; } // Zoom (scroll) cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius; cam.radius = cam.radius.clamp(cam.min_radius, cam.max_radius); // Reset (R key) if keys.just_pressed(KeyCode::KeyR) { *cam = OrbitCamera::default(); } // Keep the clip planes proportional to the zoom distance so depth precision // stays usable across scales (tiny prop → whole stage grid). if let Projection::Perspective(p) = projection.as_mut() { p.near = (cam.radius * 0.02).clamp(0.02, 50.0); p.far = (cam.radius * 50.0).max(2000.0); } *transform = orbit_transform(&cam); } fn orbit_transform(cam: &OrbitCamera) -> Transform { let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0); let offset = rotation * Vec3::new(0.0, 0.0, cam.radius); Transform::from_translation(cam.focus + offset) .looking_at(cam.focus, Vec3::Y) }