|
| 1 | +//! Shows how to zoom and orbit orthographic and perspective projection cameras. |
| 2 | +
|
| 3 | +use std::{ |
| 4 | + f32::consts::{FRAC_PI_2, PI}, |
| 5 | + ops::Range, |
| 6 | +}; |
| 7 | + |
| 8 | +use bevy::{input::mouse::AccumulatedMouseScroll, prelude::*, render::camera::ScalingMode}; |
| 9 | + |
| 10 | +#[derive(Debug, Default, Resource)] |
| 11 | +struct CameraSettings { |
| 12 | + pub orbit_distance: f32, |
| 13 | + // Multiply keyboard inputs by this factor |
| 14 | + pub orbit_speed: f32, |
| 15 | + // Clamp fixed vertical scale to this range |
| 16 | + pub orthographic_zoom_range: Range<f32>, |
| 17 | + // Multiply mouse wheel inputs by this factor |
| 18 | + pub orthographic_zoom_speed: f32, |
| 19 | + // Clamp field of view to this range |
| 20 | + pub perspective_zoom_range: Range<f32>, |
| 21 | + // Multiply mouse wheel inputs by this factor |
| 22 | + pub perspective_zoom_speed: f32, |
| 23 | + // Clamp pitch to this range |
| 24 | + pub pitch_range: Range<f32>, |
| 25 | +} |
| 26 | + |
| 27 | +fn main() { |
| 28 | + App::new() |
| 29 | + .add_plugins(DefaultPlugins) |
| 30 | + .init_resource::<CameraSettings>() |
| 31 | + .add_systems(Startup, (setup, instructions)) |
| 32 | + .add_systems(Update, (orbit, switch_projection, zoom)) |
| 33 | + .run(); |
| 34 | +} |
| 35 | + |
| 36 | +/// Set up a simple 3D scene |
| 37 | +fn setup( |
| 38 | + mut camera_settings: ResMut<CameraSettings>, |
| 39 | + mut commands: Commands, |
| 40 | + mut meshes: ResMut<Assets<Mesh>>, |
| 41 | + mut materials: ResMut<Assets<StandardMaterial>>, |
| 42 | +) { |
| 43 | + // Perspective projections use field of view, expressed in radians. We would |
| 44 | + // normally not set it to more than π, which represents a 180° FOV. |
| 45 | + let min_fov = PI / 5.; |
| 46 | + let max_fov = PI - 0.2; |
| 47 | + |
| 48 | + // In orthographic projections, we specify sizes in world units. The below values |
| 49 | + // are very roughly similar to the above FOV settings, in terms of how "far away" |
| 50 | + // the subject will appear when used with FixedVertical scaling mode. |
| 51 | + let min_zoom = 5.0; |
| 52 | + let max_zoom = 150.0; |
| 53 | + |
| 54 | + // Limiting pitch stops some unexpected rotation past 90° up or down. |
| 55 | + let pitch_limit = FRAC_PI_2 - 0.01; |
| 56 | + |
| 57 | + camera_settings.orbit_distance = 10.0; |
| 58 | + camera_settings.orbit_speed = 1.0; |
| 59 | + camera_settings.orthographic_zoom_range = min_zoom..max_zoom; |
| 60 | + camera_settings.orthographic_zoom_speed = 1.0; |
| 61 | + camera_settings.perspective_zoom_range = min_fov..max_fov; |
| 62 | + // Changes in FOV are much more noticeable due to its limited range in radians |
| 63 | + camera_settings.perspective_zoom_speed = 0.05; |
| 64 | + camera_settings.pitch_range = -pitch_limit..pitch_limit; |
| 65 | + |
| 66 | + commands.spawn(( |
| 67 | + Name::new("Camera"), |
| 68 | + Camera3dBundle { |
| 69 | + projection: OrthographicProjection { |
| 70 | + scaling_mode: ScalingMode::FixedVertical( |
| 71 | + camera_settings.orthographic_zoom_range.start, |
| 72 | + ), |
| 73 | + ..OrthographicProjection::default_3d() |
| 74 | + } |
| 75 | + .into(), |
| 76 | + transform: Transform::from_xyz(5.0, 5.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y), |
| 77 | + ..default() |
| 78 | + }, |
| 79 | + )); |
| 80 | + |
| 81 | + commands.spawn(( |
| 82 | + Name::new("Plane"), |
| 83 | + PbrBundle { |
| 84 | + mesh: meshes.add(Plane3d::default().mesh().size(5.0, 5.0)), |
| 85 | + material: materials.add(StandardMaterial { |
| 86 | + base_color: Color::srgb(0.3, 0.5, 0.3), |
| 87 | + // Turning off culling keeps the plane visible when viewed from beneath. |
| 88 | + cull_mode: None, |
| 89 | + ..default() |
| 90 | + }), |
| 91 | + ..default() |
| 92 | + }, |
| 93 | + )); |
| 94 | + |
| 95 | + commands.spawn(( |
| 96 | + Name::new("Cube"), |
| 97 | + PbrBundle { |
| 98 | + mesh: meshes.add(Cuboid::default()), |
| 99 | + material: materials.add(Color::srgb(0.8, 0.7, 0.6)), |
| 100 | + transform: Transform::from_xyz(1.5, 0.51, 1.5), |
| 101 | + ..default() |
| 102 | + }, |
| 103 | + )); |
| 104 | + |
| 105 | + commands.spawn(( |
| 106 | + Name::new("Light"), |
| 107 | + PointLightBundle { |
| 108 | + transform: Transform::from_xyz(3.0, 8.0, 5.0), |
| 109 | + ..default() |
| 110 | + }, |
| 111 | + )); |
| 112 | +} |
| 113 | + |
| 114 | +fn instructions(mut commands: Commands) { |
| 115 | + commands |
| 116 | + .spawn(( |
| 117 | + Name::new("Instructions"), |
| 118 | + NodeBundle { |
| 119 | + style: Style { |
| 120 | + align_items: AlignItems::Start, |
| 121 | + flex_direction: FlexDirection::Column, |
| 122 | + justify_content: JustifyContent::Start, |
| 123 | + width: Val::Percent(100.), |
| 124 | + ..default() |
| 125 | + }, |
| 126 | + ..default() |
| 127 | + }, |
| 128 | + )) |
| 129 | + .with_children(|parent| { |
| 130 | + parent.spawn(TextBundle::from_section( |
| 131 | + "Scroll mouse wheel to zoom in/out", |
| 132 | + TextStyle::default(), |
| 133 | + )); |
| 134 | + parent.spawn(TextBundle::from_section( |
| 135 | + "W or S: pitch", |
| 136 | + TextStyle::default(), |
| 137 | + )); |
| 138 | + parent.spawn(TextBundle::from_section( |
| 139 | + "A or D: yaw", |
| 140 | + TextStyle::default(), |
| 141 | + )); |
| 142 | + }); |
| 143 | +} |
| 144 | + |
| 145 | +fn orbit( |
| 146 | + mut camera: Query<&mut Transform, With<Camera>>, |
| 147 | + camera_settings: Res<CameraSettings>, |
| 148 | + keyboard_input: Res<ButtonInput<KeyCode>>, |
| 149 | + time: Res<Time>, |
| 150 | +) { |
| 151 | + let mut transform = camera.single_mut(); |
| 152 | + |
| 153 | + let mut delta_pitch = 0.0; |
| 154 | + let mut delta_yaw = 0.0; |
| 155 | + |
| 156 | + if keyboard_input.pressed(KeyCode::KeyW) { |
| 157 | + delta_pitch += camera_settings.orbit_speed; |
| 158 | + } |
| 159 | + if keyboard_input.pressed(KeyCode::KeyA) { |
| 160 | + delta_yaw -= camera_settings.orbit_speed; |
| 161 | + } |
| 162 | + if keyboard_input.pressed(KeyCode::KeyS) { |
| 163 | + delta_pitch -= camera_settings.orbit_speed; |
| 164 | + } |
| 165 | + if keyboard_input.pressed(KeyCode::KeyD) { |
| 166 | + delta_yaw += camera_settings.orbit_speed; |
| 167 | + } |
| 168 | + |
| 169 | + // Incorporating the delta time between calls prevents this from being framerate-bound. |
| 170 | + delta_pitch *= time.delta_seconds(); |
| 171 | + delta_yaw *= time.delta_seconds(); |
| 172 | + |
| 173 | + // Obtain the existing pitch, yaw, and roll values from the transform. |
| 174 | + let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::YXZ); |
| 175 | + |
| 176 | + // Establish the new yaw and pitch, preventing the pitch value from exceeding our limits. |
| 177 | + let pitch = (pitch + delta_pitch).clamp( |
| 178 | + camera_settings.pitch_range.start, |
| 179 | + camera_settings.pitch_range.end, |
| 180 | + ); |
| 181 | + let yaw = yaw + delta_yaw; |
| 182 | + transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll); |
| 183 | + |
| 184 | + // Adjust the translation to maintain the correct orientation toward the orbit target. |
| 185 | + transform.translation = Vec3::ZERO - transform.forward() * camera_settings.orbit_distance; |
| 186 | +} |
| 187 | + |
| 188 | +fn switch_projection( |
| 189 | + mut camera: Query<&mut Projection, With<Camera>>, |
| 190 | + camera_settings: Res<CameraSettings>, |
| 191 | + keyboard_input: Res<ButtonInput<KeyCode>>, |
| 192 | +) { |
| 193 | + let mut projection = camera.single_mut(); |
| 194 | + |
| 195 | + if keyboard_input.just_pressed(KeyCode::Space) { |
| 196 | + // Switch projection type |
| 197 | + *projection = match *projection { |
| 198 | + Projection::Orthographic(_) => Projection::Perspective(PerspectiveProjection { |
| 199 | + fov: camera_settings.perspective_zoom_range.start, |
| 200 | + ..default() |
| 201 | + }), |
| 202 | + Projection::Perspective(_) => Projection::Orthographic(OrthographicProjection { |
| 203 | + scaling_mode: ScalingMode::FixedVertical( |
| 204 | + camera_settings.orthographic_zoom_range.start, |
| 205 | + ), |
| 206 | + ..OrthographicProjection::default_3d() |
| 207 | + }), |
| 208 | + } |
| 209 | + } |
| 210 | +} |
| 211 | + |
| 212 | +fn zoom( |
| 213 | + mut camera: Query<&mut Projection, With<Camera>>, |
| 214 | + camera_settings: Res<CameraSettings>, |
| 215 | + mouse_wheel_input: Res<AccumulatedMouseScroll>, |
| 216 | +) { |
| 217 | + let projection = camera.single_mut(); |
| 218 | + |
| 219 | + // Usually, you won't need to handle both types of projection. This is by way of demonstration. |
| 220 | + match projection.into_inner() { |
| 221 | + Projection::Orthographic(ref mut orthographic) => { |
| 222 | + // Get the current scaling_mode value to allow clamping the new value to our zoom range. |
| 223 | + let ScalingMode::FixedVertical(current) = orthographic.scaling_mode else { |
| 224 | + return; |
| 225 | + }; |
| 226 | + // Set a new ScalingMode, clamped to a limited range. |
| 227 | + let zoom_level = (current |
| 228 | + + camera_settings.orthographic_zoom_speed * mouse_wheel_input.delta.y) |
| 229 | + .clamp( |
| 230 | + camera_settings.orthographic_zoom_range.start, |
| 231 | + camera_settings.orthographic_zoom_range.end, |
| 232 | + ); |
| 233 | + orthographic.scaling_mode = ScalingMode::FixedVertical(zoom_level); |
| 234 | + } |
| 235 | + Projection::Perspective(ref mut perspective) => { |
| 236 | + // Adjust the field of view, but keep it within our stated range. |
| 237 | + perspective.fov = (perspective.fov |
| 238 | + + camera_settings.perspective_zoom_speed * mouse_wheel_input.delta.y) |
| 239 | + .clamp( |
| 240 | + camera_settings.perspective_zoom_range.start, |
| 241 | + camera_settings.perspective_zoom_range.end, |
| 242 | + ); |
| 243 | + } |
| 244 | + } |
| 245 | +} |
0 commit comments