Bevy Cameras
If you load up bevy with just a Window
and no cameras you will get a black screen.
Your windows handle spawning and manipulating the operating system concept of a window but how would Bevy know what to actually display on it?
That's where a camera is used. They are positioned similar to other entities with a Transform
component.
The coordinate system in Bevy is "right handed" so:
- X increases going to the right
- Y increases going up
- Z increases coming towards the screen
- The default center of the screen is (0, 0)
When we spawn a camera we use Camera2dBundle
or Camera3dBundle
depending on our game.
// Useful for marking the "main" camera if we have many
#[derive(Component)]
pub struct MainCamera;
fn initialize_camera(
mut commands: Commands
) {
commands.spawn((
Camera2dBundle::default(),
MainCamera
));
}
Camera behavior is usually quite generic and separate from the rest of the game logic so I prefer creating a camera plugin and adding it to the app:
pub struct CameraPlugin;
impl Plugin for CameraPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, initialize_camera);
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(CameraPlugin)
.run();
}
The bundles themselves have a variety of options we can tweak:
// https://docs.rs/bevy/latest/bevy/core_pipeline/prelude/struct.Camera2dBundle.html
#[derive(Bundle)]
pub struct Camera2dBundle {
pub camera: Camera,
pub camera_render_graph: CameraRenderGraph,
pub projection: OrthographicProjection,
pub visible_entities: VisibleEntities,
pub frustum: Frustum,
pub transform: Transform,
pub global_transform: GlobalTransform,
pub camera_2d: Camera2d,
pub tonemapping: Tonemapping,
pub deband_dither: DebandDither,
pub main_texture_usages: CameraMainTextureUsages,}
Camera projection
Bevy's default camera uses an orthogonal projection with a symmetric frustrum.
Don't panic! Let's break that all down.
Projection here refers to the process of transforming a 3D scene into a 2D representation on a screen or viewport.
Since computer screens are 2D, we need to convert the 3D world of objects and their positions into a flat image that can be displayed.
An orthogonal projection is a type of projection that preserves the relative sizes of objects and their distances from us.
In other words, if two objects are at different distances from the viewer in the 3D world, their projected sizes on the 2D screen will accurately reflect their sizes in the 3D world.
A frustum, on the other hand, refers to a truncated pyramid shape that represents the viewing volume or the field of view in computer graphics. It is like a pyramid with the top cut off, resulting in a smaller pyramid shape.
In a symmetric frustum, the shape of the truncated pyramid is balanced or symmetrical, which means that the left and right sides, as well as the top and bottom sides, are equal in size and shape.
Directing the camera
To move your camera around the scene we just need to change its translation
:
fn move_camera(
mut camera: Query<&mut Transform, (With<Camera2d>, Without<Player>)>,
player: Query<&Transform, (With<Player>, Without<Camera2d>)>,
time: Res<Time>,
) {
let Ok(mut camera) = camera.get_single_mut() else {
return;
};
let Ok(player) = player.get_single() else {
return;
};
let Vec3 { x, y, .. } = player.translation;
let direction = Vec3::new(x, y, camera.translation.z);
camera.translation = camera
.translation
.lerp(direction, time.delta_seconds() * 2.);
}
We could have snapped the camera to our players position every frame, but here we are using linear interpolation to smooth this effect which is common for top down games.
To change the zoom on our camera we manipulate the OrthographicProjection
component.
fn zoom_control_system(
input: Res<ButtonInput<KeyCode>>,
mut camera_query: Query<&mut OrthographicProjection, With<MainCamera>>,
) {
let mut projection = camera_query.single_mut();
if input.pressed(KeyCode::Minus) {
projection.scale += 0.2;
}
if input.pressed(KeyCode::Equal) {
projection.scale -= 0.2;
}
projection.scale = projection.scale.clamp(0.2, 5.);
}
Render Layers
When we want a camera to only render certain entities we can use the RenderLayers
component.
By default all components are rendered on layer 0 and there are 32 TOTAL_LAYERS
to choose from.
Attaching it to our camera sets which entities it should render.
Attaching it to our other entities sets which camera should do the rendering.
// RenderLayers are Copy so aliases work to improve clarity
const BACKGROUND: RenderLayers = RenderLayers::layer(1);
const FOREGROUND: RenderLayers = RenderLayers::layer(2);
fn initialize_cameras(mut commands: Commands) {
commands.spawn((
Camera2dBundle::default(),
FOREGROUND,
MainCamera
));
commands.spawn((
Camera2dBundle::default(),
BACKGROUND
));
}
#[derive(Component)]
struct Player;
fn spawn_player(
mut commands: Commands
) {
commands.spawn((
Player,
FOREGROUND
));
}
Rendering Order
Multiple cameras will all render to the same window. When we want to control the ordering of this rendering we can use a priority.
Cameras with a higher order are rendered later, and thus on top of lower order cameras.
We can imagine it a bit like an oil painter. The first layer you apply to the canvas is the background, and the subsequent layers are painted on over top.
use bevy::render::camera::ClearColorConfig;
fn render_order(
mut commands: Commands
) {
// This camera defaults to priority 0 and is rendered "first" / "at the back"
commands.spawn(Camera3dBundle::default());
// This camera renders "after" / "at the front"
commands.spawn(Camera3dBundle {
camera_3d: Camera3d {
..default()
},
camera: Camera {
// renders after / on top of the main camera
order: 1,
// don't clear the color while rendering this camera
clear_color: ClearColorConfig::None,
..default()
},
..default()
});
}
Mouse coordinates
When you place your mouse on the screen it would two positions:
- On-screen coordinates (the position of the pixel on a screen)
- World coordinates (the position of the mouse projected onto our game)
So when we read our Window::cursor_position
we are only getting the on-screen coordinates. We would have to further convert them by projecting them according to our camera:
fn mouse_coordinates(
window_query: Query<&Window>,
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
) {
let window = window_query.single();
let (camera, camera_transform) = camera_query.single();
if let Some(world_position) = window
.cursor_position()
.and_then(|cursor| camera.viewport_to_world(camera_transform, cursor))
.map(|ray| ray.origin.truncate())
{
info!("World coords: {}/{}", world_position.x, world_position.y);
}
}