Camera3D Interpolation (Jitter fix)

Godot Version

4.4

Question

Now I’m trying to fix the famous camera jitter issue when rotating, which happens due to a mismatch between FPS and physics ticks. In my project, I have enabled Physics Interpolation and handle camera rotation in _physics_process. But Godot is still complaining that Interpolated Camera3D triggered from outside physics process.

Here are all the scripts related to the camera’s rotation:
1) mouse_capture.gd

class_name MouseCaptureComponent extends Node

@export var debug : bool = false

@export_category(“References”)
@export var camera_controller : CameraController

@export_category(“Mouse Capture Settings”)
@export var current_mouse_mode : Input.MouseMode = Input.MOUSE_MODE_CAPTURED
@export var mouse_sensitivity : float = 0.05

var _accumulated_mouse_input : Vector2 = Vector2.ZERO
var _mouse_input_to_process : Vector2 = Vector2.ZERO


func _unhandled_input(event: InputEvent) → void:
    if event is InputEventMouseMotion and Input.mouse_mode ==    Input.MOUSE_MODE_CAPTURED:
        _accumulated_mouse_input.x += -event.screen_relative.x * mouse_sensitivity
        _accumulated_mouse_input.y += -event.screen_relative.y * mouse_sensitivity


func _physics_process(_delta: float) → void:
    _mouse_input_to_process = _accumulated_mouse_input
    _accumulated_mouse_input = Vector2.ZERO
    if _mouse_input_to_process != Vector2.ZERO:
	    camera_controller.update_camera_rotation(_mouse_input_to_process)


func _ready() → void:
    Input.mouse_mode = current_mouse_mode 

  1. camera_controller.gd
class_name CameraController extends Node3D

@export var debug : bool = false

@export_category(“References”)
@export var player_controller : PlayerController
@export var component_mouse_capture : MouseCaptureComponent

@export_category(“Camera Settings”)
@export_group(“Camera Tilt”)
@export_range(-90, -60) var tilt_lower_limit : int = -90
@export_range(60, 90) var tilt_upper_limit : int = 90

var _rotation : Vector3


func update_camera_rotation(input: Vector2) → void:
    _rotation.x += input.y
    _rotation.y += input.x
    _rotation.x = clamp(_rotation.x, deg_to_rad(tilt_lower_limit),   deg_to_rad(tilt_upper_limit))

    var _player_rotation = Vector3(0.0, _rotation.y, 0.0)
    var _camera_rotation = Vector3(_rotation.x, 0.0, 0.0)

    transform.basis = Basis.from_euler(_camera_rotation)
    player_controller.update_rotation(_player_rotation)

    _rotation.z = 0.0
  1. player_controller.gd
class_name PlayerController extends StairsCharacter3D

@export var debug : bool = false

@export_category(“References”)
@export var camera_controller : CameraController
@export var camera_effects : CameraEffects


func update_rotation(rotation_input) → void:
    global_transform.basis = Basis.from_euler(rotation_input)

What’s wrong?

Some information on how this is famous would be helpful, as I’ve never heard of this problem on these forums, nor had this problem myself.

How come? Was it not working without this on? What were you seeing before you did this?

Copy and paste the exact error message, including any additional debug info like line numbers (if they exist).

Then tell us what Interpolated Camera3D is, because it’s not a default object, and none of your scripts are named that.

Show us a screenshot of your scene tree. Because CameraController is a Node3D, not a Camera3D. So how are they attached?

What does StairsCharacter3D extend?


Ultimately, it seems to me that you have a somewhat complicated setup. Here’s what my first person camera controller looks like (simplified because it handles mouse and gamepad input for 1st and 3rd person cameras):

class_name CameraMount3D extends Node3D

## How far up the camera will rotate in degrees.
@export var upwards_rotation_limit: float = 0.0
## How far down the camera will rotate in degrees.
@export var downwards_rotation_limit: float = 0.0

@onready var spring_arm_3d: SpringArm3D = $SpringArm3D
@onready var camera_3d: Camera3D = $SpringArm3D/Camera3D
@onready var horizontal_pivot: Node3D = $"Horizontal Pivot"
@onready var vertical_pivot: Node3D = $"Horizontal Pivot/Vertical Pivot"


func _physics_process(delta: float) -> void:
	_update_rotation()


func _update_rotation() -> void:
	horizontal_pivot.rotate_y(Controller.look.x)
	vertical_pivot.rotate_x(Controller.look.y)
	vertical_pivot.rotation.x = clampf(vertical_pivot.rotation.x,
		deg_to_rad(upwards_rotation_limit),
		deg_to_rad(downwards_rotation_limit)
	)
	_apply_rotation()


func _apply_rotation() -> void:
	spring_arm_3d.rotation.y = horizontal_pivot.rotation.y
	camera_3d.rotation.x = vertical_pivot.rotation.x

This is what the node tree looks like:

