Add sub_camera_view, enabling sheared projection (#15537)

# Objective

- This PR fixes #12488

## Solution

- This PR adds a new property to `Camera` that emulates the
functionality of the
[setViewOffset()](https://threejs.org/docs/#api/en/cameras/PerspectiveCamera.setViewOffset)
API in three.js.
- When set, the perspective and orthographic projections will restrict
the visible area of the camera to a part of the view frustum defined by
`offset` and `size`.

## Testing

- In the new `camera_sub_view` example, a fixed, moving and control sub
view is created for both perspective and orthographic projection
- Run the example with `cargo run --example camera_sub_view`
- The code can be tested by adding a `SubCameraView` to a camera

---

## Showcase


![image](https://github.com/user-attachments/assets/75ac45fc-d75d-4664-8ef6-ff7865297c25)

- Left Half: Perspective Projection
- Right Half: Orthographic Projection
- Small boxes in order:
  - Sub view of the left half of the full image
- Sub view moving from the top left to the bottom right of the full
image
  - Sub view of the full image (acting as a control)
- Large box: No sub view

<details>
  <summary>Shortened camera setup of `camera_sub_view` example</summary>

```rust
    // Main perspective Camera
    commands.spawn(Camera3dBundle {
        transform,
        ..default()
    });

    // Perspective camera left half
    commands.spawn(Camera3dBundle {
        camera: Camera {
            sub_camera_view: Some(SubCameraView {
                // Set the sub view camera to the left half of the full image
                full_size: uvec2(500, 500),
                offset: ivec2(0, 0),
                size: uvec2(250, 500),
            }),
            order: 1,
            ..default()
        },
        transform,
        ..default()
    });

    // Perspective camera moving
    commands.spawn((
        Camera3dBundle {
            camera: Camera {
                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(500, 500),
                    offset: ivec2(0, 0),
                    size: uvec2(100, 100),
                }),
                order: 2,
                ..default()
            },
            transform,
            ..default()
        },
        MovingCameraMarker,
    ));

    // Perspective camera control
    commands.spawn(Camera3dBundle {
        camera: Camera {
            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(450, 450),
                offset: ivec2(0, 0),
                size: uvec2(450, 450),
            }),
            order: 3,
            ..default()
        },
        transform,
        ..default()
    });

    // Main orthographic camera
    commands.spawn(Camera3dBundle {
        projection: OrthographicProjection {
          ...
        }
        .into(),
        camera: Camera {
            order: 4,
            ..default()
        },
        transform,
        ..default()
    });

    // Orthographic camera left half
    commands.spawn(Camera3dBundle {
        projection: OrthographicProjection {
          ...
        }
        .into(),
        camera: Camera {
            sub_camera_view: Some(SubCameraView {
                // Set the sub view camera to the left half of the full image
                full_size: uvec2(500, 500),
                offset: ivec2(0, 0),
                size: uvec2(250, 500),
            }),
            order: 5,
            ..default()
        },
        transform,
        ..default()
    });

    // Orthographic camera moving
    commands.spawn((
        Camera3dBundle {
            projection: OrthographicProjection {
              ...
            }
            .into(),
            camera: Camera {
                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(500, 500),
                    offset: ivec2(0, 0),
                    size: uvec2(100, 100),
                }),
                order: 6,
                ..default()
            },
            transform,
            ..default()
        },
        MovingCameraMarker,
    ));

    // Orthographic camera control
    commands.spawn(Camera3dBundle {
        projection: OrthographicProjection {
          ...
        }
        .into(),
        camera: Camera {
            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(450, 450),
                offset: ivec2(0, 0),
                size: uvec2(450, 450),
            }),
            order: 7,
            ..default()
        },
        transform,
        ..default()
    });
```

</details>
This commit is contained in:
m-edlund 2024-10-01 16:11:24 +02:00 committed by GitHub
parent 956d9ccbb1
commit c323db02e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 439 additions and 2 deletions

View file

@ -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"

View file

@ -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<RenderTargetInfo>,
// size of the `Viewport`
old_viewport_size: Option<UVec2>,
old_sub_camera_view: Option<SubCameraView>,
}
/// 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<SubCameraView>,
}
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<T: CameraProjection + Component>(
|| 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<T: CameraProjection + Component>(
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<T: CameraProjection + Component>(
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;
}
}
}

View file

@ -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),

View file

@ -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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
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<MovingCameraMarker>>,
time: Res<Time>,
) {
for mut camera in movable_camera_query.iter_mut() {
if let Some(sub_view) = &mut camera.sub_camera_view {
sub_view.offset.x = (time.elapsed_seconds() * 150.) % 450.0 - 50.0;
sub_view.offset.y = sub_view.offset.x;
}
}
}

View file

@ -139,6 +139,7 @@ Example | Description
[Auto Exposure](../examples/3d/auto_exposure.rs) | A scene showcasing auto exposure
[Blend Modes](../examples/3d/blend_modes.rs) | Showcases different blend modes
[Built-in postprocessing](../examples/3d/post_processing.rs) | Demonstrates the built-in postprocessing features
[Camera sub view](../examples/3d/camera_sub_view.rs) | Demonstrates using different sub view effects on a camera
[Clearcoat](../examples/3d/clearcoat.rs) | Demonstrates the clearcoat PBR feature
[Color grading](../examples/3d/color_grading.rs) | Demonstrates color grading
[Deferred Rendering](../examples/3d/deferred_rendering.rs) | Renders meshes with both forward and deferred pipelines