Camera Gimbal

Problem

You need a camera controller, using mouse or keyboard, that remains level while rotating and following a target.

Solution

Try this: take a Camera3D node and rotate it a small amount around X (the red ring on the gizmo), then a small amount around Z (the blue ring). Now reverse the X rotation and click the “Preview” button. Observe how the camera is now tilted.

The solution to this problem is to place the camera on a gimbal - a device designed to keep an object level during movement. We can create a gimbal using two Node3D nodes, which will control the camera’s left/right and up/down rotation respectively.

The node setup should look like this:

 Node3D: CameraGimbal
     Node3D: InnerGimbal
         Camera3D

Set the Transform/Position of the Camera3D to (0, 0, 4).

Here’s how the gimbal works: the outer node can only be rotated in Y, while the inner one rotates only in X. You can test this out by rotating them manually, but make sure you change to “Local Space Mode” first (that’s the cube icon next to the lock in the menu bar - the keyboard shortcut to toggle is “T”). Remember to only move the green ring of the outer node and only the red ring of the inner one. Don’t touch the camera node at all.

Reset all the rotations to 0 once you’ve finished experimenting.

Keyboard control

We’ll start with the keyboard controls, then add an option to use the mouse as well. Here are the required actions and their assigned inputs:

Action NameInput
"cam_up"W
"cam_down"S
"cam_right"D
"cam_left"A
"cam_zoom_in"Mouse Wheel Up
"cam_zoom_out"Mouse Wheel Down

Here’s the initial script. Note that we’re making sure to rotate each Node3D in its local space around the specific axis, as described above.

extends Node3D

var rotation_speed = PI/2

func get_input_keyboard(delta):
    # Rotate outer gimbal around y axis
    var y_rotation = Input.get_axis("cam_left", "cam_right")
    rotate_object_local(Vector3.UP, y_rotation * rotation_speed * delta)
    # Rotate inner gimbal around local x axis
    var x_rotation = Input.get_axis("cam_up", "cam_down")
    x_rotation = -x_rotation if invert_y else x_rotation
    inner.rotate_object_local(Vector3.RIGHT, x_rotation * rotation_speed * delta)

func _process(delta):
    get_input_keyboard(delta)

Make a test scene with a MeshInstance3D and instance the CameraGimbal in it to test out the movement.

You’ll notice that holding the up/down control will cause the camera to rotate all the way around, eventually becoming upside-down. To prevent this, we can clamp the rotation.

func _process(delta):
    get_input_keyboard(delta)
    $InnerGimbal.rotation.x = clamp($InnerGimbal.rotation.x, -1.4, -0.01)

The -1.4 value lets it go almost to 90 degrees up, while setting a very small value for the minimum keeps the camera from clipping into the ground. Feel free to experiment with other values.

Mouse control

We’ll add a flag called mouse_control to enable easy toggling of mouse/keyboard controls.

# mouse properties
var invert_y = false
var invert_x = false
var mouse_control = false
var mouse_sensitivity = 0.005

func _unhandled_input(event):
    if mouse_control and event is InputEventMouseMotion:
        if event.relative.x != 0:
            var dir = 1 if invert_x else -1
            rotate_object_local(Vector3.UP, dir * event.relative.x * mouse_sensitivity)
        if event.relative.y != 0:
            var dir = 1 if invert_y else -1
            $InnerGimbal.rotate_object_local(Vector3.RIGHT, dir * event.relative.y * mouse_sensitivity)

func _process(delta):
    if !mouse_control:
        get_input_keyboard(delta)

This code works by converting horizontal mouse motion to Y rotation of the outer gimbal and vertical to X rotation for the inner gimbal. We’ve also added invert_x and invert_y flags so that you can flip the motion in either axis - many players prefer one over the other, so it’s best to allow for both options.

Also, in _process() we disable keyboard input when using mouse control.

You may notice a problem with the up/down movement if you move the mouse too quickly. A large value for event.relative.y results in “skipping” to the opposite side of the clamped value. We can solve this by clamping the vertical mouse movement to a reasonable value. Change the above code for y to this:

if event.relative.y != 0:
    var dir = 1 if invert_y else -1
    var y_rotation = clamp(event.relative.y, -30, 30)
    $InnerGimbal.rotate_object_local(Vector3.RIGHT, dir * y_rotation * mouse_sensitivity)
Note

In your project, you’ll probably also want to capture the mouse during gameplay. See the linked recipe at the end of this document for details.

Camera zoom

Camera zoom works by varying the scale of the gimbal system.

# zoom settings
var max_zoom = 3.0
var min_zoom = 0.5
var zoom_speed = 0.09

var zoom = 1.5

func _unhandled_input(event):
    if event.is_action_pressed("cam_zoom_in"):
        zoom -= zoom_speed
    if event.is_action_pressed("cam_zoom_out"):
        zoom += zoom_speed
    zoom = clamp(zoom, min_zoom, max_zoom)

func _process(delta):
    scale = lerp(scale, Vector3.ONE * zoom, zoom_speed)

Using lerp() to change the zoom level results in smoother zooming.

alt alt

Following a target

Once you have the camera gimbal set up, it can follow a target by adding the following:

@export var target : Node3D

func _process(delta):
    if target:
        global_position = target.global_position

Instance the camera in your scene and use the Inspector to choose the node you want to follow.

Final script

For completeness, here’s the full script, including @export variables for all the camera settings, so that you can configure it in your project.

extends Node3D

@export var target : Node3D

@export_range(0.0, 2.0) var rotation_speed = PI/2

# mouse properties
@export var mouse_control = false
@export_range(0.001, 0.1) var mouse_sensitivity = 0.005
@export var invert_y = false
@export var invert_x = false

# zoom settings
@export var max_zoom = 3.0
@export var min_zoom = 0.4
@export_range(0.05, 1.0) var zoom_speed = 0.09

var zoom = 1.5

@onready var inner = $InnerGimbal

func _unhandled_input(event):
    if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
        return
    if event.is_action_pressed("cam_zoom_in"):
        zoom -= zoom_speed
    if event.is_action_pressed("cam_zoom_out"):
        zoom += zoom_speed
    zoom = clamp(zoom, min_zoom, max_zoom)
    if mouse_control and event is InputEventMouseMotion:
        if event.relative.x != 0:
            var dir = 1 if invert_x else -1
            rotate_object_local(Vector3.UP, dir * event.relative.x * mouse_sensitivity)
        if event.relative.y != 0:
            var dir = 1 if invert_y else -1
            var y_rotation = clamp(event.relative.y, -30, 30)
            inner.rotate_object_local(Vector3.RIGHT, dir * y_rotation * mouse_sensitivity)

func get_input_keyboard(delta):
    # Rotate outer gimbal around y axis
    var y_rotation = Input.get_axis("cam_left", "cam_right")
    rotate_object_local(Vector3.UP, y_rotation * rotation_speed * delta)
    # Rotate inner gimbal around local x axis
    var x_rotation = Input.get_axis("cam_up", "cam_down")
    x_rotation = -x_rotation if invert_y else x_rotation
    inner.rotate_object_local(Vector3.RIGHT, x_rotation * rotation_speed * delta)

func _process(delta):
    if !mouse_control:
        get_input_keyboard(delta)
    inner.rotation.x = clamp(inner.rotation.x, -1.4, -0.01)
    scale = lerp(scale, Vector3.ONE * zoom, zoom_speed)
    if target:
        global_position = target.global_position