Blacksite/crates/editor/src/viewport/camera.rs
Rbanh 2fa64106c8
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Polish inspector add component workflow
2026-06-06 03:08:28 -04:00

244 lines
7.7 KiB
Rust

use bevy::camera::visibility::RenderLayers;
use bevy::input::mouse::{AccumulatedMouseMotion, MouseWheel};
use bevy::prelude::*;
use bevy::window::{CursorOptions, PrimaryWindow};
use bevy_egui::{EguiContexts, EguiGlobalSettings, PrimaryEguiContext};
use transform_gizmo_bevy::prelude::GizmoCamera;
use crate::infra::EditorOnly;
use crate::state::scene_tools_active;
use crate::ui::{egui_captures_keyboard, UiState};
use settings::ProjectSettings;
#[derive(Component)]
pub struct EditorCamera {
pub fly_speed: f32,
pub look_sensitivity: f32,
pub pan_speed: f32,
}
impl Default for EditorCamera {
fn default() -> Self {
Self {
fly_speed: 14.0,
look_sensitivity: 0.003,
pan_speed: 0.015,
}
}
}
pub struct EditorCameraPlugin;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CameraNavigationMode {
Look,
Pan,
}
#[derive(Resource, Default)]
struct EditorCameraInputState {
mode: Option<CameraNavigationMode>,
cursor_hidden_for_look: bool,
cursor_visible_before_look: bool,
}
impl Plugin for EditorCameraPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<EditorCameraInputState>()
.add_systems(Startup, spawn_editor_camera)
.add_systems(
Update,
(sync_editor_camera_from_settings, update_editor_camera)
.chain()
.run_if(scene_tools_active),
);
}
}
fn sync_editor_camera_from_settings(
settings: Res<ProjectSettings>,
mut cameras: Query<&mut EditorCamera>,
) {
if !settings.is_changed() {
return;
}
let input = &settings.input;
for mut camera in &mut cameras {
camera.fly_speed = input.editor_fly_speed;
camera.look_sensitivity = input.editor_look_sensitivity;
camera.pan_speed = input.editor_pan_speed;
}
}
fn spawn_editor_camera(
mut commands: Commands,
mut egui_global_settings: ResMut<EguiGlobalSettings>,
) {
// Own the egui primary context explicitly. If bevy_egui auto-attaches it to
// a 3D camera, `set_camera_viewport` then crops egui's own screen rect to the
// shrinking viewport region, and egui_dock eventually lays out into a NaN
// rect (panic: "rect is nan in advance_cursor_after_rect").
egui_global_settings.auto_create_primary_context = false;
commands.spawn((
Name::new("Editor Camera"),
EditorOnly,
EditorCamera::default(),
RenderLayers::default(),
GizmoCamera,
Camera3d::default(),
Camera {
clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 1.0)),
..default()
},
Transform::from_xyz(10.0, 8.0, 18.0).looking_at(Vec3::ZERO, Vec3::Y),
));
// Dedicated full-window 2D camera that renders only egui on top of the 3D
// scene. Keeping the egui context off the 3D cameras avoids the
// viewport-cropping feedback loop described above.
commands.spawn((
Name::new("Editor Egui Camera"),
EditorOnly,
Camera2d,
PrimaryEguiContext,
RenderLayers::none(),
Camera {
order: 10,
clear_color: ClearColorConfig::None,
..default()
},
));
}
#[allow(clippy::too_many_arguments)]
fn update_editor_camera(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mouse_motion: Res<AccumulatedMouseMotion>,
mut scroll: MessageReader<MouseWheel>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
mut input_state: ResMut<EditorCameraInputState>,
mut cameras: Query<(&mut Transform, &EditorCamera)>,
mut cursor: Single<&mut CursorOptions, With<PrimaryWindow>>,
) -> Result {
let ctx = contexts.ctx_mut()?;
let in_scene = ui_state.pointer_in_viewport;
let navigation_pressed =
buttons.pressed(MouseButton::Right) || buttons.pressed(MouseButton::Middle);
if !navigation_pressed {
input_state.mode = None;
restore_editor_camera_cursor(&mut input_state, &mut cursor);
}
if egui_captures_keyboard(ctx) {
input_state.mode = None;
restore_editor_camera_cursor(&mut input_state, &mut cursor);
return Ok(());
}
if in_scene && buttons.just_pressed(MouseButton::Right) {
input_state.mode = Some(CameraNavigationMode::Look);
input_state.cursor_visible_before_look = cursor.visible;
input_state.cursor_hidden_for_look = true;
cursor.visible = false;
} else if in_scene && buttons.just_pressed(MouseButton::Middle) {
input_state.mode = Some(CameraNavigationMode::Pan);
restore_editor_camera_cursor(&mut input_state, &mut cursor);
}
if input_state.mode.is_none() && navigation_pressed {
return Ok(());
}
if !in_scene && input_state.mode.is_none() && ctx.wants_pointer_input() {
return Ok(());
}
if in_scene
&& input_state.mode.is_none()
&& ctx.wants_pointer_input()
&& !buttons.pressed(MouseButton::Right)
{
return Ok(());
}
let dt = time.delta_secs();
let raw_scroll_delta: f32 = scroll.read().map(|event| event.y).sum();
let scroll_delta = if in_scene && !ctx.wants_pointer_input() {
raw_scroll_delta
} else {
0.0
};
for (mut transform, camera) in &mut cameras {
let looking = matches!(input_state.mode, Some(CameraNavigationMode::Look))
&& buttons.pressed(MouseButton::Right);
let panning = matches!(input_state.mode, Some(CameraNavigationMode::Pan))
&& buttons.pressed(MouseButton::Middle);
if looking {
let delta = mouse_motion.delta;
if delta != Vec2::ZERO {
let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ);
let yaw = yaw - delta.x * camera.look_sensitivity;
let pitch = (pitch - delta.y * camera.look_sensitivity).clamp(
-std::f32::consts::FRAC_PI_2 + 0.01,
std::f32::consts::FRAC_PI_2 - 0.01,
);
transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, 0.0);
}
}
let forward = transform.forward().as_vec3();
let right = transform.right().as_vec3();
let up = Vec3::Y;
let mut wish = Vec3::ZERO;
if looking {
if keys.pressed(KeyCode::KeyW) {
wish += forward;
}
if keys.pressed(KeyCode::KeyS) {
wish -= forward;
}
if keys.pressed(KeyCode::KeyD) {
wish += right;
}
if keys.pressed(KeyCode::KeyA) {
wish -= right;
}
if keys.pressed(KeyCode::KeyE) {
wish += up;
}
if keys.pressed(KeyCode::KeyQ) {
wish -= up;
}
}
let sprint = if keys.pressed(KeyCode::ShiftLeft) {
3.0
} else {
1.0
};
transform.translation += wish.normalize_or_zero() * camera.fly_speed * sprint * dt;
transform.translation += forward * scroll_delta * camera.fly_speed * 0.08;
if panning {
let delta = mouse_motion.delta;
let distance_scale = transform.translation.length().max(1.0).sqrt();
transform.translation +=
(-right * delta.x + up * delta.y) * camera.pan_speed * distance_scale;
}
}
Ok(())
}
fn restore_editor_camera_cursor(
input_state: &mut EditorCameraInputState,
cursor: &mut CursorOptions,
) {
if input_state.cursor_hidden_for_look {
cursor.visible = input_state.cursor_visible_before_look;
input_state.cursor_hidden_for_look = false;
}
}