Respect viewport position in coordinate conversion functions (#17633)

# Objective

- In `Camera::viewport_to_world_2d`, `Camera::viewport_to_world`,
`Camera::world_to_viewport` and `Camera::world_to_viewport_with_depth`,
the results were incorrect when the `Camera::viewport` field was
configured with a viewport position that was non-zero. This PR attempts
to correct that.
- Fixes #16200 

## Solution

- This PR now takes the viewport position into account in the functions
mentioned above.
- Extended `2d_viewport_to_world` example to test the functions with a
dynamic viewport position and size, camera positions and zoom levels. It
is probably worth discussing whether to change the example, add a new
one or just completely skip touching the examples.

## Testing

Used the modified example to test the functions with dynamic camera
transform as well as dynamic viewport size and position.
This commit is contained in:
Johannes Ringler 2025-03-10 22:19:26 +01:00 committed by GitHub
parent b574599444
commit 683b08fec9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 184 additions and 34 deletions

View File

@ -605,7 +605,7 @@ doc-scrape-examples = true
[package.metadata.example.2d_viewport_to_world] [package.metadata.example.2d_viewport_to_world]
name = "2D Viewport To World" name = "2D Viewport To World"
description = "Demonstrates how to use the `Camera::viewport_to_world_2d` method" description = "Demonstrates how to use the `Camera::viewport_to_world_2d` method with a dynamic viewport and camera."
category = "2D Rendering" category = "2D Rendering"
wasm = true wasm = true

View File

@ -472,10 +472,10 @@ impl Camera {
camera_transform: &GlobalTransform, camera_transform: &GlobalTransform,
world_position: Vec3, world_position: Vec3,
) -> Result<Vec2, ViewportConversionError> { ) -> Result<Vec2, ViewportConversionError> {
let target_size = self let target_rect = self
.logical_viewport_size() .logical_viewport_rect()
.ok_or(ViewportConversionError::NoViewportSize)?; .ok_or(ViewportConversionError::NoViewportSize)?;
let ndc_space_coords = self let mut ndc_space_coords = self
.world_to_ndc(camera_transform, world_position) .world_to_ndc(camera_transform, world_position)
.ok_or(ViewportConversionError::InvalidData)?; .ok_or(ViewportConversionError::InvalidData)?;
// NDC z-values outside of 0 < z < 1 are outside the (implicit) camera frustum and are thus not in viewport-space // NDC z-values outside of 0 < z < 1 are outside the (implicit) camera frustum and are thus not in viewport-space
@ -486,10 +486,12 @@ impl Camera {
return Err(ViewportConversionError::PastFarPlane); return Err(ViewportConversionError::PastFarPlane);
} }
// Once in NDC space, we can discard the z element and rescale x/y to fit the screen
let mut viewport_position = (ndc_space_coords.truncate() + Vec2::ONE) / 2.0 * target_size;
// Flip the Y co-ordinate origin from the bottom to the top. // Flip the Y co-ordinate origin from the bottom to the top.
viewport_position.y = target_size.y - viewport_position.y; ndc_space_coords.y = -ndc_space_coords.y;
// Once in NDC space, we can discard the z element and map x/y to the viewport rect
let viewport_position =
(ndc_space_coords.truncate() + Vec2::ONE) / 2.0 * target_rect.size() + target_rect.min;
Ok(viewport_position) Ok(viewport_position)
} }
@ -508,10 +510,10 @@ impl Camera {
camera_transform: &GlobalTransform, camera_transform: &GlobalTransform,
world_position: Vec3, world_position: Vec3,
) -> Result<Vec3, ViewportConversionError> { ) -> Result<Vec3, ViewportConversionError> {
let target_size = self let target_rect = self
.logical_viewport_size() .logical_viewport_rect()
.ok_or(ViewportConversionError::NoViewportSize)?; .ok_or(ViewportConversionError::NoViewportSize)?;
let ndc_space_coords = self let mut ndc_space_coords = self
.world_to_ndc(camera_transform, world_position) .world_to_ndc(camera_transform, world_position)
.ok_or(ViewportConversionError::InvalidData)?; .ok_or(ViewportConversionError::InvalidData)?;
// NDC z-values outside of 0 < z < 1 are outside the (implicit) camera frustum and are thus not in viewport-space // NDC z-values outside of 0 < z < 1 are outside the (implicit) camera frustum and are thus not in viewport-space
@ -525,10 +527,12 @@ impl Camera {
// Stretching ndc depth to value via near plane and negating result to be in positive room again. // Stretching ndc depth to value via near plane and negating result to be in positive room again.
let depth = -self.depth_ndc_to_view_z(ndc_space_coords.z); let depth = -self.depth_ndc_to_view_z(ndc_space_coords.z);
// Once in NDC space, we can discard the z element and rescale x/y to fit the screen
let mut viewport_position = (ndc_space_coords.truncate() + Vec2::ONE) / 2.0 * target_size;
// Flip the Y co-ordinate origin from the bottom to the top. // Flip the Y co-ordinate origin from the bottom to the top.
viewport_position.y = target_size.y - viewport_position.y; ndc_space_coords.y = -ndc_space_coords.y;
// Once in NDC space, we can discard the z element and map x/y to the viewport rect
let viewport_position =
(ndc_space_coords.truncate() + Vec2::ONE) / 2.0 * target_rect.size() + target_rect.min;
Ok(viewport_position.extend(depth)) Ok(viewport_position.extend(depth))
} }
@ -548,15 +552,16 @@ impl Camera {
pub fn viewport_to_world( pub fn viewport_to_world(
&self, &self,
camera_transform: &GlobalTransform, camera_transform: &GlobalTransform,
mut viewport_position: Vec2, viewport_position: Vec2,
) -> Result<Ray3d, ViewportConversionError> { ) -> Result<Ray3d, ViewportConversionError> {
let target_size = self let target_rect = self
.logical_viewport_size() .logical_viewport_rect()
.ok_or(ViewportConversionError::NoViewportSize)?; .ok_or(ViewportConversionError::NoViewportSize)?;
let mut rect_relative = (viewport_position - target_rect.min) / target_rect.size();
// Flip the Y co-ordinate origin from the top to the bottom. // Flip the Y co-ordinate origin from the top to the bottom.
viewport_position.y = target_size.y - viewport_position.y; rect_relative.y = 1.0 - rect_relative.y;
let ndc = viewport_position * 2. / target_size - Vec2::ONE;
let ndc = rect_relative * 2. - Vec2::ONE;
let ndc_to_world = let ndc_to_world =
camera_transform.compute_matrix() * self.computed.clip_from_view.inverse(); camera_transform.compute_matrix() * self.computed.clip_from_view.inverse();
let world_near_plane = ndc_to_world.project_point3(ndc.extend(1.)); let world_near_plane = ndc_to_world.project_point3(ndc.extend(1.));
@ -586,14 +591,17 @@ impl Camera {
pub fn viewport_to_world_2d( pub fn viewport_to_world_2d(
&self, &self,
camera_transform: &GlobalTransform, camera_transform: &GlobalTransform,
mut viewport_position: Vec2, viewport_position: Vec2,
) -> Result<Vec2, ViewportConversionError> { ) -> Result<Vec2, ViewportConversionError> {
let target_size = self let target_rect = self
.logical_viewport_size() .logical_viewport_rect()
.ok_or(ViewportConversionError::NoViewportSize)?; .ok_or(ViewportConversionError::NoViewportSize)?;
let mut rect_relative = (viewport_position - target_rect.min) / target_rect.size();
// Flip the Y co-ordinate origin from the top to the bottom. // Flip the Y co-ordinate origin from the top to the bottom.
viewport_position.y = target_size.y - viewport_position.y; rect_relative.y = 1.0 - rect_relative.y;
let ndc = viewport_position * 2. / target_size - Vec2::ONE;
let ndc = rect_relative * 2. - Vec2::ONE;
let world_near_plane = self let world_near_plane = self
.ndc_to_world(camera_transform, ndc.extend(1.)) .ndc_to_world(camera_transform, ndc.extend(1.))

View File

@ -1,12 +1,24 @@
//! This example demonstrates how to use the `Camera::viewport_to_world_2d` method. //! This example demonstrates how to use the `Camera::viewport_to_world_2d` method with a dynamic viewport and camera.
use bevy::{color::palettes::basic::WHITE, prelude::*}; use bevy::{
color::palettes::{
basic::WHITE,
css::{GREEN, RED},
},
math::ops::powf,
prelude::*,
render::camera::Viewport,
};
fn main() { fn main() {
App::new() App::new()
.add_plugins(DefaultPlugins) .add_plugins(DefaultPlugins)
.add_systems(Startup, setup) .add_systems(Startup, setup)
.add_systems(Update, draw_cursor) .add_systems(FixedUpdate, controls)
.add_systems(
PostUpdate,
draw_cursor.after(TransformSystem::TransformPropagate),
)
.run(); .run();
} }
@ -15,30 +27,147 @@ fn draw_cursor(
window: Query<&Window>, window: Query<&Window>,
mut gizmos: Gizmos, mut gizmos: Gizmos,
) { ) {
let (camera, camera_transform) = *camera_query;
let Ok(window) = window.single() else { let Ok(window) = window.single() else {
return; return;
}; };
let (camera, camera_transform) = *camera_query;
let Some(cursor_position) = window.cursor_position() else { let Some(cursor_position) = window.cursor_position() else {
return; return;
}; };
// Calculate a world position based on the cursor's position. // Calculate a world position based on the cursor's position.
let Ok(point) = camera.viewport_to_world_2d(camera_transform, cursor_position) else { let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_position) else {
return; return;
}; };
gizmos.circle_2d(point, 10., WHITE); // To test Camera::world_to_viewport, convert result back to viewport space and then back to world space.
let Ok(viewport_check) = camera.world_to_viewport(camera_transform, world_pos.extend(0.0))
else {
return;
};
let Ok(world_check) = camera.viewport_to_world_2d(camera_transform, viewport_check.xy()) else {
return;
};
gizmos.circle_2d(world_pos, 10., WHITE);
// Should be the same as world_pos
gizmos.circle_2d(world_check, 8., RED);
} }
fn setup(mut commands: Commands) { fn controls(
commands.spawn(Camera2d); mut camera_query: Query<(&mut Camera, &mut Transform, &mut Projection)>,
window: Query<&Window>,
input: Res<ButtonInput<KeyCode>>,
time: Res<Time<Fixed>>,
) {
let Ok(window) = window.single() else {
return;
};
let Ok((mut camera, mut transform, mut projection)) = camera_query.single_mut() else {
return;
};
let fspeed = 600.0 * time.delta_secs();
let uspeed = fspeed as u32;
let window_size = window.resolution.physical_size();
// Camera movement controls
if input.pressed(KeyCode::ArrowUp) {
transform.translation.y += fspeed;
}
if input.pressed(KeyCode::ArrowDown) {
transform.translation.y -= fspeed;
}
if input.pressed(KeyCode::ArrowLeft) {
transform.translation.x -= fspeed;
}
if input.pressed(KeyCode::ArrowRight) {
transform.translation.x += fspeed;
}
// Camera zoom controls
if let Projection::Orthographic(projection2d) = &mut *projection {
if input.pressed(KeyCode::Comma) {
projection2d.scale *= powf(4.0f32, time.delta_secs());
}
if input.pressed(KeyCode::Period) {
projection2d.scale *= powf(0.25f32, time.delta_secs());
}
}
if let Some(viewport) = camera.viewport.as_mut() {
// Viewport movement controls
if input.pressed(KeyCode::KeyW) {
viewport.physical_position.y = viewport.physical_position.y.saturating_sub(uspeed);
}
if input.pressed(KeyCode::KeyS) {
viewport.physical_position.y += uspeed;
}
if input.pressed(KeyCode::KeyA) {
viewport.physical_position.x = viewport.physical_position.x.saturating_sub(uspeed);
}
if input.pressed(KeyCode::KeyD) {
viewport.physical_position.x += uspeed;
}
// Bound viewport position so it doesn't go off-screen
viewport.physical_position = viewport
.physical_position
.min(window_size - viewport.physical_size);
// Viewport size controls
if input.pressed(KeyCode::KeyI) {
viewport.physical_size.y = viewport.physical_size.y.saturating_sub(uspeed);
}
if input.pressed(KeyCode::KeyK) {
viewport.physical_size.y += uspeed;
}
if input.pressed(KeyCode::KeyJ) {
viewport.physical_size.x = viewport.physical_size.x.saturating_sub(uspeed);
}
if input.pressed(KeyCode::KeyL) {
viewport.physical_size.x += uspeed;
}
// Bound viewport size so it doesn't go off-screen
viewport.physical_size = viewport
.physical_size
.min(window_size - viewport.physical_position)
.max(UVec2::new(20, 20));
}
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Single<&Window>,
) {
let window_size = window.resolution.physical_size().as_vec2();
// Initialize centered, non-window-filling viewport
commands.spawn((
Camera2d,
Camera {
viewport: Some(Viewport {
physical_position: (window_size * 0.125).as_uvec2(),
physical_size: (window_size * 0.75).as_uvec2(),
..default()
}),
..default()
},
));
// Create a minimal UI explaining how to interact with the example // Create a minimal UI explaining how to interact with the example
commands.spawn(( commands.spawn((
Text::new("Move the mouse to see the circle follow your cursor."), Text::new(
"Move the mouse to see the circle follow your cursor.\n\
Use the arrow keys to move the camera.\n\
Use the comma and period keys to zoom in and out.\n\
Use the WASD keys to move the viewport.\n\
Use the IJKL keys to resize the viewport.",
),
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
top: Val::Px(12.0), top: Val::Px(12.0),
@ -46,4 +175,17 @@ fn setup(mut commands: Commands) {
..default() ..default()
}, },
)); ));
// Add mesh to make camera movement visible
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(40.0, 20.0))),
MeshMaterial2d(materials.add(Color::from(GREEN))),
));
// Add background to visualize viewport bounds
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(50000.0, 50000.0))),
MeshMaterial2d(materials.add(Color::linear_rgb(0.01, 0.01, 0.01))),
Transform::from_translation(Vec3::new(0.0, 0.0, -200.0)),
));
} }

View File

@ -105,7 +105,7 @@ Example | Description
[2D Bloom](../examples/2d/bloom_2d.rs) | Illustrates bloom post-processing in 2d [2D Bloom](../examples/2d/bloom_2d.rs) | Illustrates bloom post-processing in 2d
[2D Rotation](../examples/2d/rotation.rs) | Demonstrates rotating entities in 2D with quaternions [2D Rotation](../examples/2d/rotation.rs) | Demonstrates rotating entities in 2D with quaternions
[2D Shapes](../examples/2d/2d_shapes.rs) | Renders simple 2D primitive shapes like circles and polygons [2D Shapes](../examples/2d/2d_shapes.rs) | Renders simple 2D primitive shapes like circles and polygons
[2D Viewport To World](../examples/2d/2d_viewport_to_world.rs) | Demonstrates how to use the `Camera::viewport_to_world_2d` method [2D Viewport To World](../examples/2d/2d_viewport_to_world.rs) | Demonstrates how to use the `Camera::viewport_to_world_2d` method with a dynamic viewport and camera.
[2D Wireframe](../examples/2d/wireframe_2d.rs) | Showcases wireframes for 2d meshes [2D Wireframe](../examples/2d/wireframe_2d.rs) | Showcases wireframes for 2d meshes
[Arc 2D Meshes](../examples/2d/mesh2d_arcs.rs) | Demonstrates UV-mapping of the circular segment and sector primitives [Arc 2D Meshes](../examples/2d/mesh2d_arcs.rs) | Demonstrates UV-mapping of the circular segment and sector primitives
[CPU Drawing](../examples/2d/cpu_draw.rs) | Manually read/write the pixels of a texture [CPU Drawing](../examples/2d/cpu_draw.rs) | Manually read/write the pixels of a texture