Tainted\\Coders

Bevy Cameras

Bevy version: 0.14Last updated:

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:

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.

A square frustrum

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:

  1. On-screen coordinates (the position of the pixel on a screen)
  2. 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);
  }
}