Skip to content

Instantly share code, notes, and snippets.

@thygrrr
Last active October 19, 2025 01:11
Show Gist options
  • Select an option

  • Save thygrrr/8288cabeb5cd25031ce6132c4a886311 to your computer and use it in GitHub Desktop.

Select an option

Save thygrrr/8288cabeb5cd25031ce6132c4a886311 to your computer and use it in GitHub Desktop.
Godot Zoom and Pan, smooth & cursor-centric Camera2D motion
# SPDX-License-Identifier: Unlicense or CC0
extends Node2D
# Smooth panning and precise zooming for Camera2D
# Usage: This script may be placed on a child node
# of a Camera2D or on a Camera2D itself.
# Suggestion: Change and/or set up the three Input Actions,
# otherwise the mouse will fall back to hard-wired mouse
# buttons and you will miss out on alternative bindings,
# deadzones, and other nice things from the project InputMap.
class_name CameraZoomAndPan
@onready var camera : Camera2D = $".." if ($".." is Camera2D) else self
#region Exported Parameters
@export_range(1, 20, 0.01) var maxZoom : float = 5.0
@export_range(0.01, 1, 0.01) var minZoom : float = 0.1
@export_range(0.01, 0.2, 0.01) var zoomStepRatio : float = 0.1
@export_group("Actions")
@export var panAction : String = "camera>pan"
@export var zoomInAction : String = "camera>zoom+"
@export var zoomOutAction : String = "camera>zoom-"
@export_group("Mouse")
@export var zoomToCursor: bool = true
@export_enum("Auto", "Always", "Never") var useFallbackButtons: String = "Auto"
@export var panButton : MouseButton = MOUSE_BUTTON_MIDDLE
@export var zoomInButton : MouseButton = MOUSE_BUTTON_WHEEL_UP
@export var zoomOutButton : MouseButton = MOUSE_BUTTON_WHEEL_DOWN
@export_group("Smoothing")
@export_range(0, 0.99, 0.01) var panSmoothing : float = 0.5:
set(new_value):
panSmoothing = pow(new_value, slider_exponent)
get:
return panSmoothing
@export_range(0, 0.99, 0.01) var zoomSmoothing : float = 0.5:
set(new_value):
zoomSmoothing = pow(new_value, slider_exponent)
get:
return zoomSmoothing
# To make the sliders be pleasantly non-linear
const slider_exponent : float = 0.25
# To make the smoothing ratios framerate-independent
const referenceFPS : float = 120.0
#endregion
#region State Initialization
@onready var zoom_goal := camera.zoom
@onready var position_goal := camera.position
var fallback_mouse_pan : bool
var fallback_mouse_zoom_in : bool
var fallback_mouse_zoom_out : bool
var last_mouse : Vector2
var zoom_mouse : Vector2
func _ready() -> void:
# We need to do manually re-assign the editor-serialized values
# because the initial editor value doesn't go through the setter
panSmoothing = panSmoothing
zoomSmoothing = zoomSmoothing
# If the actions aren't defined and mouse fallback is enabled,
# use the default mouse buttons
var actions = InputMap.get_actions()
var always = useFallbackButtons == "Always"
var never = useFallbackButtons == "Never"
fallback_mouse_pan = not never and (always or (panAction not in actions))
fallback_mouse_zoom_in = not never and (always or (zoomInAction not in actions))
fallback_mouse_zoom_out = not never and (always or (zoomOutAction not in actions))
if not always and (fallback_mouse_pan or fallback_mouse_zoom_in or fallback_mouse_zoom_out):
prints("CameraZoomAndPan: Mouse Fallbacks for Actions in effect!",
panAction + "=" + str(fallback_mouse_pan),
zoomInAction + "=" + str(fallback_mouse_zoom_in),
zoomOutAction + "=" + str(fallback_mouse_zoom_out))
printt("CameraZoomAndPan: TIP - set up all three of the following InputActions:", panAction, zoomInAction, zoomOutAction)
#endregion
func _process(delta: float) -> void:
# Calculate FIR / invExp kernels for smoothing
var k_pan := pow(panSmoothing, referenceFPS * delta)
var k_zoom := pow(zoomSmoothing, referenceFPS * delta)
var mouse_pre_zoom := to_local(get_canvas_transform().affine_inverse().basis_xform(zoom_mouse))
camera.zoom = camera.zoom * k_zoom + (1.0-k_zoom) * zoom_goal
var mouse_post_zoom := to_local(get_canvas_transform().affine_inverse().basis_xform(zoom_mouse))
var zoom_position_offset := (mouse_pre_zoom - mouse_post_zoom) if zoomToCursor else Vector2.ZERO
position_goal += zoom_position_offset
camera.position = camera.position * k_pan + (1.0-k_pan) * position_goal + zoom_position_offset
func _unhandled_input(event: InputEvent) -> void:
if not event is InputEventMouse and not event is InputEventAction:
return
var current_mouse := get_local_mouse_position()
if Input.is_action_pressed(panAction) or (fallback_mouse_pan and Input.is_mouse_button_pressed(panButton)):
position_goal += (last_mouse - current_mouse)
if Input.is_action_just_pressed(zoomInAction) or (fallback_mouse_zoom_in and Input.is_mouse_button_pressed(zoomInButton)):
zoom_goal *= 1.0 / (1.0-zoomStepRatio)
zoom_mouse = get_viewport().get_mouse_position()
zoom_mouse -= get_viewport_rect().size * 0.5
if Input.is_action_just_pressed(zoomOutAction) or (fallback_mouse_zoom_out and Input.is_mouse_button_pressed(zoomOutButton)):
zoom_goal *= (1.0-zoomStepRatio)
zoom_mouse = get_viewport().get_mouse_position()
zoom_mouse -= get_viewport_rect().size * 0.5
zoom_goal = zoom_goal.clamp(minZoom * Vector2.ONE, maxZoom * Vector2.ONE)
last_mouse = current_mouse
@thygrrr
Copy link
Author

