diff --git a/Cargo.toml b/Cargo.toml index db60033ec1..651173a6a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3119,6 +3119,17 @@ description = "A 2D top-down camera smoothly following player movements" category = "Camera" wasm = true +[[example]] +name = "first_person_view_model" +path = "examples/camera/first_person_view_model.rs" +doc-scrape-examples = true + +[package.metadata.example.first_person_view_model] +name = "First person view model" +description = "A first-person camera that uses a world model and a view model with different field of views (FOV)" +category = "Camera" +wasm = true + [package.metadata.example.fps_overlay] name = "FPS overlay" description = "Demonstrates FPS overlay" diff --git a/examples/README.md b/examples/README.md index df2d906d2f..f33bcc7918 100644 --- a/examples/README.md +++ b/examples/README.md @@ -246,6 +246,7 @@ Example | Description Example | Description --- | --- [2D top-down camera](../examples/camera/2d_top_down_camera.rs) | A 2D top-down camera smoothly following player movements +[First person view model](../examples/camera/first_person_view_model.rs) | A first-person camera that uses a world model and a view model with different field of views (FOV) ## Dev tools diff --git a/examples/camera/first_person_view_model.rs b/examples/camera/first_person_view_model.rs new file mode 100644 index 0000000000..039058bfae --- /dev/null +++ b/examples/camera/first_person_view_model.rs @@ -0,0 +1,254 @@ +//! This example showcases a 3D first-person camera. +//! +//! The setup presented here is a very common way of organizing a first-person game +//! where the player can see their own arms. We use two industry terms to differentiate +//! the kinds of models we have: +//! +//! - The *view model* is the model that represents the player's body. +//! - The *world model* is everything else. +//! +//! ## Motivation +//! +//! The reason for this distinction is that these two models should be rendered with different field of views (FOV). +//! The view model is typically designed and animated with a very specific FOV in mind, so it is +//! generally *fixed* and cannot be changed by a player. The world model, on the other hand, should +//! be able to change its FOV to accommodate the player's preferences for the following reasons: +//! - *Accessibility*: How prone is the player to motion sickness? A wider FOV can help. +//! - *Tactical preference*: Does the player want to see more of the battlefield? +//! Or have a more zoomed-in view for precision aiming? +//! - *Physical considerations*: How well does the in-game FOV match the player's real-world FOV? +//! Are they sitting in front of a monitor or playing on a TV in the living room? How big is the screen? +//! +//! ## Implementation +//! +//! The `Player` is an entity holding two cameras, one for each model. The view model camera has a fixed +//! FOV of 70 degrees, while the world model camera has a variable FOV that can be changed by the player. +//! +//! We use different `RenderLayers` to select what to render. +//! +//! - The world model camera has no explicit `RenderLayers` component, so it uses the layer 0. +//! All static objects in the scene are also on layer 0 for the same reason. +//! - The view model camera has a `RenderLayers` component with layer 1, so it only renders objects +//! explicitly assigned to layer 1. The arm of the player is one such object. +//! The order of the view model camera is additionally bumped to 1 to ensure it renders on top of the world model. +//! - The light source in the scene must illuminate both the view model and the world model, so it is +//! assigned to both layers 0 and 1. +//! +//! ## Controls +//! +//! | Key Binding | Action | +//! |:---------------------|:--------------| +//! | mouse | Look around | +//! | arrow up | Decrease FOV | +//! | arrow down | Increase FOV | + +use bevy::color::palettes::tailwind; +use bevy::input::mouse::MouseMotion; +use bevy::pbr::NotShadowCaster; +use bevy::prelude::*; +use bevy::render::view::RenderLayers; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems( + Startup, + ( + spawn_view_model, + spawn_world_model, + spawn_lights, + spawn_text, + ), + ) + .add_systems(Update, (move_player, change_fov)) + .run(); +} + +#[derive(Debug, Component)] +struct Player; + +#[derive(Debug, Component)] +struct WorldModelCamera; + +/// Used implicitly by all entities without a `RenderLayers` component. +/// Our world model camera and all objects other than the player are on this layer. +/// The light source belongs to both layers. +const DEFAULT_RENDER_LAYER: usize = 0; + +/// Used by the view model camera and the player's arm. +/// The light source belongs to both layers. +const VIEW_MODEL_RENDER_LAYER: usize = 1; + +fn spawn_view_model( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let arm = meshes.add(Cuboid::new(0.1, 0.1, 0.5)); + let arm_material = materials.add(Color::from(tailwind::TEAL_200)); + + commands + .spawn(( + Player, + SpatialBundle { + transform: Transform::from_xyz(0.0, 1.0, 0.0), + ..default() + }, + )) + .with_children(|parent| { + parent.spawn(( + WorldModelCamera, + Camera3dBundle { + projection: PerspectiveProjection { + fov: 90.0_f32.to_radians(), + ..default() + } + .into(), + ..default() + }, + )); + + // Spawn view model camera. + parent.spawn(( + Camera3dBundle { + camera: Camera { + // Bump the order to render on top of the world model. + order: 1, + ..default() + }, + projection: PerspectiveProjection { + fov: 70.0_f32.to_radians(), + ..default() + } + .into(), + ..default() + }, + // Only render objects belonging to the view model. + RenderLayers::layer(VIEW_MODEL_RENDER_LAYER), + )); + + // Spawn the player's right arm. + parent.spawn(( + MaterialMeshBundle { + mesh: arm, + material: arm_material, + transform: Transform::from_xyz(0.2, -0.1, -0.25), + ..default() + }, + // Ensure the arm is only rendered by the view model camera. + RenderLayers::layer(VIEW_MODEL_RENDER_LAYER), + // The arm is free-floating, so shadows would look weird. + NotShadowCaster, + )); + }); +} + +fn spawn_world_model( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let floor = meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(10.0))); + let cube = meshes.add(Cuboid::new(2.0, 0.5, 1.0)); + let material = materials.add(Color::WHITE); + + // The world model camera will render the floor and the cubes spawned in this system. + // Assigning no `RenderLayers` component defaults to layer 0. + + commands.spawn(MaterialMeshBundle { + mesh: floor, + material: material.clone(), + ..default() + }); + + commands.spawn(MaterialMeshBundle { + mesh: cube.clone(), + material: material.clone(), + transform: Transform::from_xyz(0.0, 0.25, -3.0), + ..default() + }); + + commands.spawn(MaterialMeshBundle { + mesh: cube, + material, + transform: Transform::from_xyz(0.75, 1.75, 0.0), + ..default() + }); +} + +fn spawn_lights(mut commands: Commands) { + commands.spawn(( + PointLightBundle { + point_light: PointLight { + color: Color::from(tailwind::ROSE_300), + shadows_enabled: true, + ..default() + }, + transform: Transform::from_xyz(-2.0, 4.0, -0.75), + ..default() + }, + // The light source illuminates both the world model and the view model. + RenderLayers::from_layers(&[DEFAULT_RENDER_LAYER, VIEW_MODEL_RENDER_LAYER]), + )); +} + +fn spawn_text(mut commands: Commands) { + commands + .spawn(NodeBundle { + style: Style { + position_type: PositionType::Absolute, + bottom: Val::Px(12.0), + left: Val::Px(12.0), + ..default() + }, + ..default() + }) + .with_children(|parent| { + parent.spawn(TextBundle::from_section( + concat!( + "Move the camera with your mouse.\n", + "Press arrow up to decrease the FOV of the world model.\n", + "Press arrow down to increase the FOV of the world model." + ), + TextStyle { + font_size: 25.0, + ..default() + }, + )); + }); +} + +fn move_player( + mut mouse_motion: EventReader, + mut player: Query<&mut Transform, With>, +) { + let mut transform = player.single_mut(); + for motion in mouse_motion.read() { + let yaw = -motion.delta.x * 0.003; + let pitch = -motion.delta.y * 0.002; + // Order of rotations is important, see + transform.rotate_y(yaw); + transform.rotate_local_x(pitch); + } +} + +fn change_fov( + input: Res>, + mut world_model_projection: Query<&mut Projection, With>, +) { + let mut projection = world_model_projection.single_mut(); + let Projection::Perspective(ref mut perspective) = projection.as_mut() else { + unreachable!( + "The `Projection` component was explicitly built with `Projection::Perspective`" + ); + }; + + if input.pressed(KeyCode::ArrowUp) { + perspective.fov -= 1.0_f32.to_radians(); + perspective.fov = perspective.fov.max(20.0_f32.to_radians()); + } + if input.pressed(KeyCode::ArrowDown) { + perspective.fov += 1.0_f32.to_radians(); + perspective.fov = perspective.fov.min(160.0_f32.to_radians()); + } +}