This is what the mouse input looks like:

func _unhandled_input(event: InputEvent) -> void:
	if Controller.enable_3d_look and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and event is InputEventMouseMotion:
		Controller.look = -event.relative * sensitivity

It works fine. You can see the full code with a working example in my Character3D plugin.

1 Like

I fixed this using physics interpolation. My code is on GitHub here: GitHub - wide-context/first-person-controller: A first person controller for Godot 4 .

basically anything to do with the camera rotation should be separated from the physics body, and the position of the camera should be updated to match the interpolated position of the physics body. I commented the code in my GitHub pretty well and it’s basically just an extension to the generic 3D controller code so it should be easy to follow. It is dead smooth camera even at 10fps physics tick

2 Likes
  1. If its not famous - my fault, but I saw some comments on YT about this problem in other engines…
  2. If I untick “Physics Interpolation” then I see some jittery movement.
    Check this video to see the difference, its about my problem: https://www.youtube.com/watch?v=zfIuaRzNti4
  3. W 0:00:15:008 _notification: [Physics interpolation] Interpolated Camera3D triggered from outside physics process: “/root/MainScene/CurrentLevel/test_map/PlayerController/CameraController/Camera3D” (possibly benign).
    <C++ Source> scene/3d/camera_3d.cpp:216 @ _notification()
  4. Interpolated Camera3D is not an additional object or something (atleast I didn’t create it somewhere), it’s just automatically created by the engine (I guess??).

  1. I reversed my mouse_capture.gd script to its original form (I’m sorry if this confuses you.)
    And now it’s not handled in physics_process.
class_name MouseCaptureComponent extends Node

@export var debug : bool = false

@export_category(“References”)
@export var camera_controller : CameraController

@export_category(“Mouse Capture Settings”)
@export var current_mouse_mode : Input.MouseMode = Input.MOUSE_MODE_CAPTURED
@export var mouse_sensitivity : float = 0.05

var _mouse_input : Vector2


func _unhandled_input(event: InputEvent) → void:    
    if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:    
    _mouse_input.x += -event.screen_relative.x * mouse_sensitivity        
    _mouse_input.y += -event.screen_relative.y * mouse_sensitivity    
    camera_controller.update_camera_rotation(_mouse_input)


func _ready() → void:    
    Input.mouse_mode = current_mouse_mode


func _process(_delta: float) → void:    
    _mouse_input = Vector2.ZERO

you also need to separate the rotating nodes from your main physics object that controls player movement, and interpolate the position of that node manually like so:

extends CharacterBody3D

const SPEED: float = 5.0
const JUMP_VELOCITY: float = 4.5

@onready var actor: Node3D = $Actor


func _ready() -> void:
	# Separate actor transform to allow manual interpolation
	actor.top_level = true
	
	# Disable actor physics interpolation as we will manually interpolate
	actor.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF


func _process(_delta: float) -> void:
	# Update actor position to follow the FirstPersonController
	if is_physics_interpolated_and_enabled():
		# If the project settings use physics interpolation
		actor.position = get_global_transform_interpolated().origin
	else:
		# If no physics interpolation, no need to use interpolated position
		actor.position = global_transform.origin


func _physics_process(delta: float) -> void:
	# Add the gravity
	if not is_on_floor():
		velocity += get_gravity() * delta

	# Handle jump
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY

	# Get the input direction and handle the movement/deceleration
	# Use the actor global basis to determine forward direction
	var input_dir: Vector2 = Input.get_vector("move_left", "move_right",
			"move_forward", "move_backward")
	var direction: Vector3 = (actor.global_transform.basis *
			Vector3(input_dir.x, 0.0, input_dir.y)).normalized()
	
	if direction:
		velocity.x = direction.x * SPEED
		velocity.z = direction.z * SPEED
	else:
		velocity.x = move_toward(velocity.x, 0.0, SPEED)
		velocity.z = move_toward(velocity.z, 0.0, SPEED)

1 Like

Got it.