thygrrr commented Mar 10, 2024

ScrollPanDemo.mp4

@thygrrr
Copy link
Author

thygrrr commented Mar 10, 2024

Godot_v4.2.1-stable_mono_win64_0caSrT7xAJ.mp4

@thygrrr
Copy link
Author

thygrrr commented Mar 10, 2024

Exercise for the reader: Perfectionists would instead of the inverse exponential smoothing use a critical spring, aka. SmoothDamp. It only plays a role when rapidly changing directions, but will subtly feel even more pleasant.

However, this would have diluted the Gist with a 3rd function and 4th function and at least two more state variables (especially since Godot does not support passing by reference)

@thygrrr
Copy link
Author

thygrrr commented Mar 10, 2024

Godot_v4 2 1-stable_mono_win64_Wrj11aAjj5

Added setting after recommendation by https://mastodon.gamedev.place/@[email protected]

@thygrrr
Copy link
Author

thygrrr commented Mar 10, 2024

Here's a version with a simplistic SmoothDamp implementation. I lied about the exercise for the reader. Find your own motivation. 😺

# SPDX-License-Identifier: Unlicense or CC0
extends Node2D

# Smooth panning and precise zooming for Camera2D
# Usage: This script may be placed on a child node
# of a Camera2D or on a Camera2D itself.
# Suggestion: Change and/or set up the three Input Actions,
# otherwise the mouse will fall back to hard-wired mouse
# buttons and you will miss out on alternative bindings,
# deadzones, and other nice things from the project InputMap.
class_name CameraZoomAndPan

@onready var camera : Camera2D = $".." if ($".." is Camera2D) else self

#region exported Parameters
@export_range(1, 20, 0.01) var maxZoom : float = 5.0
@export_range(0.01, 1, 0.01) var minZoom : float = 0.1
@export_range(0.01, 0.2, 0.01) var zoomStepRatio : float = 0.1

@export_group("Actions")
@export var panAction : String = "camera>pan"
@export var zoomInAction : String = "camera>zoom+"
@export var zoomOutAction : String = "camera>zoom-"


@export_group("Mouse")
@export var zoomToCursor: bool = true
@export_enum("Auto", "Always", "Never") var useFallbackButtons: String = "Auto"
@export var panButton : MouseButton = MOUSE_BUTTON_MIDDLE
@export var zoomInButton : MouseButton = MOUSE_BUTTON_WHEEL_UP
@export var zoomOutButton : MouseButton = MOUSE_BUTTON_WHEEL_DOWN

@export_group("Smoothing")
@export_range(0, 0.4, 0.01) var panSmoothing : float = 0.2
@export_range(0, 0.4, 0.01) var zoomSmoothing : float = 0.2
#endregion


#region State Initialization
@onready var zoom_goal := camera.zoom
@onready var position_goal := camera.position

var fallback_mouse_pan : bool
var fallback_mouse_zoom_in : bool
var fallback_mouse_zoom_out : bool
var last_mouse : Vector2
var zoom_mouse : Vector2

@onready var damped_pan: Array[Vector2] = [camera.position, Vector2.ZERO]
@onready var damped_zoom: Array[Vector2] = [camera.zoom, Vector2.ZERO]


func _ready() -> void:
	# If the actions aren't defined and mouse fallback is enabled,
	# use the default mouse buttons
	var actions = InputMap.get_actions()
	var always = useFallbackButtons == "Always"
	var never = useFallbackButtons == "Never"
	fallback_mouse_pan = not never and (always or (panAction not in actions))
	fallback_mouse_zoom_in = not never and (always or (zoomInAction not in actions))
	fallback_mouse_zoom_out = not never and (always or (zoomOutAction not in actions))

	if not always and (fallback_mouse_pan or fallback_mouse_zoom_in or fallback_mouse_zoom_out):
		prints("CameraZoomAndPan: Mouse Fallbacks for Actions in effect!",
			panAction + "=" + str(fallback_mouse_pan),
			zoomInAction + "=" + str(fallback_mouse_zoom_in),
			zoomOutAction + "=" + str(fallback_mouse_zoom_out))
		printt("CameraZoomAndPan: TIP - set up all three of the following InputActions:",
			panAction,
			zoomInAction,
			zoomOutAction)
