Last active
February 18, 2025 18:38
-
-
Save thygrrr/8288cabeb5cd25031ce6132c4a886311 to your computer and use it in GitHub Desktop.
Godot Zoom and Pan, smooth & cursor-centric Camera2D motion
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |

Added setting after recommendation by https://mastodon.gamedev.place/@[email protected]
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
Thou art as smooth as butter on a warm summer day...
Godot.V4.2.1-Stable.Mono.Win64.Il07swzzyy-20.mp4
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 ?
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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)