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.
- Reduce texture sizes; Quest GPUs choke on high-resolution sampling.
- Disable MSAA for complex scenes or switch to 2x only.
- Cull aggressively — use
VisibleOnScreenEnabler3Don static props. - Keep draw calls under 150 per eye if possible.
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:
- Enabled: obviously.
- Form Factor: Head Mounted.
- Reference Space: Local Floor for roomscale, Local for seated.
- Submit Depth Buffer: On. Submitting the depth buffer lets the runtime do better reprojection, which reduces jitter during frame drops.
- Action Map: confirm your controller profile has /user/hand/left/input/grip/pose and aim/pose actions. The grip pose is where the hand physically grips; aim is for rays.
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.