diff --git a/Cargo.toml b/Cargo.toml index 8c05cfebf9..61bcc97a63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3430,6 +3430,17 @@ description = "Demonstrates screen space reflections with water ripples" category = "3D Rendering" wasm = false +[[example]] +name = "camera_sub_view" +path = "examples/3d/camera_sub_view.rs" +doc-scrape-examples = true + +[package.metadata.example.camera_sub_view] +name = "Camera sub view" +description = "Demonstrates using different sub view effects on a camera" +category = "3D Rendering" +wasm = true + [[example]] name = "color_grading" path = "examples/3d/color_grading.rs" diff --git a/crates/bevy_render/src/camera/camera.rs b/crates/bevy_render/src/camera/camera.rs index 1cd6238a50..2b69cf9bd4 100644 --- a/crates/bevy_render/src/camera/camera.rs +++ b/crates/bevy_render/src/camera/camera.rs @@ -67,6 +67,54 @@ impl Default for Viewport { } } +/// Settings to define a camera sub view. +/// +/// When [`Camera::sub_camera_view`] is `Some`, only the sub-section of the +/// image defined by `size` and `offset` (relative to the `full_size` of the +/// whole image) is projected to the cameras viewport. +/// +/// Take the example of the following multi-monitor setup: +/// ```css +/// ┌───┬───┐ +/// │ A │ B │ +/// ├───┼───┤ +/// │ C │ D │ +/// └───┴───┘ +/// ``` +/// If each monitor is 1920x1080, the whole image will have a resolution of +/// 3840x2160. For each monitor we can use a single camera with a viewport of +/// the same size as the monitor it corresponds to. To ensure that the image is +/// cohesive, we can use a different sub view on each camera: +/// - Camera A: `full_size` = 3840x2160, `size` = 1920x1080, `offset` = 0,0 +/// - Camera B: `full_size` = 3840x2160, `size` = 1920x1080, `offset` = 1920,0 +/// - Camera C: `full_size` = 3840x2160, `size` = 1920x1080, `offset` = 0,1080 +/// - Camera D: `full_size` = 3840x2160, `size` = 1920x1080, `offset` = +/// 1920,1080 +/// +/// However since only the ratio between the values is important, they could all +/// be divided by 120 and still produce the same image. Camera D would for +/// example have the following values: +/// `full_size` = 32x18, `size` = 16x9, `offset` = 16,9 +#[derive(Debug, Clone, Copy, Reflect, PartialEq)] +pub struct SubCameraView { + /// Size of the entire camera view + pub full_size: UVec2, + /// Offset of the sub camera + pub offset: Vec2, + /// Size of the sub camera + pub size: UVec2, +} + +impl Default for SubCameraView { + fn default() -> Self { + Self { + full_size: UVec2::new(1, 1), + offset: Vec2::new(0., 0.), + size: UVec2::new(1, 1), + } + } +} + /// Information about the current [`RenderTarget`]. #[derive(Default, Debug, Clone)] pub struct RenderTargetInfo { @@ -86,6 +134,7 @@ pub struct ComputedCameraValues { target_info: Option, // size of the `Viewport` old_viewport_size: Option, + old_sub_camera_view: Option, } /// How much energy a `Camera3d` absorbs from incoming light. @@ -256,6 +305,8 @@ pub struct Camera { pub msaa_writeback: bool, /// The clear color operation to perform on the render target. pub clear_color: ClearColorConfig, + /// If set, this camera will be a sub camera of a large view, defined by a [`SubCameraView`]. + pub sub_camera_view: Option, } impl Default for Camera { @@ -270,6 +321,7 @@ impl Default for Camera { hdr: false, msaa_writeback: true, clear_color: Default::default(), + sub_camera_view: None, } } } @@ -843,6 +895,7 @@ pub fn camera_system( || camera.is_added() || camera_projection.is_changed() || camera.computed.old_viewport_size != viewport_size + || camera.computed.old_sub_camera_view != camera.sub_camera_view { let new_computed_target_info = normalized_target.get_render_target_info( &windows, @@ -890,7 +943,10 @@ pub fn camera_system( camera.computed.target_info = new_computed_target_info; if let Some(size) = camera.logical_viewport_size() { camera_projection.update(size.x, size.y); - camera.computed.clip_from_view = camera_projection.get_clip_from_view(); + camera.computed.clip_from_view = match &camera.sub_camera_view { + Some(sub_view) => camera_projection.get_clip_from_view_for_sub(sub_view), + None => camera_projection.get_clip_from_view(), + } } } } @@ -898,6 +954,10 @@ pub fn camera_system( if camera.computed.old_viewport_size != viewport_size { camera.computed.old_viewport_size = viewport_size; } + + if camera.computed.old_sub_camera_view != camera.sub_camera_view { + camera.computed.old_sub_camera_view = camera.sub_camera_view; + } } } diff --git a/crates/bevy_render/src/camera/projection.rs b/crates/bevy_render/src/camera/projection.rs index 91c9b9cc8d..832ba6e56f 100644 --- a/crates/bevy_render/src/camera/projection.rs +++ b/crates/bevy_render/src/camera/projection.rs @@ -6,7 +6,7 @@ use core::{ use crate::{primitives::Frustum, view::VisibilitySystems}; use bevy_app::{App, Plugin, PostStartup, PostUpdate}; use bevy_ecs::prelude::*; -use bevy_math::{ops, AspectRatio, Mat4, Rect, Vec2, Vec3A}; +use bevy_math::{ops, AspectRatio, Mat4, Rect, Vec2, Vec3A, Vec4}; use bevy_reflect::{ std_traits::ReflectDefault, GetTypeRegistration, Reflect, ReflectDeserialize, ReflectSerialize, }; @@ -76,6 +76,7 @@ pub struct CameraUpdateSystem; /// [`Camera`]: crate::camera::Camera pub trait CameraProjection { fn get_clip_from_view(&self) -> Mat4; + fn get_clip_from_view_for_sub(&self, sub_view: &super::SubCameraView) -> Mat4; fn update(&mut self, width: f32, height: f32); fn far(&self) -> f32; fn get_frustum_corners(&self, z_near: f32, z_far: f32) -> [Vec3A; 8]; @@ -124,6 +125,13 @@ impl CameraProjection for Projection { } } + fn get_clip_from_view_for_sub(&self, sub_view: &super::SubCameraView) -> Mat4 { + match self { + Projection::Perspective(projection) => projection.get_clip_from_view_for_sub(sub_view), + Projection::Orthographic(projection) => projection.get_clip_from_view_for_sub(sub_view), + } + } + fn update(&mut self, width: f32, height: f32) { match self { Projection::Perspective(projection) => projection.update(width, height), @@ -189,6 +197,45 @@ impl CameraProjection for PerspectiveProjection { Mat4::perspective_infinite_reverse_rh(self.fov, self.aspect_ratio, self.near) } + fn get_clip_from_view_for_sub(&self, sub_view: &super::SubCameraView) -> Mat4 { + let full_width = sub_view.full_size.x as f32; + let full_height = sub_view.full_size.y as f32; + let sub_width = sub_view.size.x as f32; + let sub_height = sub_view.size.y as f32; + let offset_x = sub_view.offset.x; + // Y-axis increases from top to bottom + let offset_y = full_height - (sub_view.offset.y + sub_height); + + // Original frustum parameters + let top = self.near * ops::tan(0.5 * self.fov); + let bottom = -top; + let right = top * self.aspect_ratio; + let left = -right; + + // Calculate scaling factors + let width = right - left; + let height = top - bottom; + + // Calculate the new frustum parameters + let left_prime = left + (width * offset_x) / full_width; + let right_prime = left + (width * (offset_x + sub_width)) / full_width; + let bottom_prime = bottom + (height * offset_y) / full_height; + let top_prime = bottom + (height * (offset_y + sub_height)) / full_height; + + // Compute the new projection matrix + let x = (2.0 * self.near) / (right_prime - left_prime); + let y = (2.0 * self.near) / (top_prime - bottom_prime); + let a = (right_prime + left_prime) / (right_prime - left_prime); + let b = (top_prime + bottom_prime) / (top_prime - bottom_prime); + + Mat4::from_cols( + Vec4::new(x, 0.0, 0.0, 0.0), + Vec4::new(0.0, y, 0.0, 0.0), + Vec4::new(a, b, 0.0, -1.0), + Vec4::new(0.0, 0.0, self.near, 0.0), + ) + } + fn update(&mut self, width: f32, height: f32) { self.aspect_ratio = AspectRatio::try_new(width, height) .expect("Failed to update PerspectiveProjection: width and height must be positive, non-zero values") @@ -395,6 +442,42 @@ impl CameraProjection for OrthographicProjection { ) } + fn get_clip_from_view_for_sub(&self, sub_view: &super::SubCameraView) -> Mat4 { + let full_width = sub_view.full_size.x as f32; + let full_height = sub_view.full_size.y as f32; + let offset_x = sub_view.offset.x; + let offset_y = sub_view.offset.y; + let sub_width = sub_view.size.x as f32; + let sub_height = sub_view.size.y as f32; + + // Orthographic projection parameters + let top = self.area.max.y; + let bottom = self.area.min.y; + let right = self.area.max.x; + let left = self.area.min.x; + + // Calculate scaling factors + let scale_w = (right - left) / full_width; + let scale_h = (top - bottom) / full_height; + + // Calculate the new orthographic bounds + let left_prime = left + scale_w * offset_x; + let right_prime = left_prime + scale_w * sub_width; + let top_prime = top - scale_h * offset_y; + let bottom_prime = top_prime - scale_h * sub_height; + + Mat4::orthographic_rh( + left_prime, + right_prime, + bottom_prime, + top_prime, + // NOTE: near and far are swapped to invert the depth range from [0,1] to [1,0] + // This is for interoperability with pipelines using infinite reverse perspective projections. + self.far, + self.near, + ) + } + fn update(&mut self, width: f32, height: f32) { let (projection_width, projection_height) = match self.scaling_mode { ScalingMode::WindowSize(pixel_scale) => (width / pixel_scale, height / pixel_scale), diff --git a/examples/3d/camera_sub_view.rs b/examples/3d/camera_sub_view.rs new file mode 100644 index 0000000000..30985dbc09 --- /dev/null +++ b/examples/3d/camera_sub_view.rs @@ -0,0 +1,282 @@ +//! Demonstrates different sub view effects. +//! +//! A sub view is essentially a smaller section of a larger viewport. Some use +//! cases include: +//! - Split one image across multiple cameras, for use in a multimonitor setups +//! - Magnify a section of the image, by rendering a small sub view in another +//! camera +//! - Rapidly change the sub view offset to get a screen shake effect +use bevy::{ + prelude::*, + render::camera::{ScalingMode, SubCameraView, Viewport}, +}; + +const PADDING: u32 = 10; +const SMALL_SIZE: u32 = 100; +const LARGE_SIZE: u32 = 450; + +const WINDOW_HEIGHT: f32 = (LARGE_SIZE + PADDING * 3 + SMALL_SIZE) as f32; +const WINDOW_WIDTH: f32 = (LARGE_SIZE * 2 + PADDING * 3) as f32; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + // Fix window size to avoid issues with viewports on resizing + resize_constraints: WindowResizeConstraints { + min_width: WINDOW_WIDTH, + min_height: WINDOW_HEIGHT, + max_width: WINDOW_WIDTH, + max_height: WINDOW_HEIGHT, + }, + ..default() + }), + ..default() + })) + .add_systems(Startup, setup) + .add_systems(Update, move_camera_view) + .run(); +} + +#[derive(Debug, Component)] +struct MovingCameraMarker; + +/// Set up a simple 3D scene +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let transform = Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y); + + // Plane + commands.spawn(PbrBundle { + mesh: meshes.add(Plane3d::default().mesh().size(5.0, 5.0)), + material: materials.add(Color::srgb(0.3, 0.5, 0.3)), + ..default() + }); + + // Cube + commands.spawn(PbrBundle { + mesh: meshes.add(Cuboid::default()), + material: materials.add(Color::srgb(0.8, 0.7, 0.6)), + transform: Transform::from_xyz(0.0, 0.5, 0.0), + ..default() + }); + + // Light + commands.spawn(( + PointLight { + shadows_enabled: true, + ..default() + }, + Transform::from_xyz(4.0, 8.0, 4.0), + )); + + // Main perspective Camera + commands.spawn(Camera3dBundle { + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(LARGE_SIZE, LARGE_SIZE), + physical_position: UVec2::new(PADDING, PADDING * 2 + SMALL_SIZE), + ..default() + }), + ..default() + }, + transform, + ..default() + }); + + // Perspective camera left half + commands.spawn(Camera3dBundle { + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(SMALL_SIZE, SMALL_SIZE), + physical_position: UVec2::new(PADDING, PADDING), + ..default() + }), + sub_camera_view: Some(SubCameraView { + // Set the sub view camera to the right half of the full image + // + // The values of `full_size` and `size` do not have to be the + // exact values of your physical viewport. The important part is + // the ratio between them. + full_size: UVec2::new(10, 10), + // The `offset` is also relative to the values in `full_size` + // and `size` + offset: Vec2::new(5.0, 0.0), + size: UVec2::new(5, 10), + }), + order: 1, + ..default() + }, + transform, + ..default() + }); + + // Perspective camera moving + commands.spawn(( + Camera3dBundle { + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(SMALL_SIZE, SMALL_SIZE), + physical_position: UVec2::new(PADDING * 2 + SMALL_SIZE, PADDING), + ..default() + }), + sub_camera_view: Some(SubCameraView { + // Set the sub view camera to a fifth of the full view and + // move it in another system + full_size: UVec2::new(500, 500), + offset: Vec2::ZERO, + size: UVec2::new(100, 100), + }), + order: 2, + ..default() + }, + transform, + ..default() + }, + MovingCameraMarker, + )); + + // Perspective camera control + commands.spawn(Camera3dBundle { + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(SMALL_SIZE, SMALL_SIZE), + physical_position: UVec2::new(PADDING * 3 + SMALL_SIZE * 2, PADDING), + ..default() + }), + sub_camera_view: Some(SubCameraView { + // Set the sub view to the full image, to ensure that it matches + // the projection without sub view + full_size: UVec2::new(450, 450), + offset: Vec2::ZERO, + size: UVec2::new(450, 450), + }), + order: 3, + ..default() + }, + transform, + ..default() + }); + + // Main orthographic camera + commands.spawn(Camera3dBundle { + projection: OrthographicProjection { + scaling_mode: ScalingMode::FixedVertical(6.0), + ..OrthographicProjection::default_3d() + } + .into(), + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(LARGE_SIZE, LARGE_SIZE), + physical_position: UVec2::new(PADDING * 2 + LARGE_SIZE, PADDING * 2 + SMALL_SIZE), + ..default() + }), + order: 4, + ..default() + }, + transform, + ..default() + }); + + // Orthographic camera left half + commands.spawn(Camera3dBundle { + projection: OrthographicProjection { + scaling_mode: ScalingMode::FixedVertical(6.0), + ..OrthographicProjection::default_3d() + } + .into(), + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(SMALL_SIZE, SMALL_SIZE), + physical_position: UVec2::new(PADDING * 5 + SMALL_SIZE * 4, PADDING), + ..default() + }), + sub_camera_view: Some(SubCameraView { + // Set the sub view camera to the left half of the full image. + // + // The values of `full_size` and `size` do not have to be the + // exact values of your physical viewport. The important part is + // the ratio between them. + full_size: UVec2::new(2, 2), + offset: Vec2::ZERO, + size: UVec2::new(1, 2), + }), + order: 5, + ..default() + }, + transform, + ..default() + }); + + // Orthographic camera moving + commands.spawn(( + Camera3dBundle { + projection: OrthographicProjection { + scaling_mode: ScalingMode::FixedVertical(6.0), + ..OrthographicProjection::default_3d() + } + .into(), + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(SMALL_SIZE, SMALL_SIZE), + physical_position: UVec2::new(PADDING * 6 + SMALL_SIZE * 5, PADDING), + ..default() + }), + sub_camera_view: Some(SubCameraView { + // Set the sub view camera to a fifth of the full view and + // move it in another system + full_size: UVec2::new(500, 500), + offset: Vec2::ZERO, + size: UVec2::new(100, 100), + }), + order: 6, + ..default() + }, + transform, + ..default() + }, + MovingCameraMarker, + )); + + // Orthographic camera control + commands.spawn(Camera3dBundle { + projection: OrthographicProjection { + scaling_mode: ScalingMode::FixedVertical(6.0), + ..OrthographicProjection::default_3d() + } + .into(), + camera: Camera { + viewport: Option::from(Viewport { + physical_size: UVec2::new(SMALL_SIZE, SMALL_SIZE), + physical_position: UVec2::new(PADDING * 7 + SMALL_SIZE * 6, PADDING), + ..default() + }), + sub_camera_view: Some(SubCameraView { + // Set the sub view to the full image, to ensure that it matches + // the projection without sub view + full_size: UVec2::new(450, 450), + offset: Vec2::ZERO, + size: UVec2::new(450, 450), + }), + order: 7, + ..default() + }, + transform, + ..default() + }); +} + +fn move_camera_view( + mut movable_camera_query: Query<&mut Camera, With>, + time: Res