Quick answer: Pose jitter on Quest is almost never a tracking issue. It is a timing issue: the runtime gives you a predicted pose at display time, but your frame rate or origin scale corrupts that prediction. Keep the render loop at full refresh, apply a one-euro filter to held objects, verify XROrigin3D has identity scale, and use the Godot 4 OpenXR plugin’s Motion Smoothing setting.

You build a VR prototype in Godot, sideload it to a Quest 2, and the controllers shake visibly — not by a millimeter, but by several centimeters, sometimes jumping in a whole direction for one frame. The tracking camera says it sees everything fine. The desktop link version looks smooth. Only the standalone build jitters. This is a classic OpenXR prediction/timing mismatch, and it has four independent causes that often stack.

How OpenXR Delivers Pose

OpenXR does not give you “the current controller position.” It gives you a predicted pose at the expected display time of the frame you are about to submit. The runtime extrapolates based on the last few tracking samples, a velocity estimate, and a target timestamp.

If your application submits frames at 72 Hz on Quest but occasionally misses a frame, the runtime may reproject the last frame with updated pose, or it may re-predict for a frame 13.8 ms further out. Either way, the pose sequence is no longer smooth — it jumps. That is what you see as jitter.

Step 1: Stabilize the Render Loop

The single largest source of pose jitter on Quest is a dropped frame. Measure your frame time with Performance.get_monitor(Performance.TIME_PROCESS) plus physics time. On Quest 2 at 72 Hz you have ~13.8 ms; at 90 Hz you have ~11.1 ms. If any frame exceeds that, the runtime re-predicts.

If frame time is stable under the budget, proceed. If not, no amount of filtering will fix the jitter — you are re-predicting every dropped frame.

Step 2: Configure the Godot 4 OpenXR Plugin

Open Project Settings → XR → OpenXR. Key flags to review:

Step 3: Audit XROrigin3D Scale

The scene tree parenting matters. XROrigin3D should have identity scale, and every ancestor should too. If your game world is authored at a 1:10 scale and you applied scale = (0.1, 0.1, 0.1) on the root, pose deltas are scaled too — a 1 mm tracking error becomes visible jitter.

# Validator you can call on _ready
func assert_origin_scale_identity() -> void:
    var node: Node3D = $XROrigin3D
    while node:
        if not node.scale.is_equal_approx(Vector3.ONE):
            push_error("XR ancestor has non-identity scale: %s (%s)" % [node.name, node.scale])
        node = node.get_parent_node_3d()

Move the world to the origin; do not scale the origin to the world.

Step 4: One-Euro Filter for Held Objects

When the player holds a sword, you want the visual pose of the sword to be smooth, even if raw tracking has noise. But you do not want to filter the controller pose itself — the aim ray and trigger press should stay latency-accurate.

Apply a one-euro filter to the child that visually represents the held object:

class_name OneEuroFilter

var min_cutoff := 1.0
var beta := 0.01
var d_cutoff := 1.0
var _prev_value: Vector3
var _prev_deriv: Vector3
var _have_prev := false

func filter(v: Vector3, dt: float) -> Vector3:
    if not _have_prev:
        _prev_value = v
        _prev_deriv = Vector3.ZERO
        _have_prev = true
        return v
    var deriv = (v - _prev_value) / dt
    var alpha_d = _alpha(d_cutoff, dt)
    _prev_deriv = _prev_deriv.lerp(deriv, alpha_d)
    var cutoff = min_cutoff + beta * _prev_deriv.length()
    var alpha = _alpha(cutoff, dt)
    _prev_value = _prev_value.lerp(v, alpha)
    return _prev_value

func _alpha(cutoff: float, dt: float) -> float:
    var tau = 1.0 / (2.0 * PI * cutoff)
    return 1.0 / (1.0 + tau / dt)

Feed the controller’s global position into this filter and apply the result to a held mesh’s transform. The one-euro cutoff scales with motion speed, so slow careful aiming gets heavy smoothing while fast swings stay responsive.

Step 5: Use the Right Pose Source

Godot’s XRController3D exposes multiple pose actions. Use grip for held-object anchoring (this is the pose the controller’s physical grip-point occupies) and aim for rays. Mixing them looks like jitter because the two poses have slightly different offsets and your hand-model position flickers between them when you switch actions.

@onready var left_grip: XRController3D = $XROrigin3D/LeftGrip
@onready var left_aim: XRController3D = $XROrigin3D/LeftAim
# Configure one as pose="grip" and the other pose="aim" in inspector

“In VR, smoothness is a render budget problem wrapped in a filter problem wrapped in a scene-graph problem. Fix them in that order.”

Verifying the Fix

Record a gameplay video at 30 fps, step through frames, and watch a static held object while the head moves. The object should track the hand within a millimeter per frame. If it still jumps, re-check frame time — Quest’s GPU profiler will show you which pass blew the budget.

Related Issues

If the player’s head position drifts over time, see XR Recenter Drift on Standalone. For black flicker between frames, read XR Black Frame on Respawn.

Stable frame time + depth submit + identity-scale origin + one-euro filter on held meshes = stable Quest controllers.