Repository: godotengine/godot (language: cpp)

Issue:
Skeleton's deferred update process should be performed after physics interpolation
### Tested versions

- Reproduced in v4.4.beta4.official [93d270693]

### System information

Godot v4.4.beta4 - Windows 10 (build 19045) - Multi-window, 2 monitors - Vulkan (Forward+) - dedicated NVIDIA GeForce RTX 3070 Ti (NVIDIA; 32.0.15.6636) - 12th Gen Intel(R) Core(TM) i9-12900K (24 threads)

### Issue description

Enabling physics interpolation in project settings and following procedures to manually implement interpolation for camera movement ([trying to follow this](https://docs.godotengine.org/en/3.6/tutorials/physics/interpolation/advanced_physics_interpolation.html#mouse-look)) results in the SkeletonIK3D failing to have physics interpolation while moving and looking around, while everything else is smooth:

https://github.com/user-attachments/assets/0645a0ac-7f8b-48fe-a912-1e8695df23ec


It is noteworthy that disabling physics interpolation globally but keeping the manual implementation on the camera results in smooth ik movement when looking around but NOT while walking:

https://github.com/user-attachments/assets/bf066442-43d8-4549-9d8b-55cc49cd6035

It's entirely possible I am implementing this incorrectly, in which case, better documentation/information would be really helpful, as currently it seems unclear how to properly implement first person camera mouse movement with physics interpolation on high refresh monitors and inverse kinematics.

### Steps to reproduce

1. Enable physics interpolation globally and turn down the physics tick rate to 10 (so the effect is more clear)
2. Create a first person player with a camera child that is top level and has it's interpolation turned off, and manually implemented (see camera_3d.gd)
3. Using a rigged model (provided in the MRP) attach a skeletonIK3D node and put its target to a Marker3D node that is a child of the camera
4. Play the project and walk/look around


### Minimal reproduction project (MRP)

[firstpersontest.zip](https://github.com/user-attachments/files/18858802/firstpersontest.zip)


Discussion / hints:
To synchronize with Node using the Physics process, the Modifier update process in Skeleton should be Physics as well.

![Image](https://github.com/user-attachments/assets/ccdbe6ff-f271-4bde-bb05-c382aac2dbed)
Thanks for the response @TokageItLab Enabling that results in this behavior when at a low physics rate:

https://github.com/user-attachments/assets/86507b5d-1f65-4267-be9a-a1b4aed2b761

And at 60 physics ticks, the IK is still very jittery:

https://github.com/user-attachments/assets/eab403c8-ea99-4ee7-b4e7-86a22ea49161
I see, so there are two out-of-sync behaviors in the video in the description.

- "Idle" vs "Physics" Process
- "Interpolated" vs "Non-interpolated" Physics result

Regarding the former, as I commented above, the synchronization gap between Physics Process and Idle Process can be solved by setting the Modifier's CallbackModeProcess to Physics. When the FPS is low, it seems to be reduced, but actually, the latter synchronization gap is still left.

The current process order looks like this:

1. Skeleton3D's _physics_process to make a flag to deferred update 
2. iterate SkeletonModifiers in skeleton deferred update process
3. PhysicalServer's physics interpolation

However, ideally, the process should be done in the following order:

1. Skeleton3D's _physics_process to make a flag to deferred update 
2. PhysicalServer's physics interpolation
3. iterate SkeletonModifiers in skeleton deferred update process

Any good ideas on how to resolve this @lawnjelly ?

I don't know the details of how Physics Interpolation is implemented. However, if the Physics Interpolation is done in conjunction with the RenderingServer, I would think we could do something within the RenderingServer. If not so, I guess it might require some additional processing for the Skeleton's Physics Process to wait for Physics Interpolation to be processed.
Just taking a look.

First thing (which isn't really related to the behaviour your are seeing) is that for your `Camera` script, you are pointing to the correct documentation, but you don't need to do all that stuff yourself (storing previous and current xforms of the target, and interpolating), it can be done automatically for you by calling the helper function `get_global_transform_interpolated()`.

So you just need a one-liner:
```
func _process(_delta):
	global_position = target.get_global_transform_interpolated().origin
```
That's literally it, providing you have turned interpolation off (`set_physics_interpolation_mode(Node.PHYSICS_INTERPOLATION_MODE_OFF)` or in the inspector).

Indeed your method for updating prev and curr xforms looks to have bugs (I don't think it will work if two physics ticks occur before a frame), which is why using the in-built method is advised, aside from being less verbose etc.
Ok I have a workaround for now, add this script to `Skeleton3D` (and keep it in idle mode):

```
extends Skeleton3D

func _ready() -> void:
	set_as_top_level(true)
	physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
	
func _process(delta: float) -> void:
	global_transform = get_parent().get_global_transform_interpolated()
```
This is presumably also a problem in 3.x, I only tested the IK with the Godot IK demo which had a non-moving root node.

I figured out IK works most sensibly when kept in idle mode, with physics interpolation OFF for the skeleton, the only difference is that the target should be client interpolated, so in the c++ code it calls `get_global_transform_interpolated()` for the target. And it works great when the root node of the skeleton is not moving.

When moving, the problem is that the skeleton root will jump on each physics tick if physics interpolation is off. So we actually want the `Skeleton3D` itself to be interpolated, but the bones to be non-interpolated. There's no easy shortcut for this in the inspector, which is why I've done it in a script above (turned off interpolation in the `Skeleton3D` and hence all its bone children, and manually interpolated the `Skeleton3D` itself).

I'll look at a PR to do this automagically - I'll probably look at this after the weekend as there are some deeper considerations as `Skeleton3D` is shared for a number of different areas - including animation, IK and ragdoll .. so needs to work well with all (and modifications for one can cause regressions in another).

_As an aside, it is also possible theoretically to run the IK totally on the physics tick, but because interpolation is approximate, that means on the in betweener frames the IK will not line up exactly with the target, and it will appear as subtle jitter, which is why I thought it better to standardize on running the IK on the frame. Particle systems are also a similar exception, where it is often better to run them without physics interpolation._ 
Here's the end result:

https://github.com/user-attachments/assets/9e193205-7a80-46de-ab52-c4dce5b71f5f
Thank you for this!
Btw, I have a similar problem with a Skeleton2D. Unfortunately, there is no `get_global_transform_interpolated()` in 2D, so the trick above doesn't apply.
> Btw, I have a similar problem with a Skeleton2D. Unfortunately, there is no `get_global_transform_interpolated()` in 2D, so the trick above doesn't apply.

2D will need its own issue .. it's related, but physics interpolation works very differently in 2D, transform traversal is done server side in 2D, in 3D it is done client side in the scene tree and only global xforms are uploaded to the server.

Fix patch:
diff --git a/core/local_vector.h b/core/local_vector.h
index fe14dbb494ef..5dfe273c40c7 100644
--- a/core/local_vector.h
+++ b/core/local_vector.h
@@ -104,6 +104,15 @@ class LocalVector {
 		}
 	}
 
+	bool erase_unordered(const T &p_val) {
+		int64_t idx = find(p_val);
+		if (idx >= 0) {
+			remove_unordered(idx);
+			return true;
+		}
+		return false;
+	}
+
 	U erase_multiple_unordered(const T &p_val) {
 		U from = 0;
 		U count = 0;
diff --git a/doc/classes/BoneAttachment.xml b/doc/classes/BoneAttachment.xml
index 8d427fbf8eb3..703a231b22d9 100644
--- a/doc/classes/BoneAttachment.xml
+++ b/doc/classes/BoneAttachment.xml
@@ -14,6 +14,7 @@
 		<member name="bone_name" type="String" setter="set_bone_name" getter="get_bone_name" default="&quot;&quot;">
 			The name of the attached bone.
 		</member>
+		<member name="physics_interpolation_mode" type="int" setter="set_physics_interpolation_mode" getter="get_physics_interpolation_mode" overrides="Node" enum="Node.PhysicsInterpolationMode" default="1" />
 	</members>
 	<constants>
 	</constants>
diff --git a/doc/classes/VehicleWheel.xml b/doc/classes/VehicleWheel.xml
index c51542abe72c..67a80e0bf832 100644
--- a/doc/classes/VehicleWheel.xml
+++ b/doc/classes/VehicleWheel.xml
@@ -52,6 +52,7 @@
 			[b]Note:[/b] The simulation does not take the effect of gears into account, you will need to add logic for this if you wish to simulate gears.
 			A negative value will result in the wheel reversing.
 		</member>
+		<member name="physics_interpolation_mode" type="int" setter="set_physics_interpolation_mode" getter="get_physics_interpolation_mode" overrides="Node" enum="Node.PhysicsInterpolationMode" default="1" />
 		<member name="steering" type="float" setter="set_steering" getter="get_steering" default="0.0">
 			The steering angle for the wheel. Setting this to a non-zero value will result in the vehicle turning when it's moving.
 		</member>
diff --git a/doc/classes/VisualServer.xml b/doc/classes/VisualServer.xml
index 027a4a1c2413..042fe81ba650 100644
--- a/doc/classes/VisualServer.xml
+++ b/doc/classes/VisualServer.xml
@@ -1488,14 +1488,6 @@
 				Sets a material that will override the material for all surfaces on the mesh associated with this instance. Equivalent to [member GeometryInstance.material_override].
 			</description>
 		</method>
-		<method name="instance_reset_physics_interpolation">
-			<return type="void" />
-			<argument index="0" name="instance" type="RID" />
-			<description>
-				Prevents physics interpolation for the current physics tick.
-				This is useful when moving an instance to a new location, to give an instantaneous change rather than interpolation from the previous location.
-			</description>
-		</method>
 		<method name="instance_set_base">
 			<return type="void" />
 			<argument index="0" name="instance" type="RID" />
@@ -1537,14 +1529,6 @@
 				Sets a margin to increase the size of the AABB when culling objects from the view frustum. This allows you to avoid culling objects that fall outside the view frustum. Equivalent to [member GeometryInstance.extra_cull_margin].
 			</description>
 		</method>
-		<method name="instance_set_interpolated">
-			<return type="void" />
-			<argument index="0" name="instance" type="RID" />
-			<argument index="1" name="interpolated" type="bool" />
-			<description>
-				Turns on and off physics interpolation for the instance.
-			</description>
-		</method>
 		<method name="instance_set_layer_mask">
 			<return type="void" />
 			<argument index="0" name="instance" type="RID" />
diff --git a/scene/3d/arvr_nodes.cpp b/scene/3d/arvr_nodes.cpp
index 7b5e96759bf6..a68360fb6d8e 100644
--- a/scene/3d/arvr_nodes.cpp
+++ b/scene/3d/arvr_nodes.cpp
@@ -613,7 +613,7 @@ void ARVROrigin::_notification(int p_what) {
 		}; break;
 		case NOTIFICATION_INTERNAL_PROCESS: {
 			// set our world origin to our node transform
-			arvr_server->set_world_origin(get_global_transform());
+			arvr_server->set_world_origin(get_global_transform_interpolated());
 
 			// check if we have a primary interface
 			Ref<ARVRInterface> arvr_interface = arvr_server->get_primary_interface();
diff --git a/scene/3d/bone_attachment.cpp b/scene/3d/bone_attachment.cpp
index fc9f67abd526..5b2f4cd25492 100644
--- a/scene/3d/bone_attachment.cpp
+++ b/scene/3d/bone_attachment.cpp
@@ -105,6 +105,7 @@ void BoneAttachment::_notification(int p_what) {
 }
 
 BoneAttachment::BoneAttachment() {
+	set_physics_interpolation_mode(PHYSICS_INTERPOLATION_MODE_OFF);
 	bound = false;
 }
 
diff --git a/scene/3d/camera.cpp b/scene/3d/camera.cpp
index 8645e8567ee4..5b59a6785139 100644
--- a/scene/3d/camera.cpp
+++ b/scene/3d/camera.cpp
@@ -45,6 +45,13 @@ void Camera::_request_camera_update() {
 	_update_camera();
 }
 
+void Camera::fti_update_servers() {
+	if (camera.is_valid()) {
+		Transform tr = _get_adjusted_camera_transform(_get_cached_global_transform_interpolated());
+		VisualServer::get_singleton()->camera_set_transform(camera, tr);
+	}
+}
+
 void Camera::_update_camera_mode() {
 	force_change = true;
 	switch (mode) {
@@ -85,12 +92,8 @@ void Camera::_update_camera() {
 	if (!is_physics_interpolated_and_enabled()) {
 		VisualServer::get_singleton()->camera_set_transform(camera, get_camera_transform());
 	} else {
-		// Ideally we shouldn't be moving a physics interpolated camera within a frame,
-		// because it will break smooth interpolation, but it may occur on e.g. level load.
-		if (!Engine::get_singleton()->is_in_physics_frame() && camera.is_valid()) {
-			_physics_interpolation_ensure_transform_calculated(true);
-			VisualServer::get_singleton()->camera_set_transform(camera, _interpolation_data.camera_xform_interpolated);
-		}
+		// Force a refresh next frame.
+		fti_notify_node_changed();
 	}
 
 	// here goes listener stuff
@@ -114,40 +117,6 @@ void Camera::_physics_interpolated_changed() {
 	_update_process_mode();
 }
 
-void Camera::_physics_interpolation_ensure_data_flipped() {
-	// The curr -> previous update can either occur
-	// on the INTERNAL_PHYSICS_PROCESS OR
-	// on NOTIFICATION_TRANSFORM_CHANGED,
-	// if NOTIFICATION_TRANSFORM_CHANGED takes place
-	// earlier than INTERNAL_PHYSICS_PROCESS on a tick.
-	// This is to ensure that the data keeps flowing, but the new data
-	// doesn't overwrite before prev has been set.
-
-	// Keep the data flowing.
-	uint64_t tick = Engine::get_singleton()->get_physics_frames();
-	if (_interpolation_data.last_update_physics_tick != tick) {
-		_interpolation_data.xform_prev = _interpolation_data.xform_curr;
-		_interpolation_data.last_update_physics_tick = tick;
-		physics_interpolation_flip_data();
-	}
-}
-
-void Camera::_physics_interpolation_ensure_transform_calculated(bool p_force) const {
-	DEV_CHECK_ONCE(!Engine::get_singleton()->is_in_physics_frame());
-
-	InterpolationData &id = _interpolation_data;
-	uint64_t frame = Engine::get_singleton()->get_frames_drawn();
-
-	if (id.last_update_frame != frame || p_force) {
-		id.last_update_frame = frame;
-
-		TransformInterpolator::interpolate_transform(id.xform_prev, id.xform_curr, id.xform_interpolated, Engine::get_singleton()->get_physics_interpolation_fraction());
-
-		Transform &tr = id.camera_xform_interpolated;
-		tr = _get_adjusted_camera_transform(id.xform_interpolated);
-	}
-}
-
 void Camera::set_desired_process_modes(bool p_process_internal, bool p_physics_process_internal) {
 	_desired_process_internal = p_process_internal;
 	_desired_physics_process_internal = p_physics_process_internal;
@@ -155,17 +124,8 @@ void Camera::set_desired_process_modes(bool p_process_internal, bool p_physics_p
 }
 
 void Camera::_update_process_mode() {
-	bool process = _desired_process_internal;
-	bool physics_process = _desired_physics_process_internal;
-
-	if (is_physics_interpolated_and_enabled()) {
-		if (is_current()) {
-			process = true;
-			physics_process = true;
-		}
-	}
-	set_process_internal(process);
-	set_physics_process_internal(physics_process);
+	set_process_internal(_desired_process_internal);
+	set_physics_process_internal(_desired_physics_process_internal);
 }
 
 void Camera::_notification(int p_what) {
@@ -182,56 +142,21 @@ void Camera::_notification(int p_what) {
 				viewport->_camera_set(this);
 			}
 		} break;
-		case NOTIFICATION_INTERNAL_PROCESS: {
-			if (is_physics_interpolated_and_enabled() && camera.is_valid()) {
-				_physics_interpolation_ensure_transform_calculated();
-
-#ifdef VISUAL_SERVER_DEBUG_PHYSICS_INTERPOLATION
-				print_line("\t\tinterpolated Camera: " + rtos(_interpolation_data.xform_interpolated.origin.x) + "\t( prev " + rtos(_interpolation_data.xform_prev.origin.x) + ", curr " + rtos(_interpolation_data.xform_curr.origin.x) + " ) on tick " + itos(Engine::get_singleton()->get_physics_frames()));
-#endif
-
-				VisualServer::get_singleton()->camera_set_transform(camera, _interpolation_data.camera_xform_interpolated);
-			}
-		} break;
-		case NOTIFICATION_INTERNAL_PHYSICS_PROCESS: {
-			if (is_physics_interpolated_and_enabled()) {
-				_physics_interpolation_ensure_data_flipped();
-				_interpolation_data.xform_curr = get_global_transform();
-			}
-		} break;
 		case NOTIFICATION_TRANSFORM_CHANGED: {
-			if (is_physics_interpolated_and_enabled()) {
-				_physics_interpolation_ensure_data_flipped();
-				_interpolation_data.xform_curr = get_global_transform();
 #if defined(DEBUG_ENABLED) && defined(TOOLS_ENABLED)
+			if (is_physics_interpolated_and_enabled()) {
 				if (!Engine::get_singleton()->is_in_physics_frame()) {
 					PHYSICS_INTERPOLATION_NODE_WARNING(get_instance_id(), "Interpolated Camera triggered from outside physics process");
 				}
-#endif
 			}
+#endif
 			_request_camera_update();
 			if (doppler_tracking != DOPPLER_TRACKING_DISABLED) {
 				velocity_tracker->update_position(get_global_transform().origin);
 			}
-			// Allow auto-reset when first adding to the tree, as a convenience.
-			if (_is_physics_interpolation_reset_requested() && is_inside_tree()) {
-				_notification(NOTIFICATION_RESET_PHYSICS_INTERPOLATION);
-				_set_physics_interpolation_reset_requested(false);
-			}
-
 		} break;
 		case NOTIFICATION_RESET_PHYSICS_INTERPOLATION: {
-			if (is_inside_tree()) {
-				_interpolation_data.xform_curr = get_global_transform();
-				_interpolation_data.xform_prev = _interpolation_data.xform_curr;
-				_update_process_mode();
-			}
-		} break;
-		case NOTIFICATION_PAUSED: {
-			if (is_physics_interpolated_and_enabled() && is_inside_tree() && is_visible_in_tree()) {
-				_physics_interpolation_ensure_transform_calculated(true);
-				VisualServer::get_singleton()->camera_set_transform(camera, _interpolation_data.camera_xform_interpolated);
-			}
+			_update_process_mode();
 		} break;
 		case NOTIFICATION_EXIT_WORLD: {
 			if (!get_tree()->is_node_being_edited(this)) {
@@ -274,8 +199,7 @@ Transform Camera::_get_adjusted_camera_transform(const Transform &p_xform) const
 
 Transform Camera::get_camera_transform() const {
 	if (is_physics_interpolated_and_enabled() && !Engine::get_singleton()->is_in_physics_frame()) {
-		_physics_interpolation_ensure_transform_calculated();
-		return _interpolation_data.camera_xform_interpolated;
+		return _get_adjusted_camera_transform(_get_cached_global_transform_interpolated());
 	}
 
 	return _get_adjusted_camera_transform(get_global_transform());
@@ -865,9 +789,16 @@ float ClippedCamera::get_margin() const {
 	return margin;
 }
 void ClippedCamera::set_process_mode(ProcessMode p_mode) {
-	if (is_physics_interpolated_and_enabled() && p_mode == CLIP_PROCESS_IDLE) {
-		p_mode = CLIP_PROCESS_PHYSICS;
-		WARN_PRINT_ONCE("[Physics interpolation] Forcing ClippedCamera to PROCESS_PHYSICS mode.");
+	if (is_physics_interpolated_and_enabled()) {
+		if (p_mode == CLIP_PROCESS_IDLE) {
+			p_mode = CLIP_PROCESS_PHYSICS;
+			WARN_PRINT_ONCE("[Physics interpolation] Forcing ClippedCamera to PROCESS_PHYSICS mode.");
+		}
+
+		process_mode = p_mode;
+
+		set_desired_process_modes(false, true);
+		return;
 	}
 
 	if (process_mode == p_mode) {
@@ -881,8 +812,11 @@ ClippedCamera::ProcessMode ClippedCamera::get_process_mode() const {
 	return process_mode;
 }
 
-void ClippedCamera::physics_interpolation_flip_data() {
+void ClippedCamera::fti_pump() {
 	_interpolation_data.clip_offset_prev = _interpolation_data.clip_offset_curr;
+
+	// Must call the base class.
+	Spatial::fti_pump();
 }
 
 void ClippedCamera::_physics_interpolated_changed() {
@@ -899,6 +833,11 @@ Transform ClippedCamera::_get_adjusted_camera_transform(const Transform &p_xform
 	return t;
 }
 
+void ClippedCamera::fti_update_servers() {
+	clip_offset = ((_interpolation_data.clip_offset_curr - _interpolation_data.clip_offset_prev) * Engine::get_singleton()->get_physics_interpolation_fraction()) + _interpolation_data.clip_offset_prev;
+	Camera::fti_update_servers();
+}
+
 void ClippedCamera::_notification(int p_what) {
 	if (p_what == NOTIFICATION_ENTER_TREE) {
 		// Switch process mode to physics if we are turning on interpolation.
@@ -966,10 +905,6 @@ void ClippedCamera::_notification(int p_what) {
 		_update_camera();
 	}
 
-	if (is_physics_interpolated_and_enabled() && (p_what == NOTIFICATION_INTERNAL_PROCESS)) {
-		clip_offset = ((_interpolation_data.clip_offset_curr - _interpolation_data.clip_offset_prev) * Engine::get_singleton()->get_physics_interpolation_fraction()) + _interpolation_data.clip_offset_prev;
-	}
-
 	if (p_what == NOTIFICATION_LOCAL_TRANSFORM_CHANGED) {
 		update_gizmo();
 	}
diff --git a/scene/3d/camera.h b/scene/3d/camera.h
index afa95b046bbe..72fdb2641d05 100644
--- a/scene/3d/camera.h
+++ b/scene/3d/camera.h
@@ -91,26 +91,10 @@ class Camera : public Spatial {
 	Ref<SpatialVelocityTracker> velocity_tracker;
 	bool affect_lod = true;
 
-	///////////////////////////////////////////////////////
-	// INTERPOLATION FUNCTIONS
-	void _physics_interpolation_ensure_transform_calculated(bool p_force = false) const;
-	void _physics_interpolation_ensure_data_flipped();
-
-	// These can be set by derived Cameras,
-	// if they wish to do processing (while still
-	// allowing physics interpolation to function).
+	// These can be set by derived Cameras.
 	bool _desired_process_internal = false;
 	bool _desired_physics_process_internal = false;
 
-	mutable struct InterpolationData {
-		Transform xform_curr;
-		Transform xform_prev;
-		Transform xform_interpolated;
-		Transform camera_xform_interpolated; // After modification according to camera type.
-		uint32_t last_update_physics_tick = 0;
-		uint32_t last_update_frame = UINT32_MAX;
-	} _interpolation_data;
-
 	void _update_process_mode();
 
 protected:
@@ -118,9 +102,6 @@ class Camera : public Spatial {
 	// This is because physics interpolation may need to request process modes additionally.
 	void set_desired_process_modes(bool p_process_internal, bool p_physics_process_internal);
 
-	// Opportunity for derived classes to interpolate extra attributes.
-	virtual void physics_interpolation_flip_data() {}
-
 	virtual void _physics_interpolated_changed();
 	virtual Transform _get_adjusted_camera_transform(const Transform &p_xform) const;
 	///////////////////////////////////////////////////////
@@ -128,6 +109,7 @@ class Camera : public Spatial {
 	void _update_camera();
 	virtual void _request_camera_update();
 	void _update_camera_mode();
+	virtual void fti_update_servers();
 
 	void _notification(int p_what);
 	virtual void _validate_property(PropertyInfo &p_property) const;
@@ -248,8 +230,9 @@ class ClippedCamera : public Camera {
 
 protected:
 	virtual Transform _get_adjusted_camera_transform(const Transform &p_xform) const;
-	virtual void physics_interpolation_flip_data();
+	virtual void fti_pump();
 	virtual void _physics_interpolated_changed();
+	virtual void fti_update_servers();
 	///////////////////////////////////////////////////////
 
 	void _notification(int p_what);
diff --git a/scene/3d/spatial.cpp b/scene/3d/spatial.cpp
index cac9673bfa06..394f334adc48 100644
--- a/scene/3d/spatial.cpp
+++ b/scene/3d/spatial.cpp
@@ -118,7 +118,7 @@ void Spatial::_propagate_transform_changed(Spatial *p_origin) {
 #endif
 		get_tree()->xform_change_list.add(&xform_change);
 	}
-	data.dirty |= DIRTY_GLOBAL;
+	data.dirty |= DIRTY_GLOBAL | DIRTY_GLOBAL_INTERPOLATED;
 
 	data.children_lock--;
 }
@@ -174,13 +174,27 @@ void Spatial::_notification(int p_what) {
 				_propagate_merging_allowed(merging_allowed);
 			}
 
-			data.dirty |= DIRTY_GLOBAL; //global is always dirty upon entering a scene
+			data.dirty |= DIRTY_GLOBAL | DIRTY_GLOBAL_INTERPOLATED; //global is always dirty upon entering a scene
 			_notify_dirty();
 
 			notification(NOTIFICATION_ENTER_WORLD);
 
+			if (is_physics_interpolated_and_enabled()) {
+				// Always reset FTI when entering tree.
+				fti_pump();
+
+				// No need to interpolate as we are doing a reset.
+				data.global_transform_interpolated = get_global_transform();
+
+				// Make sure servers are up to date.
+				fti_update_servers();
+			}
 		} break;
 		case NOTIFICATION_EXIT_TREE: {
+			if (is_inside_tree()) {
+				get_tree()->get_scene_tree_fti().spatial_notify_delete(this);
+			}
+
 			notification(NOTIFICATION_EXIT_WORLD, true);
 			if (xform_change.in_list()) {
 				get_tree()->xform_change_list.remove(&xform_change);
@@ -252,6 +266,13 @@ void Spatial::_notification(int p_what) {
 			if (data.client_physics_interpolation_data) {
 				data.client_physics_interpolation_data->global_xform_prev = data.client_physics_interpolation_data->global_xform_curr;
 			}
+			data.local_transform_prev = data.local_transform;
+		} break;
+
+		case NOTIFICATION_PAUSED: {
+			if (is_physics_interpolated_and_enabled()) {
+				data.local_transform_prev = data.local_transform;
+			}
 		} break;
 
 		default: {
@@ -281,7 +302,22 @@ void Spatial::set_global_rotation(const Vector3 &p_euler_rad) {
 	set_global_transform(transform);
 }
 
+void Spatial::fti_pump() {
+	if (data.dirty & DIRTY_LOCAL) {
+		_update_local_transform();
+	}
+
+	data.local_transform_prev = data.local_transform;
+}
+
+void Spatial::fti_notify_node_changed() {
+	if (is_inside_tree()) {
+		get_tree()->get_scene_tree_fti().spatial_notify_set_transform(*this);
+	}
+}
+
 void Spatial::set_transform(const Transform &p_transform) {
+	fti_notify_node_changed();
 	data.local_transform = p_transform;
 	data.dirty |= DIRTY_VECTORS;
 	data.dirty &= ~DIRTY_LOCAL;
@@ -404,14 +440,19 @@ Transform Spatial::_get_global_transform_interpolated(real_t p_interpolation_fra
 }
 
 Transform Spatial::get_global_transform_interpolated() {
+#if 1
 	// Pass through if physics interpolation is switched off.
 	// This is a convenience, as it allows you to easy turn off interpolation
 	// without changing any code.
-	if (!is_physics_interpolated_and_enabled()) {
-		return get_global_transform();
+	if (data.fti_global_xform_interp_set && is_physics_interpolated_and_enabled() && !Engine::get_singleton()->is_in_physics_frame() && is_visible_in_tree()) {
+		return data.global_transform_interpolated;
 	}
 
-	// If we are in the physics frame, the interpolated global transform is meaningless.
+	return get_global_transform();
+#else
+	// OLD METHOD - deprecated since moving to SceneTreeFTI,
+	// but leaving for reference and comparison for debugging.
+
 	// However, there is an exception, we may want to use this as a means of starting off the client
 	// interpolation pump if not already started (when _is_physics_interpolated_client_side() is false).
 	if (Engine::get_singleton()->is_in_physics_frame() && _is_physics_interpolated_client_side()) {
@@ -419,6 +460,7 @@ Transform Spatial::get_global_transform_interpolated() {
 	}
 
 	return _get_global_transform_interpolated(Engine::get_singleton()->get_physics_interpolation_fraction());
+#endif
 }
 
 Transform Spatial::get_global_transform() const {
@@ -484,6 +526,7 @@ Transform Spatial::get_relative_transform(const Node *p_parent) const {
 }
 
 void Spatial::set_translation(const Vector3 &p_translation) {
+	fti_notify_node_changed();
 	data.local_transform.origin = p_translation;
 	_change_notify("transform");
 	_propagate_transform_changed(this);
@@ -493,6 +536,7 @@ void Spatial::set_translation(const Vector3 &p_translation) {
 }
 
 void Spatial::set_rotation(const Vector3 &p_euler_rad) {
+	fti_notify_node_changed();
 	if (data.dirty & DIRTY_VECTORS) {
 		data.scale = data.local_transform.basis.get_scale();
 		data.dirty &= ~DIRTY_VECTORS;
@@ -512,6 +556,7 @@ void Spatial::set_rotation_degrees(const Vector3 &p_euler_deg) {
 }
 
 void Spatial::set_scale(const Vector3 &p_scale) {
+	fti_notify_node_changed();
 	if (data.dirty & DIRTY_VECTORS) {
 		data.rotation = data.local_transform.basis.get_rotation();
 		data.dirty &= ~DIRTY_VECTORS;
@@ -1061,6 +1106,11 @@ Spatial::Spatial() :
 	data.disable_scale = false;
 	data.vi_visible = true;
 	data.merging_allowed = true;
+
+	data.fti_on_frame_list = false;
+	data.fti_on_tick_list = false;
+	data.fti_global_xform_interp_set = false;
+
 	data.merging_mode = MERGING_MODE_INHERIT;
 
 	data.client_physics_interpolation_data = nullptr;
@@ -1077,4 +1127,8 @@ Spatial::Spatial() :
 
 Spatial::~Spatial() {
 	_disable_client_physics_interpolation();
+
+	if (is_inside_tree()) {
+		get_tree()->get_scene_tree_fti().spatial_notify_delete(this);
+	}
 }
diff --git a/scene/3d/spatial.h b/scene/3d/spatial.h
index 9c80044919eb..53c595ee3f3a 100644
--- a/scene/3d/spatial.h
+++ b/scene/3d/spatial.h
@@ -52,6 +52,8 @@ class Spatial : public Node {
 	GDCLASS(Spatial, Node);
 	OBJ_CATEGORY("3D");
 
+	friend class SceneTreeFTI;
+
 public:
 	enum MergingMode : unsigned int {
 		MERGING_MODE_INHERIT,
@@ -74,15 +76,27 @@ class Spatial : public Node {
 		DIRTY_NONE = 0,
 		DIRTY_VECTORS = 1,
 		DIRTY_LOCAL = 2,
-		DIRTY_GLOBAL = 4
+		DIRTY_GLOBAL = 4,
+		DIRTY_GLOBAL_INTERPOLATED = 8,
 	};
 
 	mutable SelfList<Node> xform_change;
 	SelfList<Spatial> _client_physics_interpolation_spatials_list;
 
 	struct Data {
+		// Interpolated global transform - correct on the frame only.
+		// Only used with FTI.
+		Transform global_transform_interpolated;
+
+		// Current xforms are either
+		// * Used for everything (when not using FTI)
+		// * Correct on the physics tick (when using FTI)
 		mutable Transform global_transform;
 		mutable Transform local_transform;
+
+		// Only used with FTI.
+		Transform local_transform_prev;
+
 		mutable Vector3 rotation;
 		mutable Vector3 scale;
 
@@ -108,6 +122,11 @@ class Spatial : public Node {
 		bool visible : 1;
 		bool disable_scale : 1;
 
+		// Scene tree interpolation
+		bool fti_on_frame_list : 1;
+		bool fti_on_tick_list : 1;
+		bool fti_global_xform_interp_set : 1;
+
 		bool merging_allowed : 1;
 
 		int children_lock;
@@ -139,9 +158,27 @@ class Spatial : public Node {
 
 	void _set_vi_visible(bool p_visible);
 	bool _is_vi_visible() const { return data.vi_visible; }
+
 	Transform _get_global_transform_interpolated(real_t p_interpolation_fraction);
+	const Transform &_get_cached_global_transform_interpolated() const { return data.global_transform_interpolated; }
 	void _disable_client_physics_interpolation();
 
+	// Calling this announces to the FTI system that a node has been moved,
+	// or requires an update in terms of interpolation
+	// (e.g. changing Camera zoom even if position hasn't changed).
+	void fti_notify_node_changed();
+
+	// Opportunity after FTI to update the servers
+	// with global_transform_interpolated,
+	// and any custom interpolated data in derived classes.
+	// Make sure to call the parent class fti_update_servers(),
+	// so all data is updated to the servers.
+	virtual void fti_update_servers() {}
+
+	// Pump the FTI data, also gives a chance for inherited classes
+	// to pump custom data, but they *must* call the base class here too.
+	virtual void fti_pump();
+
 	void _notification(int p_what);
 	static void _bind_methods();
 
diff --git a/scene/3d/vehicle_body.cpp b/scene/3d/vehicle_body.cpp
index 8231e4a6daf8..724867a0bcc1 100644
--- a/scene/3d/vehicle_body.cpp
+++ b/scene/3d/vehicle_body.cpp
@@ -44,7 +44,7 @@ class btVehicleJacobianEntry {
 
 	real_t getDiagonal() const { return m_Adiag; }
 
-	btVehicleJacobianEntry(){};
+	btVehicleJacobianEntry() {}
 	//constraint between two different rigidbodies
 	btVehicleJacobianEntry(
 			const Basis &world2A,
@@ -366,6 +366,8 @@ VehicleWheel::VehicleWheel() {
 	m_raycastInfo.m_suspensionLength = 0.0;
 
 	body = nullptr;
+
+	set_physics_interpolation_mode(PHYSICS_INTERPOLATION_MODE_OFF);
 }
 
 void VehicleBody::_update_wheel_transform(VehicleWheel &wheel, PhysicsDirectBodyState *s) {
diff --git a/scene/3d/visual_instance.cpp b/scene/3d/visual_instance.cpp
index cf80a6742dd7..05d7414b9698 100644
--- a/scene/3d/visual_instance.cpp
+++ b/scene/3d/visual_instance.cpp
@@ -82,6 +82,12 @@ void VisualInstance::set_instance_use_identity_transform(bool p_enable) {
 	}
 }
 
+void VisualInstance::fti_update_servers() {
+	if (!_is_using_identity_transform()) {
+		VisualServer::get_singleton()->instance_set_transform(get_instance(), _get_cached_global_transform_interpolated());
+	}
+}
+
 void VisualInstance::_notification(int p_what) {
 	switch (p_what) {
 		case NOTIFICATION_ENTER_WORLD: {
@@ -97,33 +103,26 @@ void VisualInstance::_notification(int p_what) {
 
 		} break;
 		case NOTIFICATION_TRANSFORM_CHANGED: {
-			if (_is_vi_visible() || is_physics_interpolated_and_enabled()) {
+			if (_is_vi_visible()) {
 				if (!_is_using_identity_transform()) {
-					VisualServer::get_singleton()->instance_set_transform(instance, get_global_transform());
-
-					// For instance when first adding to the tree, when the previous transform is
-					// unset, to prevent streaking from the origin.
-					if (_is_physics_interpolation_reset_requested() && is_physics_interpolated_and_enabled() && is_inside_tree()) {
-						if (_is_vi_visible()) {
-							_notification(NOTIFICATION_RESET_PHYSICS_INTERPOLATION);
+					// Physics interpolated VIs don't need to send their transform immediately after setting,
+					// indeed it is counterproductive, because the interpolated transform will be sent
+					// to the VisualServer immediately prior to rendering.
+					if (!is_physics_interpolated_and_enabled()) {
+						VisualServer::get_singleton()->instance_set_transform(instance, get_global_transform());
+					} else {
+						// For instance when first adding to the tree, when the previous transform is
+						// unset, to prevent streaking from the origin.
+						if (_is_physics_interpolation_reset_requested() && is_inside_tree()) {
+							if (_is_vi_visible()) {
+								_notification(NOTIFICATION_RESET_PHYSICS_INTERPOLATION);
+							}
+							_set_physics_interpolation_reset_requested(false);
 						}
-						_set_physics_interpolation_reset_requested(false);
 					}
 				}
 			}
 		} break;
-		case NOTIFICATION_RESET_PHYSICS_INTERPOLATION: {
-			if (_is_vi_visible() && is_physics_interpolated() && is_inside_tree()) {
-				// We must ensure the VisualServer transform is up to date before resetting.
-				// This is because NOTIFICATION_TRANSFORM_CHANGED is deferred,
-				// and cannot be relied to be called in order before NOTIFICATION_RESET_PHYSICS_INTERPOLATION.
-				if (!_is_using_identity_transform()) {
-					VisualServer::get_singleton()->instance_set_transform(instance, get_global_transform());
-				}
-
-				VisualServer::get_singleton()->instance_reset_physics_interpolation(instance);
-			}
-		} break;
 		case NOTIFICATION_EXIT_WORLD: {
 			VisualServer::get_singleton()->instance_set_scenario(instance, RID());
 			VisualServer::get_singleton()->instance_attach_skeleton(instance, RID());
@@ -140,10 +139,6 @@ void VisualInstance::_notification(int p_what) {
 	}
 }
 
-void VisualInstance::_physics_interpolated_changed() {
-	VisualServer::get_singleton()->instance_set_interpolated(instance, is_physics_interpolated());
-}
-
 RID VisualInstance::get_instance() const {
 	return instance;
 }
diff --git a/scene/3d/visual_instance.h b/scene/3d/visual_instance.h
index 0b605ed15c72..ce8164bff25b 100644
--- a/scene/3d/visual_instance.h
+++ b/scene/3d/visual_instance.h
@@ -51,8 +51,8 @@ class VisualInstance : public CullInstance {
 protected:
 	void _update_visibility();
 	virtual void _refresh_portal_mode();
-	virtual void _physics_interpolated_changed();
 	void set_instance_use_identity_transform(bool p_enable);
+	virtual void fti_update_servers();
 
 	void _notification(int p_what);
 	static void _bind_methods();
diff --git a/scene/animation/skeleton_ik.cpp b/scene/animation/skeleton_ik.cpp
index 22561d1d7be8..8fed9677ae51 100644
--- a/scene/animation/skeleton_ik.cpp
+++ b/scene/animation/skeleton_ik.cpp
@@ -276,7 +276,7 @@ void FabrikInverseKinematic::solve(Task *p_task, real_t blending_delta, bool ove
 	p_task->skeleton->set_bone_global_pose_override(p_task->chain.chain_root.bone, Transform(), 0.0, false);
 	Vector3 origin_pos = p_task->skeleton->get_bone_global_pose(p_task->chain.chain_root.bone).origin;
 
-	make_goal(p_task, p_task->skeleton->get_global_transform().affine_inverse(), blending_delta);
+	make_goal(p_task, p_task->skeleton->get_global_transform_interpolated().affine_inverse(), blending_delta);
 
 	if (p_use_magnet && p_task->chain.middle_chain_item) {
 		p_task->chain.magnet_position = p_task->chain.middle_chain_item->initial_transform.origin.linear_interpolate(p_magnet_position, blending_delta);
diff --git a/scene/main/scene_tree.cpp b/scene/main/scene_tree.cpp
index 6a7f4b4eb55c..33a828e41757 100644
--- a/scene/main/scene_tree.cpp
+++ b/scene/main/scene_tree.cpp
@@ -538,6 +538,8 @@ void SceneTree::set_physics_interpolation_enabled(bool p_enabled) {
 	_physics_interpolation_enabled = p_enabled;
 	VisualServer::get_singleton()->set_physics_interpolation_enabled(p_enabled);
 
+	get_scene_tree_fti().set_enabled(get_root(), p_enabled);
+
 	// Perform an auto reset on the root node for convenience for the user.
 	if (root) {
 		root->reset_physics_interpolation();
@@ -563,6 +565,7 @@ void SceneTree::iteration_prepare() {
 		// Make sure any pending transforms from the last tick / frame
 		// are flushed before pumping the interpolation prev and currents.
 		flush_transform_notifications();
+		get_scene_tree_fti().tick_update();
 		VisualServer::get_singleton()->tick();
 	}
 }
@@ -628,6 +631,17 @@ bool SceneTree::idle(float p_time) {
 	//print_line("node count: "+itos(get_node_count()));
 	//print_line("TEXTURE RAM: "+itos(VS::get_singleton()->get_render_info(VS::INFO_TEXTURE_MEM_USED)));
 
+	// First pass of scene tree fixed timestep interpolation.
+	if (get_scene_tree_fti().is_enabled()) {
+		// Special, we need to ensure RenderingServer is up to date
+		// with *all* the pending xforms *before* updating it during
+		// the FTI update.
+		// If this is not done, we can end up with a deferred `set_transform()`
+		// overwriting the interpolated xform in the server.
+		flush_transform_notifications();
+		get_scene_tree_fti().frame_update(get_root(), true);
+	}
+
 	root_lock++;
 
 	if (MainLoop::idle(p_time)) {
@@ -733,6 +747,11 @@ bool SceneTree::idle(float p_time) {
 
 #endif
 
+	// Second pass of scene tree fixed timestep interpolation.
+	// ToDo: Possibly needs another flush_transform_notifications here
+	// depending on whether there are side effects to _call_idle_callbacks().
+	get_scene_tree_fti().frame_update(get_root(), false);
+
 	VisualServer::get_singleton()->pre_draw(true);
 
 	return _quit;
diff --git a/scene/main/scene_tree.h b/scene/main/scene_tree.h
index 292cdf23ec28..b8ab1ed4b3fc 100644
--- a/scene/main/scene_tree.h
+++ b/scene/main/scene_tree.h
@@ -35,6 +35,7 @@
 #include "core/os/main_loop.h"
 #include "core/os/thread_safe.h"
 #include "core/self_list.h"
+#include "scene/main/scene_tree_fti.h"
 #include "scene/resources/mesh.h"
 #include "scene/resources/world.h"
 #include "scene/resources/world_2d.h"
@@ -160,6 +161,7 @@ class SceneTree : public MainLoop {
 	StretchAspect stretch_aspect;
 	Size2i stretch_min;
 	real_t stretch_scale;
+	SceneTreeFTI scene_tree_fti;
 
 	void _update_font_oversampling(float p_ratio);
 	void _update_root_rect();
@@ -438,6 +440,8 @@ class SceneTree : public MainLoop {
 	void client_physics_interpolation_add_spatial(SelfList<Spatial> *p_elem);
 	void client_physics_interpolation_remove_spatial(SelfList<Spatial> *p_elem);
 
+	SceneTreeFTI &get_scene_tree_fti() { return scene_tree_fti; }
+
 	static void add_idle_callback(IdleCallback p_callback);
 	SceneTree();
 	~SceneTree();
diff --git a/scene/main/scene_tree_fti.cpp b/scene/main/scene_tree_fti.cpp
new file mode 100644
index 000000000000..f9e3ef26fbd1
--- /dev/null
+++ b/scene/main/scene_tree_fti.cpp
@@ -0,0 +1,288 @@
+/**************************************************************************/
+/*  scene_tree_fti.cpp                                                    */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#ifndef _3D_DISABLED
+
+#include "scene_tree_fti.h"
+#include "core/engine.h"
+#include "core/error_macros.h"
+#include "core/math/transform_interpolator.h"
+#include "core/os/os.h"
+#include "scene/3d/spatial.h"
+#include "scene/3d/visual_instance.h"
+
+void SceneTreeFTI::_reset_flags(Node *p_node) {
+	Spatial *s = Object::cast_to<Spatial>(p_node);
+
+	if (s) {
+		s->data.fti_on_frame_list = false;
+		s->data.fti_on_tick_list = false;
+
+		// In most cases the later  NOTIFICATION_RESET_PHYSICS_INTERPOLATION
+		// will reset this, but this should help cover hidden nodes.
+		s->data.local_transform_prev = s->data.local_transform;
+	}
+
+	for (int n = 0; n < p_node->get_child_count(); n++) {
+		_reset_flags(p_node->get_child(n));
+	}
+}
+
+void SceneTreeFTI::set_enabled(Node *p_root, bool p_enabled) {
+	if (data.enabled == p_enabled) {
+		return;
+	}
+
+	data.spatial_tick_list[0].clear();
+	data.spatial_tick_list[1].clear();
+
+	// Spatial flags must be reset.
+	if (p_root) {
+		_reset_flags(p_root);
+	}
+
+	data.enabled = p_enabled;
+}
+
+void SceneTreeFTI::tick_update() {
+	if (!data.enabled) {
+		return;
+	}
+
+	uint32_t curr_mirror = data.mirror;
+	uint32_t prev_mirror = curr_mirror ? 0 : 1;
+
+	LocalVector<Spatial *> &curr = data.spatial_tick_list[curr_mirror];
+	LocalVector<Spatial *> &prev = data.spatial_tick_list[prev_mirror];
+
+	// First detect on the previous list but not on this tick list.
+	for (uint32_t n = 0; n < prev.size(); n++) {
+		Spatial *s = prev[n];
+		if (!s->data.fti_on_tick_list) {
+			// Needs a reset so jittering will stop.
+			s->fti_pump();
+
+			// This may not get updated so set it to the same as global xform.
+			// TODO: double check this is the best value.
+			s->data.global_transform_interpolated = s->get_global_transform();
+
+			// Remove from interpolation list.
+			if (s->data.fti_on_frame_list) {
+				s->data.fti_on_frame_list = false;
+			}
+		}
+	}
+
+	// Now pump all on the current list.
+	for (uint32_t n = 0; n < curr.size(); n++) {
+		Spatial *s = curr[n];
+
+		// Reset, needs to be marked each tick.
+		s->data.fti_on_tick_list = false;
+
+		// Pump.
+		s->fti_pump();
+	}
+
+	// Clear previous list and flip.
+	prev.clear();
+	data.mirror = prev_mirror;
+}
+
+void SceneTreeFTI::_spatial_notify_set_transform(Spatial &r_spatial) {
+	// This may be checked by the calling routine already,
+	// but needs to be double checked for custom SceneTrees.
+	if (!data.enabled || !r_spatial.is_physics_interpolated()) {
+		return;
+	}
+
+	if (!r_spatial.data.fti_on_tick_list) {
+		r_spatial.data.fti_on_tick_list = true;
+		data.spatial_tick_list[data.mirror].push_back(&r_spatial);
+	}
+
+	if (!r_spatial.data.fti_on_frame_list) {
+		r_spatial.data.fti_on_frame_list = true;
+	}
+}
+
+void SceneTreeFTI::spatial_notify_delete(Spatial *p_spatial) {
+	if (!data.enabled) {
+		return;
+	}
+
+	if (p_spatial->data.fti_on_frame_list) {
+		p_spatial->data.fti_on_frame_list = false;
+	}
+
+	// This can potentially be optimized for large scenes with large churn,
+	// as it will be doing a linear search through the lists.
+	data.spatial_tick_list[0].erase_unordered(p_spatial);
+	data.spatial_tick_list[1].erase_unordered(p_spatial);
+}
+
+void SceneTreeFTI::_update_dirty_spatials(Node *p_node, uint32_t p_current_frame, float p_interpolation_fraction, bool p_active, const Transform *p_parent_global_xform, int p_depth) {
+	Spatial *s = Object::cast_to<Spatial>(p_node);
+
+	// Don't recurse into hidden branches.
+	if (s && !s->is_visible()) {
+		// NOTE : If we change from recursing entire tree, we should do an is_visible_in_tree()
+		// check for the first of the branch.
+		return;
+	}
+
+	// Not a Spatial.
+	// Could be e.g. a viewport or something
+	// so we should still recurse to children.
+	if (!s) {
+		for (int n = 0; n < p_node->get_child_count(); n++) {
+			_update_dirty_spatials(p_node->get_child(n), p_current_frame, p_interpolation_fraction, p_active, nullptr, p_depth + 1);
+		}
+		return;
+	}
+
+	// Start the active interpolation chain from here onwards
+	// as we recurse further into the SceneTree.
+	// Once we hit an active (interpolated) node, we have to fully
+	// process all ancestors because their xform will also change.
+	// Anything not moving (inactive) higher in the tree need not be processed.
+	if (!p_active) {
+		if (data.frame_start) {
+			// On the frame start, activate whenever we hit something that requests interpolation.
+			if (s->data.fti_on_frame_list) {
+				p_active = true;
+			}
+		} else {
+			// On the frame end, we want to re-interpolate *anything* that has moved
+			// since the frame start.
+			if (s->data.dirty & Spatial::DIRTY_GLOBAL_INTERPOLATED) {
+				p_active = true;
+			}
+		}
+	}
+
+	if (data.frame_start) {
+		// Mark on the Spatial whether we have set global_transform_interp.
+		// This can later be used when calling `get_global_transform_interpolated()`
+		// to know which xform to return.
+		s->data.fti_global_xform_interp_set = p_active;
+	}
+
+	if (p_active) {
+#if 0
+		bool dirty = s->data.dirty & Spatial::DIRTY_GLOBAL_INTERP;
+
+		if (data.debug) {
+			String sz;
+			for (int n = 0; n < p_depth; n++) {
+				sz += "\t";
+			}
+			print_line(sz + p_node->get_name() + (dirty ? " DIRTY" : ""));
+		}
+#endif
+
+		// First calculate our local xform.
+		// This will either use interpolation, or just use the current local if not interpolated.
+		Transform local_interp;
+		if (s->is_physics_interpolated()) {
+			TransformInterpolator::interpolate_transform(s->data.local_transform_prev, s->data.local_transform, local_interp, p_interpolation_fraction);
+		} else {
+			local_interp = s->data.local_transform;
+		}
+
+		// Concatenate parent xform.
+		if (!s->is_set_as_toplevel()) {
+			if (p_parent_global_xform) {
+				s->data.global_transform_interpolated = (*p_parent_global_xform) * local_interp;
+			} else {
+				const Spatial *parent = s->get_parent_spatial();
+
+				if (parent) {
+					const Transform &parent_glob = parent->data.fti_global_xform_interp_set ? parent->data.global_transform_interpolated : parent->data.global_transform;
+					s->data.global_transform_interpolated = parent_glob * local_interp;
+				} else {
+					s->data.global_transform_interpolated = local_interp;
+				}
+			}
+		} else {
+			s->data.global_transform_interpolated = local_interp;
+		}
+
+		// Upload to VisualServer the interpolated global xform.
+		s->fti_update_servers();
+
+	} // if active.
+
+	// Remove the dirty interp flag from EVERYTHING as we go.
+	s->data.dirty &= ~Spatial::DIRTY_GLOBAL_INTERPOLATED;
+
+	// Recurse to children.
+	for (int n = 0; n < p_node->get_child_count(); n++) {
+		_update_dirty_spatials(p_node->get_child(n), p_current_frame, p_interpolation_fraction, p_active, s->data.fti_global_xform_interp_set ? &s->data.global_transform_interpolated : &s->data.global_transform, p_depth + 1);
+	}
+}
+
+void SceneTreeFTI::frame_update(Node *p_root, bool p_frame_start) {
+	if (!data.enabled || !p_root) {
+		return;
+	}
+
+	data.frame_start = p_frame_start;
+
+	float f = Engine::get_singleton()->get_physics_interpolation_fraction();
+	uint32_t frame = Engine::get_singleton()->get_frames_drawn();
+
+// #define SCENE_TREE_FTI_TAKE_TIMINGS
+#ifdef SCENE_TREE_FTI_TAKE_TIMINGS
+	uint64_t before = OS::get_singleton()->get_ticks_usec();
+#endif
+
+	if (data.debug) {
+		print_line(String("\nScene: ") + (data.frame_start ? "start" : "end") + "\n");
+	}
+
+	// Probably not the most optimal approach as we traverse the entire SceneTree
+	// but simple and foolproof.
+	// Can be optimized later.
+	_update_dirty_spatials(p_root, frame, f, false);
+
+	if (!p_frame_start && data.debug) {
+		data.debug = false;
+	}
+
+#ifdef SCENE_TREE_FTI_TAKE_TIMINGS
+	uint64_t after = OS::get_singleton()->get_ticks_usec();
+	if ((Engine::get_singleton()->get_frames_drawn() % 60) == 0) {
+		print_line("Took " + itos(after - before) + " usec " + (data.frame_start ? "start" : "end"));
+	}
+#endif
+}
+
+#endif // ndef _3D_DISABLED
diff --git a/scene/main/scene_tree_fti.h b/scene/main/scene_tree_fti.h
new file mode 100644
index 000000000000..5e76d30c7b10
--- /dev/null
+++ b/scene/main/scene_tree_fti.h
@@ -0,0 +1,112 @@
+/**************************************************************************/
+/*  scene_tree_fti.h                                                      */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#ifndef SCENE_TREE_FTI_H
+#define SCENE_TREE_FTI_H
+
+#include <core/local_vector.h>
+#include <core/os/mutex.h>
+
+class Spatial;
+class Node;
+class Transform;
+
+#ifdef _3D_DISABLED
+// Stubs
+class SceneTreeFTI {
+public:
+	void frame_update(Node *p_root, bool p_frame_start) {}
+	void tick_update() {}
+	void set_enabled(Node *p_root, bool p_enabled) {}
+	bool is_enabled() const { return false; }
+
+	void spatial_notify_set_transform(Spatial &r_spatial) {}
+	void spatial_notify_delete(Spatial *p_spatial) {}
+};
+#else
+
+// Important.
+// This class uses raw pointers, so it is essential that on deletion, this class is notified
+// so that any references can be cleared up to prevent dangling pointer access.
+
+// This class can be used from a custom SceneTree.
+
+// Note we could potentially make SceneTreeFTI static / global to avoid the lookup through scene tree,
+// but this covers the custom case of multiple scene trees.
+
+// This class is not thread safe, but can be made thread safe easily with a mutex as in the 4.x version.
+
+class SceneTreeFTI {
+	struct Data {
+		// Prev / Curr lists of spatials having local xforms pumped.
+		LocalVector<Spatial *> spatial_tick_list[2];
+		uint32_t mirror = 0;
+
+		bool enabled = false;
+
+		// Whether we are in physics ticks, or in a frame.
+		bool in_frame = false;
+
+		// Updating at the start of the frame, or the end on second pass.
+		bool frame_start = true;
+
+		bool debug = false;
+	} data;
+
+	void _update_dirty_spatials(Node *p_node, uint32_t p_current_frame, float p_interpolation_fraction, bool p_active, const Transform *p_parent_global_xform = nullptr, int p_depth = 0);
+	void _reset_flags(Node *p_node);
+	void _spatial_notify_set_transform(Spatial &r_spatial);
+
+public:
+	// Hottest function, allow inlining the data.enabled check.
+	void spatial_notify_set_transform(Spatial &r_spatial) {
+		if (!data.enabled) {
+			return;
+		}
+		_spatial_notify_set_transform(r_spatial);
+	}
+
+	void spatial_notify_delete(Spatial *p_spatial);
+
+	// Calculate interpolated xforms, send to visual server.
+	void frame_update(Node *p_root, bool p_frame_start);
+
+	// Update local xform pumps.
+	void tick_update();
+
+	void set_enabled(Node *p_root, bool p_enabled);
+	bool is_enabled() const { return data.enabled; }
+
+	void set_debug_next_frame() { data.debug = true; }
+};
+
+#endif // ndef _3D_DISABLED
+
+#endif // SCENE_TREE_FTI_H
diff --git a/servers/visual/rasterizer.h b/servers/visual/rasterizer.h
index 2b2259cc17f0..e99b16da6c4c 100644
--- a/servers/visual/rasterizer.h
+++ b/servers/visual/rasterizer.h
@@ -92,16 +92,8 @@ class RasterizerScene {
 		RID material_override;
 		RID material_overlay;
 
-		// This is the main transform to be drawn with ..
-		// This will either be the interpolated transform (when using fixed timestep interpolation)
-		// or the ONLY transform (when not using FTI).
 		Transform transform;
 
-		// for interpolation we store the current transform (this physics tick)
-		// and the transform in the previous tick
-		Transform transform_curr;
-		Transform transform_prev;
-
 		int depth_layer;
 		uint32_t layer_mask;
 
@@ -123,16 +115,6 @@ class RasterizerScene {
 		bool baked_light : 1; //this flag is only to know if it actually did use baked light
 		bool redraw_if_visible : 1;
 
-		bool on_interpolate_list : 1;
-		bool on_interpolate_transform_list : 1;
-		bool interpolated : 1;
-		TransformInterpolator::Method interpolation_method : 3;
-
-		// For fixed timestep interpolation.
-		// Note 32 bits is plenty for checksum, no need for real_t
-		float transform_checksum_curr;
-		float transform_checksum_prev;
-
 		float depth; //used for sorting
 
 		SelfList<InstanceBase> dependency_item;
@@ -158,12 +140,6 @@ class RasterizerScene {
 			lightmap_capture = nullptr;
 			lightmap_slice = -1;
 			lightmap_uv_rect = Rect2(0, 0, 1, 1);
-			on_interpolate_list = false;
-			on_interpolate_transform_list = false;
-			interpolated = true;
-			interpolation_method = TransformInterpolator::INTERP_LERP;
-			transform_checksum_curr = 0.0;
-			transform_checksum_prev = 0.0;
 		}
 	};
 
diff --git a/servers/visual/visual_server_raster.h b/servers/visual/visual_server_raster.h
index 6d088c09c3ec..297e3693096a 100644
--- a/servers/visual/visual_server_raster.h
+++ b/servers/visual/visual_server_raster.h
@@ -573,8 +573,6 @@ class VisualServerRaster : public VisualServer {
 	BIND2(instance_set_layer_mask, RID, uint32_t)
 	BIND3(instance_set_pivot_data, RID, float, bool)
 	BIND2(instance_set_transform, RID, const Transform &)
-	BIND2(instance_set_interpolated, RID, bool)
-	BIND1(instance_reset_physics_interpolation, RID)
 	BIND2(instance_attach_object_instance_id, RID, ObjectID)
 	BIND3(instance_set_blend_shape_weight, RID, int, float)
 	BIND3(instance_set_surface_material, RID, int, RID)
diff --git a/servers/visual/visual_server_scene.cpp b/servers/visual/visual_server_scene.cpp
index bdea223d5cee..1552469abb6a 100644
--- a/servers/visual/visual_server_scene.cpp
+++ b/servers/visual/visual_server_scene.cpp
@@ -673,9 +673,6 @@ void VisualServerScene::instance_set_scenario(RID p_instance, RID p_scenario) {
 			_instance_destroy_occlusion_rep(instance);
 		}
 
-		// remove any interpolation data associated with the instance in this scenario
-		_interpolation_data.notify_free_instance(p_instance, *instance);
-
 		switch (instance->base_type) {
 			case VS::INSTANCE_LIGHT: {
 				InstanceLightData *light = static_cast<InstanceLightData *>(instance->base_data);
@@ -765,27 +762,6 @@ void VisualServerScene::instance_set_pivot_data(RID p_instance, float p_sorting_
 	instance->use_aabb_center = p_use_aabb_center;
 }
 
-void VisualServerScene::instance_reset_physics_interpolation(RID p_instance) {
-	Instance *instance = instance_owner.get(p_instance);
-	ERR_FAIL_COND(!instance);
-
-	if (_interpolation_data.interpolation_enabled && instance->interpolated) {
-		instance->transform_prev = instance->transform_curr;
-		instance->transform_checksum_prev = instance->transform_checksum_curr;
-
-#ifdef VISUAL_SERVER_DEBUG_PHYSICS_INTERPOLATION
-		print_line("instance_reset_physics_interpolation .. tick " + itos(Engine::get_singleton()->get_physics_frames()));
-		print_line("\tprev " + rtos(instance->transform_prev.origin.x) + ", curr " + rtos(instance->transform_curr.origin.x));
-#endif
-	}
-}
-
-void VisualServerScene::instance_set_interpolated(RID p_instance, bool p_interpolated) {
-	Instance *instance = instance_owner.get(p_instance);
-	ERR_FAIL_COND(!instance);
-	instance->interpolated = p_interpolated;
-}
-
 void VisualServerScene::instance_set_transform(RID p_instance, const Transform &p_transform) {
 	Instance *instance = instance_owner.get(p_instance);
 	ERR_FAIL_COND(!instance);
@@ -794,47 +770,8 @@ void VisualServerScene::instance_set_transform(RID p_instance, const Transform &
 	print_line("instance_set_transform " + rtos(p_transform.origin.x) + " .. tick " + itos(Engine::get_singleton()->get_physics_frames()));
 #endif
 
-	if (!(_interpolation_data.interpolation_enabled && instance->interpolated) || !instance->scenario) {
-		if (instance->transform == p_transform) {
-			return; //must be checked to avoid worst evil
-		}
-
-#ifdef DEBUG_ENABLED
-
-		for (int i = 0; i < 4; i++) {
-			const Vector3 &v = i < 3 ? p_transform.basis.elements[i] : p_transform.origin;
-			ERR_FAIL_COND(Math::is_inf(v.x));
-			ERR_FAIL_COND(Math::is_nan(v.x));
-			ERR_FAIL_COND(Math::is_inf(v.y));
-			ERR_FAIL_COND(Math::is_nan(v.y));
-			ERR_FAIL_COND(Math::is_inf(v.z));
-			ERR_FAIL_COND(Math::is_nan(v.z));
-		}
-
-#endif
-		instance->transform = p_transform;
-		_instance_queue_update(instance, true);
-
-#if defined(DEBUG_ENABLED) && defined(TOOLS_ENABLED)
-		if ((_interpolation_data.interpolation_enabled && !instance->interpolated) && (Engine::get_singleton()->is_in_physics_frame())) {
-			PHYSICS_INTERPOLATION_NODE_WARNING(instance->object_id, "Non-interpolated triggered from physics process");
-		}
-#endif
-
-		return;
-	}
-
-	float new_checksum = TransformInterpolator::checksum_transform(p_transform);
-	bool checksums_match = (instance->transform_checksum_curr == new_checksum) && (instance->transform_checksum_prev == new_checksum);
-
-	// we can't entirely reject no changes because we need the interpolation
-	// system to keep on stewing
-
-	// Optimized check. First checks the checksums. If they pass it does the slow check at the end.
-	// Alternatively we can do this non-optimized and ignore the checksum...
-	// if no change
-	if (checksums_match && (instance->transform_curr == p_transform) && (instance->transform_prev == p_transform)) {
-		return;
+	if (instance->transform == p_transform) {
+		return; //must be checked to avoid worst evil
 	}
 
 #ifdef DEBUG_ENABLED
@@ -850,62 +787,8 @@ void VisualServerScene::instance_set_transform(RID p_instance, const Transform &
 	}
 
 #endif
-
-	instance->transform_curr = p_transform;
-
-#ifdef VISUAL_SERVER_DEBUG_PHYSICS_INTERPOLATION
-	print_line("\tprev " + rtos(instance->transform_prev.origin.x) + ", curr " + rtos(instance->transform_curr.origin.x));
-#endif
-
-	// keep checksums up to date
-	instance->transform_checksum_curr = new_checksum;
-
-	if (!instance->on_interpolate_transform_list) {
-		_interpolation_data.instance_transform_update_list_curr->push_back(p_instance);
-		instance->on_interpolate_transform_list = true;
-	} else {
-		DEV_ASSERT(_interpolation_data.instance_transform_update_list_curr->size());
-	}
-
-	// If the instance is invisible, then we are simply updating the data flow, there is no need to calculate the interpolated
-	// transform or anything else.
-	// Ideally we would not even call the VisualServer::set_transform() when invisible but that would entail having logic
-	// to keep track of the previous transform on the SceneTree side. The "early out" below is less efficient but a lot cleaner codewise.
-	if (!instance->visible) {
-		return;
-	}
-
-	// decide on the interpolation method .. slerp if possible
-	instance->interpolation_method = TransformInterpolator::find_method(instance->transform_prev.basis, instance->transform_curr.basis);
-
-	if (!instance->on_interpolate_list) {
-		_interpolation_data.instance_interpolate_update_list.push_back(p_instance);
-		instance->on_interpolate_list = true;
-	} else {
-		DEV_ASSERT(_interpolation_data.instance_interpolate_update_list.size());
-	}
-
+	instance->transform = p_transform;
 	_instance_queue_update(instance, true);
-
-#if defined(DEBUG_ENABLED) && defined(TOOLS_ENABLED)
-	if (!Engine::get_singleton()->is_in_physics_frame()) {
-		PHYSICS_INTERPOLATION_NODE_WARNING(instance->object_id, "Interpolated triggered from outside physics process");
-	}
-#endif
-}
-
-void VisualServerScene::InterpolationData::notify_free_instance(RID p_rid, Instance &r_instance) {
-	r_instance.on_interpolate_list = false;
-	r_instance.on_interpolate_transform_list = false;
-
-	if (!interpolation_enabled) {
-		return;
-	}
-
-	// if the instance was on any of the lists, remove
-	instance_interpolate_update_list.erase_multiple_unordered(p_rid);
-	instance_transform_update_list_curr->erase_multiple_unordered(p_rid);
-	instance_transform_update_list_prev->erase_multiple_unordered(p_rid);
 }
 
 void VisualServerScene::update_interpolation_tick(bool p_process) {
@@ -915,84 +798,11 @@ void VisualServerScene::update_interpolation_tick(bool p_process) {
 
 	// update interpolation in storage
 	VSG::storage->update_interpolation_tick(p_process);
-
-	// detect any that were on the previous transform list that are no longer active,
-	// we should remove them from the interpolate list
-
-	for (unsigned int n = 0; n < _interpolation_data.instance_transform_update_list_prev->size(); n++) {
-		const RID &rid = (*_interpolation_data.instance_transform_update_list_prev)[n];
-		Instance *instance = instance_owner.getornull(rid);
-
-		bool active = true;
-
-		// no longer active? (either the instance deleted or no longer being transformed)
-		if (instance && !instance->on_interpolate_transform_list) {
-			active = false;
-			instance->on_interpolate_list = false;
-
-			// make sure the most recent transform is set
-			instance->transform = instance->transform_curr;
-
-			// and that both prev and current are the same, just in case of any interpolations
-			instance->transform_prev = instance->transform_curr;
-
-			// make sure are updated one more time to ensure the AABBs are correct
-			_instance_queue_update(instance, true);
-		}
-
-		if (!instance) {
-			active = false;
-		}
-
-		if (!active) {
-			_interpolation_data.instance_interpolate_update_list.erase(rid);
-		}
-	}
-
-	// and now for any in the transform list (being actively interpolated), keep the previous transform
-	// value up to date ready for the next tick
-	if (p_process) {
-		for (unsigned int n = 0; n < _interpolation_data.instance_transform_update_list_curr->size(); n++) {
-			const RID &rid = (*_interpolation_data.instance_transform_update_list_curr)[n];
-			Instance *instance = instance_owner.getornull(rid);
-			if (instance) {
-				instance->transform_prev = instance->transform_curr;
-				instance->transform_checksum_prev = instance->transform_checksum_curr;
-				instance->on_interpolate_transform_list = false;
-			}
-		}
-	}
-
-	// we maintain a mirror list for the transform updates, so we can detect when an instance
-	// is no longer being transformed, and remove it from the interpolate list
-	SWAP(_interpolation_data.instance_transform_update_list_curr, _interpolation_data.instance_transform_update_list_prev);
-
-	// prepare for the next iteration
-	_interpolation_data.instance_transform_update_list_curr->clear();
 }
 
 void VisualServerScene::update_interpolation_frame(bool p_process) {
 	// update interpolation in storage
 	VSG::storage->update_interpolation_frame(p_process);
-
-	if (p_process) {
-		real_t f = Engine::get_singleton()->get_physics_interpolation_fraction();
-
-		for (unsigned int i = 0; i < _interpolation_data.instance_interpolate_update_list.size(); i++) {
-			const RID &rid = _interpolation_data.instance_interpolate_update_list[i];
-			Instance *instance = instance_owner.getornull(rid);
-			if (instance) {
-				TransformInterpolator::interpolate_transform_via_method(instance->transform_prev, instance->transform_curr, instance->transform, f, instance->interpolation_method);
-
-#ifdef VISUAL_SERVER_DEBUG_PHYSICS_INTERPOLATION
-				print_line("\t\tinterpolated: " + rtos(instance->transform.origin.x) + "\t( prev " + rtos(instance->transform_prev.origin.x) + ", curr " + rtos(instance->transform_curr.origin.x) + " ) on tick " + itos(Engine::get_singleton()->get_physics_frames()));
-#endif
-
-				// make sure AABBs are constantly up to date through the interpolation
-				_instance_queue_update(instance, true);
-			}
-		} // for n
-	}
 }
 
 void VisualServerScene::instance_attach_object_instance_id(RID p_instance, ObjectID p_id) {
@@ -1046,25 +856,6 @@ void VisualServerScene::instance_set_visible(RID p_instance, bool p_visible) {
 
 	instance->visible = p_visible;
 
-	// Special case for physics interpolation, we want to ensure the interpolated data is up to date
-	if (_interpolation_data.interpolation_enabled && p_visible && instance->interpolated && instance->scenario && !instance->on_interpolate_list) {
-		// Do all the extra work we normally do on instance_set_transform(), because this is optimized out for hidden instances.
-		// This prevents a glitch of stale interpolation transform data when unhiding before the next physics tick.
-		instance->interpolation_method = TransformInterpolator::find_method(instance->transform_prev.basis, instance->transform_curr.basis);
-		_interpolation_data.instance_interpolate_update_list.push_back(p_instance);
-		instance->on_interpolate_list = true;
-		_instance_queue_update(instance, true);
-
-		// We must also place on the transform update list for a tick, so the system
-		// can auto-detect if the instance is no longer moving, and remove from the interpolate lists again.
-		// If this step is ignored, an unmoving instance could remain on the interpolate lists indefinitely
-		// (or rather until the object is deleted) and cause unnecessary updates and drawcalls.
-		if (!instance->on_interpolate_transform_list) {
-			_interpolation_data.instance_transform_update_list_curr->push_back(p_instance);
-			instance->on_interpolate_transform_list = true;
-		}
-	}
-
 	// give the opportunity for the spatial partitioning scene to use a special implementation of visibility
 	// for efficiency (supported in BVH but not octree)
 
@@ -4496,7 +4287,6 @@ bool VisualServerScene::free(RID p_rid) {
 		update_dirty_instances();
 
 		Instance *instance = instance_owner.get(p_rid);
-		_interpolation_data.notify_free_instance(p_rid, *instance);
 
 		instance_set_use_lightmap(p_rid, RID(), RID(), -1, Rect2(0, 0, 1, 1));
 		instance_set_scenario(p_rid, RID());
diff --git a/servers/visual/visual_server_scene.h b/servers/visual/visual_server_scene.h
index 56b6ceb5b60b..76b890765796 100644
--- a/servers/visual/visual_server_scene.h
+++ b/servers/visual/visual_server_scene.h
@@ -397,12 +397,6 @@ class VisualServerScene {
 	virtual void set_physics_interpolation_enabled(bool p_enabled);
 
 	struct InterpolationData {
-		void notify_free_instance(RID p_rid, Instance &r_instance);
-		LocalVector<RID> instance_interpolate_update_list;
-		LocalVector<RID> instance_transform_update_lists[2];
-		LocalVector<RID> *instance_transform_update_list_curr = &instance_transform_update_lists[0];
-		LocalVector<RID> *instance_transform_update_list_prev = &instance_transform_update_lists[1];
-
 		bool interpolation_enabled = false;
 	} _interpolation_data;
 
@@ -662,8 +656,6 @@ class VisualServerScene {
 	virtual void instance_set_layer_mask(RID p_instance, uint32_t p_mask);
 	virtual void instance_set_pivot_data(RID p_instance, float p_sorting_offset, bool p_use_aabb_center);
 	virtual void instance_set_transform(RID p_instance, const Transform &p_transform);
-	virtual void instance_set_interpolated(RID p_instance, bool p_interpolated);
-	virtual void instance_reset_physics_interpolation(RID p_instance);
 	virtual void instance_attach_object_instance_id(RID p_instance, ObjectID p_id);
 	virtual void instance_set_blend_shape_weight(RID p_instance, int p_shape, float p_weight);
 	virtual void instance_set_surface_material(RID p_instance, int p_surface, RID p_material);
diff --git a/servers/visual/visual_server_wrap_mt.h b/servers/visual/visual_server_wrap_mt.h
index 6ba16fb7786f..a422de5f6401 100644
--- a/servers/visual/visual_server_wrap_mt.h
+++ b/servers/visual/visual_server_wrap_mt.h
@@ -481,8 +481,6 @@ class VisualServerWrapMT : public VisualServer {
 	FUNC2(instance_set_layer_mask, RID, uint32_t)
 	FUNC3(instance_set_pivot_data, RID, float, bool)
 	FUNC2(instance_set_transform, RID, const Transform &)
-	FUNC2(instance_set_interpolated, RID, bool)
-	FUNC1(instance_reset_physics_interpolation, RID)
 	FUNC2(instance_attach_object_instance_id, RID, ObjectID)
 	FUNC3(instance_set_blend_shape_weight, RID, int, float)
 	FUNC3(instance_set_surface_material, RID, int, RID)
diff --git a/servers/visual_server.cpp b/servers/visual_server.cpp
index f13470cf11de..93ca55c52437 100644
--- a/servers/visual_server.cpp
+++ b/servers/visual_server.cpp
@@ -2172,8 +2172,6 @@ void VisualServer::_bind_methods() {
 	ClassDB::bind_method(D_METHOD("instance_set_scenario", "instance", "scenario"), &VisualServer::instance_set_scenario);
 	ClassDB::bind_method(D_METHOD("instance_set_layer_mask", "instance", "mask"), &VisualServer::instance_set_layer_mask);
 	ClassDB::bind_method(D_METHOD("instance_set_transform", "instance", "transform"), &VisualServer::instance_set_transform);
-	ClassDB::bind_method(D_METHOD("instance_set_interpolated", "instance", "interpolated"), &VisualServer::instance_set_interpolated);
-	ClassDB::bind_method(D_METHOD("instance_reset_physics_interpolation", "instance"), &VisualServer::instance_reset_physics_interpolation);
 	ClassDB::bind_method(D_METHOD("instance_attach_object_instance_id", "instance", "id"), &VisualServer::instance_attach_object_instance_id);
 	ClassDB::bind_method(D_METHOD("instance_set_blend_shape_weight", "instance", "shape", "weight"), &VisualServer::instance_set_blend_shape_weight);
 	ClassDB::bind_method(D_METHOD("instance_set_surface_material", "instance", "surface", "material"), &VisualServer::instance_set_surface_material);
diff --git a/servers/visual_server.h b/servers/visual_server.h
index 9668be06c56f..7ea42a4ebf34 100644
--- a/servers/visual_server.h
+++ b/servers/visual_server.h
@@ -878,8 +878,6 @@ class VisualServer : public Object {
 	virtual void instance_set_layer_mask(RID p_instance, uint32_t p_mask) = 0;
 	virtual void instance_set_pivot_data(RID p_instance, float p_sorting_offset, bool p_use_aabb_center) = 0;
 	virtual void instance_set_transform(RID p_instance, const Transform &p_transform) = 0;
-	virtual void instance_set_interpolated(RID p_instance, bool p_interpolated) = 0;
-	virtual void instance_reset_physics_interpolation(RID p_instance) = 0;
 	virtual void instance_attach_object_instance_id(RID p_instance, ObjectID p_id) = 0;
 	virtual void instance_set_blend_shape_weight(RID p_instance, int p_shape, float p_weight) = 0;
 	virtual void instance_set_surface_material(RID p_instance, int p_surface, RID p_material) = 0;


Test patch: