#Moving the camera after zooming to preserve cursor's world coordinates

17 messages · Page 1 of 1 (latest)

stoic stirrup
#

(Images below).

My zoom system essentially looks like this:

#[derive(Debug, Resource, Deref, DerefMut)]
struct Scale(f32);

fn zoom(
    scroll: Res<AccumulatedMouseScroll>,
    mut camera_projection: Single<&mut Projection, With<Camera>>,
    mut scale: ResMut<Scale>,
) {
    **scale -= scroll.delta.y;

    let Projection::Orthographic(ref mut orthographic_projection) = **camera_projection else {
        return;
    };

    orthographic_projection.scale = (**scale / 8.0).exp();
}
#

I have a window, I have a scene (something in the world), and I have a cursor position relative to the window:

#

Now I zoom in 2x, the problem is it looks like this (i.e. the zoom occurs at the origin coordinates 0, 0), which means the coordinates under the cursor has changed, which is bad UX:

#

But what I actually want is to "zoom into the cursor" (this is what most software typically does), which effectively means, "whatever world coordinate was under the cursor, move the camera such that the cursor still appears above that same world coordinate":

#

(I should note this is in 2d, but I can imagine it might generalise to 3d)

stoic stirrup
#

hmm, I'm wrong about the zoom, it's actually zooming relative to the current camera position, not the origin

unreal prawn
#

Probably not a professional approach, but probably just attach a resource component to your cursor and have your zoom in system just query off that. It would in theory at least give you more control over whatever default problem your running into.

#

But if anyone suggests a more professional opinion I'd 100% listen to them

gaunt lark
#

There is a crate called bevy_blendy_camera, that pretty much emulates blender's camera, with zoom to mouse features. you could dig around in their to see the math that calculates how to do this

stoic stirrup
#

right so what I had in my head is that I want to compute the "before" cursor's position and the "after" cursor's position, then adjust the camera's translation by the difference to "reset" the cursor's position in screen space, but I had no idea how to approach that.

I've done a bit of research; fundamentally I just didn't understand how camera.viewport_to_world_2d(global_transform, cursor) works. I had no idea how it could compute the position when it doesn't yet know about the scale (the updated projection isn't one of the inputs)*

when I change the Projection's scale, nothing has actually happened with the camera yet; during PostUpdate there's a bevy_render::camera_system which updates the camera component's computed projection matrix; this means that at the point I updated the scale, the cursor's world position is effectively stale, so the answer to my earlier question* is that it can't, the point computed at that time is in the past, so I won't be able to adjust the camera's translation until after that system runs.

so I think what I can do is:

  1. compute and record the cursor's world position before bevy_render::camera_system where the scale change takes effect
  2. compute the cursor's new world position after bevy_render::camera_system and use this to update the camera's world position

I'm just hoping the GlobalTransform happens after that, otherwise I guess I'll have a one frame delay?

stoic stirrup
#

yep that's done it

#
#[derive(Debug, Resource, Deref, DerefMut)]
struct Scale(f32);

/// Update
fn zoom(
    scroll: Res<AccumulatedMouseScroll>,
    window: Single<&Window, With<PrimaryWindow>>,
    camera: Single<(&Camera, &mut Projection, &GlobalTransform)>,
    mut scale: ResMut<Scale>,
    mut commands: Commands,
) {
    **scale -= scroll.delta.y;

    let (camera, mut orthographic_projection, global_transform) = camera.into_inner();

    let Projection::Orthographic(ref mut orthographic_projection) = *orthographic_projection else {
        return;
    };

    orthographic_projection.scale = (**scale / 8.0).exp();

    if scroll.delta.y.abs() < f32::EPSILON {
        return;
    }

    let Some(cursor) = window.cursor_position() else {
        return;
    };

    let Ok(world_cursor) = camera.viewport_to_world_2d(global_transform, cursor) else {
        return;
    };

    commands.insert_resource(OldCursor(world_cursor));
}

#[derive(Resource, Deref, DerefMut)]
struct OldCursor(Vec2);

/// PostUpdate, after CameraUpdateSystem, before TransformPropagate
fn move_camera_after_zoom(
    mut commands: Commands,
    old_cursor: Option<Res<OldCursor>>,
    window: Single<&Window, With<PrimaryWindow>>,
    camera: Single<(&Camera, &mut Transform, &GlobalTransform)>,
) {
    let Some(old_cursor) = old_cursor else {
        return;
    };

    let (camera, mut transform, global_transform) = camera.into_inner();

    let Some(cursor) = window.cursor_position() else {
        return;
    };

    let Ok(new_cursor) = camera.viewport_to_world_2d(global_transform, cursor) else {
        return;
    };

    let offset = **old_cursor - new_cursor;

    transform.translation += offset.extend(0.);

    commands.remove_resource::<OldCursor>();
}
supple gyro
# stoic stirrup ```rs #[derive(Debug, Resource, Deref, DerefMut)] struct Scale(f32); /// Update...

Nice! this will be useful for when i'm going to try implementing the same in my current pan/zoom cam.

One thing if you don't mind: I would consider making OldCursor a Message and using MessageWriter/MessageReader instead. Unless move_camera_after_zoom can be written so it works during Update, then i'd either inline it or use a Event/Observer.

Plus i'd use a picking Observer with window picking from the get-go - that allows you to benefit from picking observers bubbling nature, allowing things to block the scroll from propagating

stoic stirrup
# supple gyro Nice! this will be useful for when i'm going to try implementing the same in my ...

One thing if you don't mind: I would consider making OldCursor a Message and using MessageWriter/MessageReader instead. Unless move_camera_after_zoom can be written so it works during Update, then i'd either inline it or use a Event/Observer.

I haven't kept up to date with a lot of the newer bevy features tbh; I've only recently started using observers and picking, and I never even knew Message was a thing. Can you show me what you mean? I've just read it and I see it's what used to be called an Event.

I did think about using a Message, but it felt somewhat like overkill, since I'll only ever produce it in one place, and consume it in one other place. I suppose it does fit the bill but it's just a way of passing data into the future.

Particularly interested in how you would inline it - one way I thought of would be to duplicate the code in camera_update_system and simulate the change to the camera. Alternatively I could insert another camera_update_system into the schedule, but either way I need to know the state before and after the "camera" has been updated. Seems to be a blindspot with the existing API; it certainly wasn't intuitive to me that changing the Projection would update the Camera towards the end of the frame, and the Camera was doing the position calculation.

How would you use an Event/Observer?

Plus i'd use a picking Observer with window picking from the get-go - that allows you to benefit from picking observers bubbling nature, allowing things to block the scroll from propagating

I had wondered this for pointer events on empty space (e.g. for deselecting entities) but have no idea how to add an observer to a window - also, I didn't think mouse scroll is one of the events, or are you suggesting creating a new picking backend?

I have personally struggled with the discoverability of bevy features; the best write-ups are always in the change logs (or the PRs) but the change logs do tend to be very, very long.