So this is useful, because we can look at the code in the Godot repo that’s throwing the error and potentially get some clues. Looking at line 216, it’s being called when the Camer3D gets the NOTIFICATION_EXIT_WORLD notification. Which means something is exiting the world . . .

		case NOTIFICATION_EXIT_WORLD: {
/*Line 216*/if (!is_part_of_edited_scene()) {
				if (is_current()) {
					clear_current();
					current = true; //keep it true

				} else {
					current = false;
				}
			}

. . . and specifically that line is checking to see if the object that threw the notification is part of the edited scene:

bool Node::is_part_of_edited_scene() const {
	return Engine::get_singleton()->is_editor_hint() && is_inside_tree() && data.tree->get_edited_scene_root() &&
			data.tree->get_edited_scene_root()->get_parent()->is_ancestor_of(this);
}

So, based on the error, it looks like the Camera3D is being triggered when something is leaving the scene - most likely through queue_free(), and it’s happening outside the physics process - which it has to because you cannot queue_free something in the middle of a physics tick. (It always runs at the end of the current tick, no matter when it is called.)

Having said all that, I think @wide-context may be correct about your solution. If you look at my code above, and my scene tree, I do the rotation on two Node3D objects - one each for x-axis and y-axis, and the copy those rotations onto the SpringArm3D (horizontal) and Camera3D (vertical). The separate axes are to prevent gimbal lock. And applying the rotation from separate nodes prevents weird rotation issues.

2 Likes

Is it okay to use two parallel _unhandled_input functions? It seems to me it would consume quite a lot of resources. (If we’re talking about the look_left_right and look_up_down functions)

Joining the discussion late and not fully up to speed so sorry if this was already discussed but in the documentation on advanced physics interpolation it says that Camera3D should not use physics and instead should be interpolated manually in _process. See here.

3 Likes

I used @wide-context project and did something…
Overall, everything worked, but I’m afraid I might have misunderstood something and left behind some non-obvious (to me) issues.

  1. mouse_capture.gd
class_name MouseCaptureComponent extends Node

@export var debug : bool = false

@export_category("References")
@export var camera_controller : CameraController
@export var actor : Actor

@export_category("Mouse Capture Settings")
@export var current_mouse_mode : Input.MouseMode = Input.MOUSE_MODE_CAPTURED
@export var mouse_sensitivity : float = 0.002

var look_rotation : Vector2 = Vector2(0.0, 0.0)


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		look_rotation.x -= event.relative.x * mouse_sensitivity
		look_rotation.y -= event.relative.y * mouse_sensitivity
		camera_controller.update_camera_rotation_up_down(look_rotation.y)
		actor.update_camera_rotation_left_right(look_rotation.x)


func _ready() -> void:
	Input.mouse_mode = current_mouse_mode
  1. actor.gd
class_name Actor extends Node3D

@export var debug : bool = false
@export_category(“References”)
@export var player_controller : PlayerController
@export var component_mouse_capture : MouseCaptureComponent

func update_camera_rotation_left_right(look_rotation_left_right: float) → void:
    look_rotation_left_right = fposmod(look_rotation_left_right, TAU)

    transform.basis = Basis()

    rotate_object_local(Vector3(0.0, 1.0, 0.0), look_rotation_left_right)

    rotation.z = 0.0
  1. camera_controller.gd
class_name CameraController extends Node3D

@export var debug : bool = false
@export_category("References")
@export var player_controller : PlayerController
@export var component_mouse_capture : MouseCaptureComponent

@export_category("Camera Settings")
@export_group("Camera Tilt")
@export_range(-90, -60) var tilt_lower_limit : int = -90
@export_range(60, 90) var tilt_upper_limit : int = 90
@export_group("Crouch Vertical Movement")
@export var crouch_offset : float = 0.0
@export var crouch_speed : float = 3.0

const DEFAULT_HEIGHT : float = 0.5


func update_camera_rotation_up_down(look_rotation_up_down: float) -> void:
	look_rotation_up_down = clampf(look_rotation_up_down, deg_to_rad(tilt_lower_limit), deg_to_rad(tilt_upper_limit))

	transform.basis = Basis()

	rotate_object_local(Vector3(1.0, 0.0, 0.0), look_rotation_up_down)

	rotation.z = 0.0


func update_camera_height(delta: float, direction: int) -> void:
	if position.y >= crouch_offset and position.y <= DEFAULT_HEIGHT:
		position.y = clampf(position.y + (crouch_speed * direction) * delta, crouch_offset, DEFAULT_HEIGHT)
  1. player_controller.gd
class_name PlayerController extends StairsCharacter3D

@export var debug : bool = false

@export_category("References")
@export var camera_controller : CameraController
@export var camera_effects : CameraEffects

@onready var actor: Node3D = $Actor


func _ready() -> void:
	actor.top_level = true
	actor.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF


func _process(_delta: float) -> void:
	if is_physics_interpolated_and_enabled():
		actor.position = get_global_transform_interpolated().origin
	else:
		actor.position = global_transform.origin
  1. Node Tree

It looks like you’ve got everything you need in place for it to work.

A couple of things I noticed in your code:

The Actor and CameraController node scripts don’t need a reference to the MouseCapture node since your calling the update_rotation…() functions from inside the MouseCapture script.

In the update_rotation scripts you don’t need to set rotation.z = 0.0 as transform.basis = Basis() is resetting all rotation and scale to 0.0 already.

Using the input and unhandled_input functions are accessing the same input event so I don’t see how it would be significantly increasing the resources used (I could be wrong though, I’m pretty new here). I did it that way to avoid having to reference other nodes for the mouse input, but if you use mouse input for other functions as well maybe it makes sense to separate its functionality to its own node.

Otherwise it looks good! Hope this helped!

1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.