#endregion


func _process(delta: float) -> void:
	_SmoothDamp(damped_zoom, zoom_goal, zoomSmoothing, delta)

	# Zoom in and determine camera offset to keep
	# the view under the mouse cursor
	var mouse_pre_zoom := to_local(get_canvas_transform().affine_inverse().basis_xform(zoom_mouse))
	camera.zoom = damped_zoom[0]
	var mouse_post_zoom := to_local(get_canvas_transform().affine_inverse().basis_xform(zoom_mouse))

	var zoom_position_offset := (mouse_pre_zoom - mouse_post_zoom) if zoomToCursor else Vector2.ZERO

	position_goal += zoom_position_offset
	damped_pan[0] += zoom_position_offset


	_SmoothDamp(damped_pan, position_goal, panSmoothing, delta)
	camera.position = damped_pan[0]




func _unhandled_input(event: InputEvent) -> void:
	if not event is InputEventMouse and not event is InputEventAction:
		return

	var current_mouse := get_local_mouse_position()

	if Input.is_action_pressed(panAction) or (fallback_mouse_pan and Input.is_mouse_button_pressed(panButton)):
		position_goal += (last_mouse - current_mouse)

	if Input.is_action_just_pressed(zoomInAction) or (fallback_mouse_zoom_in and Input.is_mouse_button_pressed(zoomInButton)):
		zoom_goal *= 1.0 / (1.0-zoomStepRatio)
		zoom_mouse = get_viewport().get_mouse_position()
		zoom_mouse -= get_viewport_rect().size * 0.5

	if Input.is_action_just_pressed(zoomOutAction) or (fallback_mouse_zoom_out and Input.is_mouse_button_pressed(zoomOutButton)):
		zoom_goal *= (1.0-zoomStepRatio)
		zoom_mouse = get_viewport().get_mouse_position()
		zoom_mouse -= get_viewport_rect().size * 0.5

	zoom_goal = zoom_goal.clamp(minZoom * Vector2.ONE, maxZoom * Vector2.ONE)
	last_mouse = current_mouse




func _SmoothDamp(state: Array[Vector2], target : Vector2, smoothTime : float, deltaTime : float):
		# We speed up the spring to allow for nicer input values
		# and a behaviour closer to the "actual" time to come to rest
		smoothTime /= 2.0

		var current := state[0]
		var linear_velocity := state[1]

		if smoothTime == 0:
			state[0] = target
			state[1] = Vector2.ZERO
			return

		var omega := 2.0 / smoothTime

		var x := omega * deltaTime;
		var expo := 1.0 / (1.0 + x + 0.48 * x * x + 0.235 * x * x * x);

		var change := current - target;
		var originalTo := target;

		# Optional: Clamp maxSpeed
		# var maxChange = maxSpeed * smoothTime;
		# change = clamp(change, -maxChange, maxChange);
		target = current - change;

		var temp := (linear_velocity + omega * change) * deltaTime
		linear_velocity = (linear_velocity - omega * temp) * expo
		var output := target + (change + temp) * expo

		# Prevent overshooting - FIXME
		# likely needs to treat all components separately
		if (originalTo.x > current.x) == (output.x > originalTo.x):
			output.x = originalTo.x
			linear_velocity.x = (output.x - originalTo.x) / deltaTime
		if (originalTo.y > current.y) == (output.y > originalTo.y):
			output.y = originalTo.y
			linear_velocity.y = (output.y - originalTo.y) / deltaTime

		state[0] = output
		state[1] = linear_velocity

@thygrrr
Copy link
Author

thygrrr commented Mar 10, 2024

Thou art as smooth as butter on a warm summer day...

Godot.V4.2.1-Stable.Mono.Win64.Il07swzzyy-20.mp4

@MrSmiler
Copy link

MrSmiler commented Feb 17, 2025

thank you for this code, I was going to ask you if it's possible could you also implement camera pan when we move mouse to edges like a RTS game ?

@Dev-Sharbel
Copy link

Thank you for this! Though the zooming seem to wobble when zoomToCursor is true. I'm on Godot stable v4.4.1

Perhaps this wobbling is caused by minuscule changes in cursor position when zooming in and out, causing it the camera to move back to the cursor's original position prior to zoom. It's apparent when zooming quickly from max zoom to min zoom.

Second version of the code, with SmoothDamp implementation does not have such problem, and is "as smooth as butter on a warm summer day..." xd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment