Camera2D zoom position towards the mouse

:information_source: Attention Topic was automatically imported from the old Question2Answer platform.
:bust_in_silhouette: Asked By DodoIta

Hi folks!
I want the Camera2D to zoom towards the mouse position and although I set the Anchor Mode to Drag Center, when I zoom is the camera zoomes towards the top left corner of the screen. How can I fix this?

:bust_in_silhouette: Reply From: Bartosz

First using only zoom property to zoom at point will not be sufficient(read below). Second it looks like either some script modifies cameras anchor_mode or your camera isn’t active-one after all. Try creating simple scene and make zooming at center work, then after you resolve that issue read next part how zoom at specific point.

If we would do manually zoom-at-point transformation we could split it into 3 parts:

  1. translating to zooming point - to set zoom center
  2. zooming by desire amount - actual zooming
  3. applying reversed translation - returning camera to its appropriate position

Because Zooming part is doing for us by Godot as a first transformation we need to combine 1st and 3rd step together and do camera positioning ourselves and it will involves math.


To tackle this problem first we must get our facts strait.

  • Looking for: new camera position
  • Variable: zoom
  • Invariant: screen size, mouse global position, mouse position on screen

Mouse positions looks like most appropriate points of building an equation to compute new position of camera.

Lets start with defining vars:

  • M global mouse position
  • m mouse position on screen
  • C global camera position
  • s screen size
  • z zoom level

now lets build equation:

  1. mouse global position depends on position on screen
	M ~ m
  1. we set camera anchor to drag center so its reference point is in center of the screen and mouse position on screen is relative to upper left corner which is half screen size to upper left direction
	M ~ (m - 0.5 * s)
  1. screen can be zoomed so we need to apply that to screen size and mouse position on screen
	M ~ (m - 0.5 * s)*z
  1. everything is relative to camera position
	M ~ (m - 0.5 * s)*z + C
  1. that is everything need to compute global mouse position
	M = (m - 0.5 * s)*z + C
  1. global mouse positions before and after zooming will be
	M0 = (m - 0.5 * s)*z0 + C0
	M1 = (m - 0.5 * s)*z1 + C1
  1. We know that global mouse position and mouse position on screen before and after zooming will be same
	M0 = M1	
  1. replacing M1 and M2 with equations
	(m - 0.5 * s)*z0 + C0 = (m - 0.5 * s)*z1 + C1
  1. After transforming(skipping some steps) we will get this:
	C1 = C0 + (-0.5*s + m)*(z0 - z1)

That is new camera position that we were searching for.

And here some code to attach to camera script:

	var zoom_step = 1.1

	func _input(event):
		if event is InputEventMouse:
			if event.is_pressed() and not event.is_echo():
				var mouse_position = event.position
				if event.button_index == BUTTON_WHEEL_UP:
				else : if event.button_index == BUTTON_WHEEL_DOWN:
	func zoom_at_point(zoom_change, point):
		var c0 = global_position # camera position
		var v0 = get_viewport().size # vieport size
		var c1 # next camera position
		var z0 = zoom # current zoom value
		var z1 = z0 * zoom_change # next zoom value

		c1 = c0 + (-0.5*v0 + point)*(z0 - z1)
		zoom = z1
		global_position = c1

best practice is to write new code by yourself - you will remember it better that way - so try avoid copy-pasting

Thank you so much for replying.
I have a few doubts about what you typed:

  1. I don’t undestand the difference between M and m.
  2. Why M0 and M1 would be the same after zooming?
  3. I implemented your algorithm and zooming works, but camera positioning behaves still the same as before (it zooms towards the upper left corner of the screen.

I’ll try investigating in the meantime

DodoIta | 2018-03-23 15:45

  1. Imaging you are looking at google maps, at some house. Its geographic location is M and it m is its location on your screen. You can zoom and pan view m will change but M does not.

  2. We decided that. We want to have zoom-at-point meaning that we point on something, we zoom ,and we still pointing at the same thing e.g you point on sprite of small insect and use mouse wheel, you expected that when zoom is finished you will be pointing on enlarge sprite of insect

  3. I do not have enough information to help you with that. Maybe if you shared minimal scene that causes problem I could find whats wrong

Bartosz | 2018-03-23 18:34

Ok, now I understand it.
After some thought I discovered that the problem was setting strict limits on the camera (Limit property), so setting very large limits fixed it. That’s probably what messed up my code, too.
Thank you a lot, very helpful.

DodoIta | 2018-03-23 20:54

I just spent 3 days trying to fix my problem and I thought it would help others to leave a comment here.

I used your script to implement zooming in my top-down 2D game. Your script seemed to work as intended, initially. However, I soon discovered that all of my A Star pathing logic in my TileMap broke after I zoomed.

The game seemed to register clicks in a different position than where my tiles actually were. But it was only off after I zoomed in or out, even if I returned (or tried to) to the default zoom level and position. Your script even helped me write my own simple camera panning script, but that too altered how the game was registering clicks.

Anyway the fix was to not use the Camera2D.global_position or Camera2D.position. I edited your zoom script and my simple panning script to use the Camera2D.offset instead. The panning and zooming now works as before, and the game is registering mouse clicks where it should.

Django | 2019-02-15 17:28

For anyone in the future who needs to zoom in on something without using a camera, this is what I’m using and it works well.

var zoom_factor = 1.1

func _input(event):
	if event is InputEventMouse:
		if event.is_pressed() and not event.is_echo():
			var mouse_position = event.position
			if event.button_index == BUTTON_WHEEL_UP:
				_zoom_at_point(zoom_factor, mouse_position)
			elif event.button_index == BUTTON_WHEEL_DOWN:
				_zoom_at_point(1 / zoom_factor, mouse_position)

func _zoom_at_point(zoom_change, mouse_position):
	scale = scale * zoom_change
	var delta_x = (mouse_position.x - global_position.x) * (zoom_change - 1)
	var delta_y = (mouse_position.y - global_position.y) * (zoom_change - 1)
	global_position.x = global_position.x - delta_x
	global_position.y = global_position.y - delta_y

Austin | 2020-05-28 04:18

thanks! epic answer :slight_smile:

rakkarage | 2020-06-05 06:07

In case anyone lands here looking for how to scale a tilemap, using the mouse wheel, centred on the mouse pointer I have a piece of code as such:

			if event.pressed:
				if (event.button_index == BUTTON_WHEEL_UP) or (event.button_index == BUTTON_WHEEL_DOWN):
					var old_scale := self.scale.x
					if (event.button_index == BUTTON_WHEEL_UP): self.scale += Vector2(1, 1) 
					elif (event.button_index == BUTTON_WHEEL_DOWN): self.scale -= Vector2(1, 1) 
					var scale_amount := self.scale.x / old_scale
					# Reset position to Centre on pointer
					self.position = (self.position * scale_amount) - (event.position * (scale_amount - 1))

It’s a bit raw currently (using the actual scale on the node rather than having a global scale for instance) but shows how to do the math.

Ratty | 2020-06-10 10:12

Thank you all for sharing!

DodoIta | 2020-06-10 13:01

:bust_in_silhouette: Reply From: rainlizard

Here’s the same code which was posted, except readable by humans:

extends Camera2D

var zoom_speed = 0.5

func _input(event):
	if event.is_action_released('zoom_in'):
		zoom_camera(-zoom_speed, event.position)
	if event.is_action_released('zoom_out'):
		zoom_camera(zoom_speed, event.position)

func zoom_camera(zoom_factor, mouse_position):
	var viewport_size = get_viewport().size
	var previous_zoom = zoom
	zoom += zoom * zoom_factor
	offset += ((viewport_size * 0.5) - mouse_position) * (zoom-previous_zoom)

Plus panning:

extends Camera2D

var zoom_speed = 0.5
var panning = false

func _input(event):
	if event.is_action_released('zoom_in'):
		zoom_camera(-zoom_speed, event.position)
	if event.is_action_released('zoom_out'):
		zoom_camera(zoom_speed, event.position)
	if event.is_action_pressed("pan_with_mouse"):
		panning = true
	elif event.is_action_released("pan_with_mouse"):
		panning = false
	if event is InputEventMouseMotion and panning == true:
		offset -= event.relative * zoom

func zoom_camera(zoom_factor, mouse_position):
	var viewport_size = get_viewport().size
	var previous_zoom = zoom
	zoom += zoom * zoom_factor
	offset += ((viewport_size * 0.5) - mouse_position) * (zoom-previous_zoom)

I’m using “offset” to set the position of the camera, but you can write “position” or “global_position” and it should work the same.

1 Like
:bust_in_silhouette: Reply From: kckckc

I tried a lot of solutions and couldn’t get them working so I finally tried to write my own and came up with this very simple solution (C#):

// Initial point
Vector2 zoomPoint = GetGlobalMousePosition();
// Do our zoom
camera.Zoom *= zoomFactor;
// Get back to the initial point
Vector2 currPoint = GetGlobalMousePosition();
Vector2 diff = zoomPoint - currPoint;

It checks the mouse position before and after zooming the Camera2D and then translates the camera to get the mouse back to where it was before in the world.

this is the easiest and most straight forward solution to implement, thank you!
however using Translate() caused some issues where the camera would randomly wiggle so i just update the offset

func zoom_camera(direction: int) -> void:
	var previous_mouse_position := get_local_mouse_position()

	zoom += zoom * zoom_speed * direction
	var diff = previous_mouse_position - get_local_mouse_position()
	offset += diff

direction should be -1 when zooming in and 1 when zooming out

CollCaz | 2023-05-06 16:36


Spent many hours trying to make Bartosz’s example work with GD 4.2 without success using
C1 = C0 + (-0.5*s + m)*(z0 - z1)

Working through the example step by step with the assumption that starting with a zoom of 1 and going to a zoom of 2 means that each pixel in screen space will go from 1 to 0.5 pixels in world space, means that the following is not formulated accurately.
M ~ (m - 0.5 * s)*z
with z = 2 each pixel in screen translates to 2 pixels in world space. Switching to the formula to
M ~ (m - 0.5 * s)/z
with z=2 this gives 0.5 world space pixels (M) per 1 in screen space (m)

simplifying to the end result

c1 = c0 + (-0.5*s + m) * (z1 - z0) / (z0 * z1);

which works perfectly in my use case. Big thank you to Bartosz for the original detailed post!

thank you two so much

After much struggling myself (Why is this problem so hard?!?) I found @SilverB1rd’s solution worked, although they changed a couple variables from the original and required some jumping around to understand. Here’s my code for using it on a Camera2D with a tween for a smooth zoom.

# Up top of your script
@onready var camera2d: Camera2D = $Camera2D
@onready var _half_size := Vector2(get_viewport().size) / 2.0

var _zoom_target: Vector2
var _zoom_start: float
var _zoom_start_position: Vector2
var _zoom_tween: Tween

# Your zoom in function, `mouse_pos` is screen space, `new_zoom` is a float with the new zoom level.
func _zoom_towards(new_zoom: float, mouse_pos: Vector2) -> void:
  _zoom_start = camera2d.zoom.x
  _zoom_start_position = camera2d.position
  _zoom_target = mouse_pos

  if _zoom_tween:
  _zoom_tween = get_tree().create_tween()
  _zoom_tween.tween_method(_zoom_towards_val, _zoom_start, new_zoom, 0.1)

# Handle the tween
func _zoom_towards_val(z: float) -> void:
	tree_camera.zoom = Vector2(z, z)
	tree_camera.position = _zoom_start_position + (-_half_size + _zoom_target) * (z - _zoom_start) / (_zoom_start * z)