diff --git a/Cargo.toml b/Cargo.toml index 61d0d5a580a80..abb958f349348 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4387,7 +4387,18 @@ doc-scrape-examples = true [package.metadata.example.camera_sub_view] name = "Camera sub view" -description = "Demonstrates using different sub view effects on a camera" +description = "Demonstrates splitting an image across multiple windows with CameraSubView" +category = "3D Rendering" +wasm = true + +[[example]] +name = "magnifier" +path = "examples/usage/magnifier.rs" +doc-scrape-examples = true + +[package.metadata.example.magnifier] +name = "Magnifier" +description = "Demonstrates magnifying part of the screen with CameraSubView" category = "3D Rendering" wasm = true diff --git a/crates/bevy_camera/src/camera.rs b/crates/bevy_camera/src/camera.rs index f83834aef5cc4..0d79b0e5f09e7 100644 --- a/crates/bevy_camera/src/camera.rs +++ b/crates/bevy_camera/src/camera.rs @@ -1,4 +1,4 @@ -use crate::primitives::Frustum; +use crate::{primitives::Frustum, Projection}; use super::{ visibility::{Visibility, VisibleEntities}, @@ -108,55 +108,137 @@ impl Viewport { #[reflect(Component)] pub struct MainPassResolutionOverride(pub UVec2); -/// Settings to define a camera sub view. +/// Settings to define a camera sub view. Also called a "sheared projection matrix". /// -/// 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. +/// This is not a component type itself, but rather is stored in the `sub_camera_view` field of the [`Camera`] component. +/// The `Camera` component is typically used alongside the `Projection` component, which is used to calculate a view frustum +/// for that camera. If a sub view is set on the camera, it is used to modify the frustum calculation. /// -/// Take the example of the following multi-monitor setup: -/// ```css -/// ┌───┬───┐ -/// │ A │ B │ -/// ├───┼───┤ -/// │ C │ D │ -/// └───┴───┘ +/// The two parameters that a sub view has are `scale` and `offset`. +/// `scale` is a multiplier for the width and height of the view frustum. +/// `offset` is an interpolation parameter for the position of the view frustum, within the bounds of the unmodified "base" frustum, +/// that would be used if the camera didn't have a sub view set. +/// +/// Changing the scale of a sub view will not change the size of the rendered image on screen, as the size of a camera's frustum +/// is independent of the size of its viewport. Rather, this will cause the image to appear to zoom in or out. An important +/// thing to note is that the scale does not zoom "around" the center of the view, but rather the top-left corner. +/// +/// The top-left corner is also the point controlled by the offset parameter. An offset of 0 in a given axis puts the +/// corresponding edge (either top or left) on the same edge of the base frustum. An offset of 1 puts that edge on the +/// opposite edge of the base frustum, which means the rest of the sub view will be *outside* of the base view on that axis. +/// Offset values in between 0 and 1 are linearly interpolated between these two extremes. +/// +/// ## Example +/// +/// Suppose you have a camera with a square viewport, looking at a grid of digits. +/// The camera's projection is configured so the entire grid is visible on screen. +/// +/// ```text +/// ┌─────────┐ +/// │ 0 1 2 3 │ +/// │ 4 5 6 7 │ +/// │ 8 9 A B │ +/// │ C D E F │ +/// └─────────┘ +/// ``` +/// +/// If a `SubCameraView` was set on the camera, with a scale of `0.5`, and an offset of `(0.0, 0.0)`, the width and height of +/// the camera's view frustum would both be halved, resulting in a quarter of the grid being visible. The top-left corner +/// of the frustum would remain in place, so it would be the top-left quarter of the grid that gets projected to the camera's +/// viewport. +/// +/// ```text +/// ┌────────── +/// │ 0 1 +/// │ +/// │ +/// │ 4 5 +/// │ +/// ``` +/// +/// If the sub view had its offset changed to `(0.5, 0.5)`, then the top-left corner of the view frustum would be moved to the +/// middle of the camera's original view. Given that the camera was originally configured so that its view perfectly lined up +/// with the grid, this means the top-left corner of the frustum would appear to be moved to the middle of the grid. +/// +/// ```text +/// │ +/// A B │ +/// │ +/// │ +/// E F │ +/// ──────────┘ /// ``` -/// 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 +/// Now suppose that the sub view's offset was changed to `(1.0, 0.0)`. This would move the top-left corner of the view to the +/// top-right corner of the grid, putting the grid off-screen. +/// +/// ```text +/// ┐ +/// │ +/// │ +/// │ +/// │ +/// │ +/// ``` +/// +/// Lastly, if we wanted to center the view on the grid, we would need to modify the offset so that we can use it to describe the +/// position of the center of the view, rather than the top-left corner. We can do that by subtracting half the scale from it. +/// With a scale of `0.5`, and a desired offset of `(0.5, 0.5)` for the center of the view, the offset to use would be `(0.25, 0.25)`. +/// +/// ```text +/// +/// 5 6 +/// +/// +/// 9 A +/// +/// ``` +/// +/// See the `camera_sub_view` example for more information. +/// +/// ## [`SubViewSourceProjection`] +/// +/// The `SubViewSourceProjection` relationship component can be inserted onto an camera entity that has a sub view set, in +/// order to use the `Projection` component on a different entity as the base for the sub view frustum calculation. All of the +/// parameters of the specified projection will be used, except that the aspect ratio of the calculated frustum will still be +/// the same as the aspect ratio of the camera's viewport. This can be used to drastically simplify the math in some use cases, +/// see the "magnifier" example for an example of this. #[derive(Debug, Clone, Copy, Reflect, PartialEq)] #[reflect(Clone, PartialEq, Default)] pub struct SubCameraView { - /// Size of the entire camera view - pub full_size: UVec2, - /// Offset of the sub camera + /// Scaling factor for the size of the sub view. The height of the sub view will be scale * the height of the base view + pub scale: f32, + /// Percentage offset of the top-left corner of the sub view, from top-left at `0,0` to bottom-right at `1,1` 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), + scale: 1.0, + offset: Vec2::ZERO, } } } +/// Points to an entity with a [`Projection`] component, which will be used to calculate the camera sub view on this entity. +/// An entity with a sub view, but without this component, will use its own projection. +#[derive(Component)] +#[relationship(relationship_target = SubViewsUsingThisProjection)] +pub struct SubViewSourceProjection(pub Entity); + +/// List of all entities with camera sub views, that are calculated from this entity's [`Projection`]. +#[derive(Component)] +#[require(Projection)] +#[relationship_target(relationship = SubViewSourceProjection)] +pub struct SubViewsUsingThisProjection(Vec); + +impl SubViewsUsingThisProjection { + pub fn get_entities(&self) -> impl Iterator { + self.0.iter().copied() + } +} + /// Information about the current [`RenderTarget`]. #[derive(Debug, Clone)] pub struct RenderTargetInfo { @@ -183,7 +265,8 @@ impl Default for RenderTargetInfo { pub struct ComputedCameraValues { pub clip_from_view: Mat4, pub target_info: Option, - // size of the `Viewport` + // These two values aren't actually "computed" like the above two are, + // but are cached here for more granular change detection in the `camera_system`. pub old_viewport_size: Option, pub old_sub_camera_view: Option, } @@ -315,7 +398,7 @@ pub enum ViewportConversionError { #[error("computed coordinate beyond `Camera`'s far plane")] PastFarPlane, /// The Normalized Device Coordinates could not be computed because the `camera_transform`, the - /// `world_position`, or the projection matrix defined by [`Projection`](super::projection::Projection) + /// `world_position`, or the projection matrix defined by [`Projection`] /// contained `NAN` (see [`world_to_ndc`][Camera::world_to_ndc] and [`ndc_to_world`][Camera::ndc_to_world]). #[error("found NaN while computing NDC")] InvalidData, @@ -490,7 +573,7 @@ impl Camera { .map(|t: &RenderTargetInfo| t.scale_factor) } - /// The projection matrix computed using this camera's [`Projection`](super::projection::Projection). + /// The projection matrix computed using this camera's [`Projection`]. #[inline] pub fn clip_from_view(&self) -> Mat4 { self.computed.clip_from_view @@ -696,7 +779,7 @@ impl Camera { /// [`world_to_viewport`](Self::world_to_viewport). /// /// Returns `None` if the `camera_transform`, the `world_position`, or the projection matrix defined by - /// [`Projection`](super::projection::Projection) contain `NAN`. + /// [`Projection`] contain `NAN`. /// /// # Panics /// @@ -723,7 +806,7 @@ impl Camera { /// [`viewport_to_world`](Self::viewport_to_world). /// /// Returns `None` if the `camera_transform`, the `ndc_point`, or the projection matrix defined by - /// [`Projection`](super::projection::Projection) contain `NAN`. + /// [`Projection`] contain `NAN`. /// /// # Panics /// diff --git a/crates/bevy_camera/src/projection.rs b/crates/bevy_camera/src/projection.rs index 7fcbb6930b37e..7fe410b3cf095 100644 --- a/crates/bevy_camera/src/projection.rs +++ b/crates/bevy_camera/src/projection.rs @@ -45,7 +45,11 @@ pub trait CameraProjection { fn get_clip_from_view(&self) -> Mat4; /// Generate the projection matrix for a [`SubCameraView`](super::SubCameraView). - fn get_clip_from_view_for_sub(&self, sub_view: &super::SubCameraView) -> Mat4; + fn get_clip_from_view_for_sub( + &self, + sub_view: &super::SubCameraView, + sub_view_aspect_ratio: Option, + ) -> Mat4; /// When the area this camera renders to changes dimensions, this method will be automatically /// called. Use this to update any projection properties that depend on the aspect ratio or @@ -341,32 +345,41 @@ impl CameraProjection for PerspectiveProjection { matrix } - 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; + fn get_clip_from_view_for_sub( + &self, + sub_view: &super::SubCameraView, + sub_view_aspect_ratio: Option, + ) -> Mat4 { + let scale = sub_view.scale; 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); - - let full_aspect = full_width / full_height; + let offset_y = sub_view.offset.y; // Original frustum parameters + // These are the edges of the near plane rect of the full view in world units let top = self.near * ops::tan(0.5 * self.fov); let bottom = -top; - let right = top * full_aspect; + let right = top * self.aspect_ratio; let left = -right; - // Calculate scaling factors let width = right - left; let height = top - bottom; + // Use the sub view's aspect ratio instead of our own for just the width of the sub view rect. + // This width is what needs to match the width of the camera's viewport for the image to be correct, + // so we want to use the aspect ratio that the camera_system is keeping in sync with the viewport. + // That is the sub view's aspect ratio if it's set, otherwise the projection's aspect ratio. + // The rest of the calculations just use the projection's aspect ratio, because they can't cause + // the image to become distorted. + let sub_width = sub_view_aspect_ratio.map_or(width, |aspect_ratio| height * aspect_ratio); + // 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; + // These are the edges of the near plane rect of the sub view in world units + // In the case that `sub_width == width`, the `right_prime` calculation is equivalent to + // `left + (width * (offset_x + scale)`, i.e. the same form as the `bottom_prime` calculation + let top_prime = top - (height * offset_y); + let bottom_prime = top - (height * (offset_y + scale)); + let right_prime = left + ((width * offset_x) + (sub_width * scale)); + let left_prime = left + (width * offset_x); // Compute the new projection matrix let x = (2.0 * self.near) / (right_prime - left_prime); @@ -644,36 +657,39 @@ 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; + fn get_clip_from_view_for_sub( + &self, + sub_view: &super::SubCameraView, + sub_view_aspect_ratio: Option, + ) -> Mat4 { + let scale = sub_view.scale; 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; - let full_aspect = full_width / full_height; - - // Base the vertical size on self.area and adjust the horizontal size let top = self.area.max.y; let bottom = self.area.min.y; - let ortho_height = top - bottom; - let ortho_width = ortho_height * full_aspect; + let right = self.area.max.x; + let left = self.area.min.x; - // Center the orthographic area horizontally - let center_x = (self.area.max.x + self.area.min.x) / 2.0; - let left = center_x - ortho_width / 2.0; - let right = center_x + ortho_width / 2.0; + let ortho_height = top - bottom; + let ortho_width = right - left; - // Calculate scaling factors - let scale_w = (right - left) / full_width; - let scale_h = (top - bottom) / full_height; + // Use the sub view's aspect ratio instead of our own for just the width of the sub view rect. + // This width is what needs to match the width of the camera's viewport for the image to be correct, + // so we want to use the aspect ratio that the camera_system is keeping in sync with the viewport. + // That is the sub view's aspect ratio if it's set, otherwise the projection's aspect ratio. + // The rest of the calculations just use the projection's aspect ratio, because they can't cause + // the image to become distorted. + let sub_width = + sub_view_aspect_ratio.map_or(ortho_width, |aspect_ratio| ortho_height * aspect_ratio); // 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; + // In the case that `sub_width == ortho_width`, the `right_prime` calculation is equivalent to + // `left + (ortho_width * (offset_x + scale)`, i.e. the same form as the `bottom_prime` calculation + let top_prime = top - (ortho_height * offset_y); + let bottom_prime = top - (ortho_height * (offset_y + scale)); + let right_prime = left + ((ortho_width * offset_x) + (sub_width * scale)); + let left_prime = left + (ortho_width * offset_x); Mat4::orthographic_rh( left_prime, diff --git a/crates/bevy_render/src/camera.rs b/crates/bevy_render/src/camera.rs index 27dac44807dd7..cf0aa990c3e1c 100644 --- a/crates/bevy_render/src/camera.rs +++ b/crates/bevy_render/src/camera.rs @@ -21,7 +21,8 @@ use bevy_camera::{ visibility::{self, RenderLayers, VisibleEntities}, Camera, Camera2d, Camera3d, CameraMainTextureUsages, CameraOutputMode, CameraUpdateSystems, ClearColor, ClearColorConfig, Exposure, ManualTextureViewHandle, MsaaWriteback, - NormalizedRenderTarget, Projection, RenderTarget, RenderTargetInfo, Viewport, + NormalizedRenderTarget, Projection, RenderTarget, RenderTargetInfo, SubViewSourceProjection, + SubViewsUsingThisProjection, Viewport, }; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ @@ -36,11 +37,11 @@ use bevy_ecs::{ reflect::ReflectComponent, resource::Resource, schedule::IntoScheduleConfigs, - system::{Commands, Query, Res, ResMut}, + system::{Commands, In, IntoSystem, Query, Res, ResMut}, world::DeferredWorld, }; use bevy_image::Image; -use bevy_math::{uvec2, vec2, Mat4, URect, UVec2, UVec4, Vec2}; +use bevy_math::{uvec2, vec2, AspectRatio, Mat4, URect, UVec2, UVec4, Vec2}; use bevy_platform::collections::{HashMap, HashSet}; use bevy_reflect::prelude::*; use bevy_transform::components::GlobalTransform; @@ -61,10 +62,16 @@ impl Plugin for CameraPlugin { ExtractResourcePlugin::::default(), ExtractComponentPlugin::::default(), )) - .add_systems(PostStartup, camera_system.in_set(CameraUpdateSystems)) + .add_systems( + PostStartup, + camera_system + .pipe(camera_sub_view_system) + .in_set(CameraUpdateSystems), + ) .add_systems( PostUpdate, camera_system + .pipe(camera_sub_view_system) .in_set(CameraUpdateSystems) .before(AssetEventSystems) .before(visibility::update_frusta), @@ -303,7 +310,7 @@ pub enum MissingRenderTargetInfoError { /// /// [`OrthographicProjection`]: bevy_camera::OrthographicProjection /// [`PerspectiveProjection`]: bevy_camera::PerspectiveProjection -pub fn camera_system( +fn camera_system( mut window_resized_reader: MessageReader, mut window_created_reader: MessageReader, mut window_scale_factor_changed_reader: MessageReader, @@ -312,8 +319,15 @@ pub fn camera_system( windows: Query<(Entity, &Window)>, images: Res>, manual_texture_views: Res, - mut cameras: Query<(&mut Camera, &RenderTarget, &mut Projection)>, -) -> Result<(), BevyError> { + mut cameras: Query<( + Entity, + &mut Camera, + &RenderTarget, + &mut Projection, + Option<&SubViewsUsingThisProjection>, + Has, + )>, +) -> Result, BevyError> { let primary_window = primary_window.iter().next(); let mut changed_window_ids = >::default(); @@ -333,7 +347,15 @@ pub fn camera_system( }) .collect(); - for (mut camera, render_target, mut camera_projection) in &mut cameras { + // Cameras with a `SubViewSourceProjection`, that will have their `clip_from_view` recalculated later in a second loop. + // This is necessary because these cameras depend on the projection of another camera, which may be updated in this + // first loop. Therefore, to avoid potentially using an old incorrect projection value for the `clip_from_view` calculation, + // those calculations must be done in a second loop, after this one. + let mut has_dependent_sub_view = Vec::new(); + + for (entity, mut camera, render_target, mut camera_projection, sub_views, has_source) in + &mut cameras + { let mut viewport_size = camera .viewport .as_ref() @@ -382,11 +404,28 @@ pub fn camera_system( && size.y != 0.0 { camera_projection.update(size.x, size.y); - 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(), + if !has_source { + // This camera is using its own projection + camera.computed.clip_from_view = match &mut camera.sub_camera_view { + Some(sub_view) => { + camera_projection.get_clip_from_view_for_sub(sub_view, None) + } + None => camera_projection.get_clip_from_view(), + } } } + + if let Some(sub_views) = sub_views { + // This camera changed, queue dependent cameras to be updated as well + // (The dependent cameras may have already been iterated over previously in this loop) + has_dependent_sub_view.extend(sub_views.get_entities()); + } + + if has_source { + // This is a dependent camera, queue it to be updated later + // (The camera this one depends on may have its projection updated later in this loop) + has_dependent_sub_view.push(entity); + } } if camera.computed.old_viewport_size != viewport_size { @@ -397,7 +436,40 @@ pub fn camera_system( camera.computed.old_sub_camera_view = camera.sub_camera_view; } } - Ok(()) + + Ok(has_dependent_sub_view) +} + +fn camera_sub_view_system( + dependent_cameras_that_need_updating: In>, + mut cameras: Query<(&mut Camera, &Projection, Option<&SubViewSourceProjection>)>, +) { + // Update dependent cameras in a second loop, so that all the cameras that are depended on have already been updated + // Doing it like this is also necessary for borrow checker reasons + for entity in dependent_cameras_that_need_updating.0 { + if let Ok((camera, _, Some(projection_entity))) = cameras.get(entity) + && let Ok((_, projection, _)) = cameras.get(projection_entity.0) + && let Some(sub_view) = &camera.sub_camera_view + && let Some(size) = camera.logical_viewport_size() + && size.x != 0.0 + && size.y != 0.0 + { + // This expect will usually never be hit, because the same check is performed in the projection update call earlier + let aspect_ratio = AspectRatio::try_new(size.x, size.y) + .expect("Failed to update CameraSubView: viewport size must be a positive, non-zero value") + .ratio(); + + // This method call requires a shared borrow on two different entities, thus the mutable re-query below + let clip_from_view = + projection.get_clip_from_view_for_sub(sub_view, Some(aspect_ratio)); + + // Re-get the camera, but mutably (exclusively) this time, now that the calculation has been done and the + // two simultaneous shared borrows can be dropped + if let Ok((mut camera, _, _)) = cameras.get_mut(entity) { + camera.computed.clip_from_view = clip_from_view; + } + } + } } #[derive(Component, Debug)] @@ -700,3 +772,142 @@ impl Default for MipBias { Self(-1.0) } } + +#[cfg(test)] +mod tests { + use bevy_app::App; + use bevy_asset::AssetPlugin; + use bevy_camera::{ + Camera, CameraProjection, Projection, SubCameraView, SubViewSourceProjection, + }; + use bevy_image::ImagePlugin; + use bevy_utils::default; + use bevy_window::WindowPlugin; + use glam::{Mat4, Vec3A, Vec4}; + + use crate::texture::TexturePlugin; + + const NO_SUB_VIEW: Mat4 = Mat4::ZERO; + const WITH_SUB_VIEW: Mat4 = Mat4::from_diagonal(Vec4::new(1.0, 0.0, 0.0, 0.0)); + const DEPENDENT_SUB_VIEW: Mat4 = Mat4::from_diagonal(Vec4::new(2.0, 0.0, 0.0, 0.0)); + + #[derive(Debug, Clone)] + struct TestProjection; + + impl CameraProjection for TestProjection { + fn get_clip_from_view(&self) -> Mat4 { + NO_SUB_VIEW + } + + fn get_clip_from_view_for_sub( + &self, + _sub_view: &SubCameraView, + sub_view_aspect_ratio: Option, + ) -> Mat4 { + if sub_view_aspect_ratio.is_none() { + WITH_SUB_VIEW + } else { + DEPENDENT_SUB_VIEW + } + } + + fn update(&mut self, _width: f32, _height: f32) {} + + fn far(&self) -> f32 { + unimplemented!() + } + + fn get_frustum_corners(&self, _z_near: f32, _z_far: f32) -> [Vec3A; 8] { + unimplemented!() + } + } + + #[test] + fn camera_without_sub_view() { + let mut app = App::new(); + app.add_plugins(( + WindowPlugin::default(), + AssetPlugin::default(), + ImagePlugin::default(), + TexturePlugin, + super::CameraPlugin, + )); + + let camera = app + .world_mut() + .spawn((Camera::default(), Projection::custom(TestProjection))) + .id(); + + app.update(); + + let camera = app.world().get::(camera).unwrap(); + + assert_eq!(camera.computed.clip_from_view, NO_SUB_VIEW); + } + + #[test] + fn camera_with_sub_view() { + let mut app = App::new(); + app.add_plugins(( + WindowPlugin::default(), + AssetPlugin::default(), + ImagePlugin::default(), + TexturePlugin, + super::CameraPlugin, + )); + + let camera = app + .world_mut() + .spawn(( + Camera { + sub_camera_view: Some(SubCameraView::default()), + ..default() + }, + Projection::custom(TestProjection), + )) + .id(); + + app.update(); + + let camera = app.world().get::(camera).unwrap(); + + assert_eq!(camera.computed.clip_from_view, WITH_SUB_VIEW); + } + + #[test] + fn camera_with_dependent_sub_view() { + let mut app = App::new(); + app.add_plugins(( + WindowPlugin::default(), + AssetPlugin::default(), + ImagePlugin::default(), + TexturePlugin, + super::CameraPlugin, + )); + + let source_camera = app + .world_mut() + .spawn((Camera::default(), Projection::custom(TestProjection))) + .id(); + + let sub_view_camera = app + .world_mut() + .spawn(( + Camera { + sub_camera_view: Some(SubCameraView::default()), + ..default() + }, + Projection::custom(TestProjection), + SubViewSourceProjection(source_camera), + )) + .id(); + + app.update(); + + let sub_view_camera = app.world().get::(sub_view_camera).unwrap(); + assert_eq!(sub_view_camera.computed.clip_from_view, DEPENDENT_SUB_VIEW); + + let source_camera = app.world().get::(source_camera).unwrap(); + assert_eq!(source_camera.computed.clip_from_view, NO_SUB_VIEW); + } +} diff --git a/examples/3d/camera_sub_view.rs b/examples/3d/camera_sub_view.rs index 73cf870707b8c..e8cc9d33ed269 100644 --- a/examples/3d/camera_sub_view.rs +++ b/examples/3d/camera_sub_view.rs @@ -1,35 +1,40 @@ -//! Demonstrates different sub view effects. +//! Demonstrates splitting an image across multiple windows with [`SubCameraView`]. //! -//! 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 +//! A `SubCameraView` is a way of cropping the image that a camera renders to its viewport, using a "sheared projection matrix". +//! Some use cases include: +//! - Splitting one image between multiple render targets, as demonstrated by this example +//! - Magnifying a section of the image, as demonstrated by the `magnifier` example +//! - Creating a screen shake effect by rapidly changing the sub view offset use bevy::{ - camera::{ScalingMode, SubCameraView, Viewport}, + camera::{RenderTarget, SubCameraView}, prelude::*, + window::WindowRef, }; +const WINDOW_RESOLUTION: (u32, u32) = (640, 360); +const WINDOW_POS_OFFSET: IVec2 = IVec2::new(50, 50); + fn main() { App::new() - .add_plugins(DefaultPlugins) + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Top Left".into(), + resolution: WINDOW_RESOLUTION.into(), + position: WindowPosition::new(WINDOW_POS_OFFSET), + ..default() + }), + ..default() + })) .add_systems(Startup, setup) - .add_systems(Update, (move_camera_view, resize_viewports)) .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(( Mesh3d(meshes.add(Plane3d::default().mesh().size(5.0, 5.0))), @@ -52,266 +57,95 @@ fn setup( Transform::from_xyz(4.0, 8.0, 4.0), )); - // Main perspective camera: - // - // The main perspective image to use as a comparison for the sub views. - commands.spawn(( - Camera3d::default(), - Camera::default(), - ExampleViewports::PerspectiveMain, - transform, - )); + let transform = Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y); - // Perspective camera right half: - // - // For this camera, the projection is perspective, and `size` is half the - // width of the `full_size`, while the x value of `offset` is set to half - // the value of the full width, causing the right half of the image to be - // shown. Since the viewport has an aspect ratio of 1x1 and the sub view has - // an aspect ratio of 1x2, the image appears stretched along the horizontal - // axis. + // Camera for the primary window (Top Left) commands.spawn(( Camera3d::default(), - Camera { - sub_camera_view: Some(SubCameraView { - // 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() - }, - ExampleViewports::PerspectiveStretched, transform, - )); - - // Perspective camera moving: - // - // For this camera, the projection is perspective, and the offset is updated - // continuously in 150 units per second in `move_camera_view`. Since the - // `full_size` is 500x500, the image should appear to be moving across the - // full image once every 3.3 seconds. `size` is a fifth of the size of - // `full_size`, so the image will appear zoomed in. - commands.spawn(( - Camera3d::default(), Camera { sub_camera_view: Some(SubCameraView { - full_size: UVec2::new(500, 500), + scale: 0.5, offset: Vec2::ZERO, - size: UVec2::new(100, 100), }), - order: 2, ..default() }, - transform, - ExampleViewports::PerspectiveMoving, - MovingCameraMarker, )); - // Perspective camera different aspect ratio: - // - // For this camera, the projection is perspective, and the aspect ratio of - // the sub view (2x1) is different to the aspect ratio of the full view - // (2x2). The aspect ratio of the sub view matches the aspect ratio of - // the viewport and should show an unstretched image of the top half of the - // full perspective image. + let top_right = commands + .spawn(Window { + title: "Top Right".to_owned(), + resolution: WINDOW_RESOLUTION.into(), + position: WindowPosition::new( + WINDOW_POS_OFFSET + IVec2::new(WINDOW_RESOLUTION.0 as _, 0), + ), + ..default() + }) + .id(); + + // offset is set to `(0.5, 0.0)` instead of `(1.0, 0.0)` because it controls the top-left corner of the view. + // As this camera is the top-right quadrant of the overall image, the top-left corner of this quadrant + // would be halfway along horizontally, and at the very top vertically. Hence the offset being `(0.5, 0.0)`. commands.spawn(( Camera3d::default(), + transform, Camera { sub_camera_view: Some(SubCameraView { - full_size: UVec2::new(800, 800), - offset: Vec2::ZERO, - size: UVec2::new(800, 400), + scale: 0.5, + offset: Vec2::new(0.5, 0.0), }), - order: 3, ..default() }, - ExampleViewports::PerspectiveControl, - transform, + RenderTarget::Window(WindowRef::Entity(top_right)), )); - // Main orthographic camera: - // - // The main orthographic image to use as a comparison for the sub views. - commands.spawn(( - Camera3d::default(), - Projection::from(OrthographicProjection { - scaling_mode: ScalingMode::FixedVertical { - viewport_height: 6.0, - }, - ..OrthographicProjection::default_3d() - }), - Camera { - order: 4, + let bottom_left = commands + .spawn(Window { + title: "Bottom Left".to_owned(), + resolution: WINDOW_RESOLUTION.into(), + position: WindowPosition::new( + WINDOW_POS_OFFSET + IVec2::new(0, WINDOW_RESOLUTION.1 as _), + ), ..default() - }, - ExampleViewports::OrthographicMain, - transform, - )); + }) + .id(); - // Orthographic camera left half: - // - // For this camera, the projection is orthographic, and `size` is half the - // width of the `full_size`, causing the left half of the image to be shown. - // Since the viewport has an aspect ratio of 1x1 and the sub view has an - // aspect ratio of 1x2, the image appears stretched along the horizontal axis. + // Same logic as the top-right, except for the vertical axis instead of the horizontal axis. commands.spawn(( Camera3d::default(), - Projection::from(OrthographicProjection { - scaling_mode: ScalingMode::FixedVertical { - viewport_height: 6.0, - }, - ..OrthographicProjection::default_3d() - }), + transform, Camera { sub_camera_view: Some(SubCameraView { - full_size: UVec2::new(2, 2), - offset: Vec2::ZERO, - size: UVec2::new(1, 2), + scale: 0.5, + offset: Vec2::new(0.0, 0.5), }), - order: 5, ..default() }, - ExampleViewports::OrthographicStretched, - transform, + RenderTarget::Window(WindowRef::Entity(bottom_left)), )); - // Orthographic camera moving: - // - // For this camera, the projection is orthographic, and the offset is - // updated continuously in 150 units per second in `move_camera_view`. Since - // the `full_size` is 500x500, the image should appear to be moving across - // the full image once every 3.3 seconds. `size` is a fifth of the size of - // `full_size`, so the image will appear zoomed in. - commands.spawn(( - Camera3d::default(), - Projection::from(OrthographicProjection { - scaling_mode: ScalingMode::FixedVertical { - viewport_height: 6.0, - }, - ..OrthographicProjection::default_3d() - }), - Camera { - sub_camera_view: Some(SubCameraView { - full_size: UVec2::new(500, 500), - offset: Vec2::ZERO, - size: UVec2::new(100, 100), - }), - order: 6, + let bottom_right = commands + .spawn(Window { + title: "Bottom Right".to_owned(), + resolution: WINDOW_RESOLUTION.into(), + position: WindowPosition::new( + WINDOW_POS_OFFSET + IVec2::new(WINDOW_RESOLUTION.0 as _, WINDOW_RESOLUTION.1 as _), + ), ..default() - }, - transform, - ExampleViewports::OrthographicMoving, - MovingCameraMarker, - )); + }) + .id(); - // Orthographic camera different aspect ratio: - // - // For this camera, the projection is orthographic, and the aspect ratio of - // the sub view (2x1) is different to the aspect ratio of the full view - // (2x2). The aspect ratio of the sub view matches the aspect ratio of - // the viewport and should show an unstretched image of the top half of the - // full orthographic image. + // The top-left corner of the bottom-right quadrant is the very middle. commands.spawn(( Camera3d::default(), - Projection::from(OrthographicProjection { - scaling_mode: ScalingMode::FixedVertical { - viewport_height: 6.0, - }, - ..OrthographicProjection::default_3d() - }), + transform, Camera { sub_camera_view: Some(SubCameraView { - full_size: UVec2::new(200, 200), - offset: Vec2::ZERO, - size: UVec2::new(200, 100), + scale: 0.5, + offset: Vec2::splat(0.5), }), - order: 7, ..default() }, - ExampleViewports::OrthographicControl, - transform, + RenderTarget::Window(WindowRef::Entity(bottom_right)), )); } - -fn move_camera_view( - mut movable_camera_query: Query<&mut Camera, With>, - time: Res