Skip to content

Commit

Permalink
Add sub_camera_view, enabling sheared projection (#15537)
Browse files Browse the repository at this point in the history
# 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>
  • Loading branch information
m-edlund authored Oct 1, 2024
1 parent 956d9cc commit c323db0
Show file tree
Hide file tree
Showing 5 changed files with 439 additions and 2 deletions.
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
62 changes: 61 additions & 1 deletion crates/bevy_render/src/camera/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -270,6 +321,7 @@ impl Default for Camera {
hdr: false,
msaa_writeback: true,
clear_color: Default::default(),
sub_camera_view: None,
}
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -890,14 +943,21 @@ 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(),
}
}
}
}

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;
}
}
}

Expand Down
85 changes: 84 additions & 1 deletion crates/bevy_render/src/camera/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand Down
Loading

0 comments on commit c323db0

Please sign in to comment.