Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when building VR/AR/XR applications — OpenXR setup, XROrigin3D, hand tracking, controllers, passthrough, and Meta Quest deployment in Godot 4.3+
.claude/skills/jame581-xr-development/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 104% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 87% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 68% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 131% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 122% | 0% |
All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.
> Related skills: 3d-essentials for 3D rendering and environment, physics-system for 3D physics interactions, input-handling for non-XR input patterns, export-pipeline for platform exports.
OpenXR (or OpenXR Plugin depending on version)truetrue (for XR shader support)Disabled (the XR runtime controls frame timing)Main (Node3D)
├── XROrigin3D ← Player's physical space origin
│ ├── XRCamera3D ← Head-mounted display
│ ├── XRController3D (left) ← Left controller
│ │ └── LeftHandModel (MeshInstance3D or hand tracking)
│ ├── XRController3D (right) ← Right controller
│ │ └── RightHandModel
│ └── (XRBodyTracker via XRServer — optional full body tracking)
├── WorldEnvironment
└── GameWorld (Node3D)
└── ... level geometrygdscriptextends Node3D func _ready() -> void: var xr_interface: XRInterface = XRServer.find_interface("OpenXR") if xr_interface and xr_interface.is_initialized(): get_viewport().use_xr = true else: push_error("OpenXR not available")
csharppublic partial class XRMain : Node3D { public override void _Ready() { var xrInterface = XRServer.FindInterface("OpenXR"); if (xrInterface != null && xrInterface.IsInitialized()) GetViewport().UseXr = true; else GD.PushError("OpenXR not available"); } }
XRController3D nodes (one per hand, child of XROrigin3D) expose buttons via the OpenXR action map. Common buttons: trigger (select_button), grip (grip_button), thumbstick (primary_axis Vector2). Apply thumbstick locomotion velocity to XROrigin3D (not the camera).
> See references/controllers-and-input.md for full XRController3D wiring, OpenXR button reference, and thumbstick locomotion recipe.
When the headset supports hand tracking (Quest 2+, Vision Pro), XRController3D nodes can be configured to track hand joints. Sample finger positions for gesture detection, or bind to standard select / grip events when the user pinches.
> See references/hand-tracking.md for hand-tracking node setup, joint sampling, and gesture-driven interactions.
Standard pattern: an Area3D on the controller detects nearby RigidBody3D objects; on grip-press, parent the body to the controller and freeze it; on grip-release, restore the parent and apply the controller's velocity to launch.
> See references/grabbing-objects.md for the full physics-based grabbing implementation (GDScript + C#).
For UI in VR, render a Control tree to a SubViewport, map its texture onto a Quad mesh placed in 3D space. Pointer: a RayCast3D from the controller hits the quad, the hit position is converted back to 2D viewport coords, an InputEventMouseMotion is forwarded into the SubViewport.
> See references/xr-ui.md for the SubViewport-on-quad recipe and pointer/ray interaction.
For headsets that support it (Quest 2+, Vision Pro), set OpenXRInterface.environment_blend_mode = XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND and clear the WorldEnvironment background to transparent. The user sees the real world with virtual content composited on top.
> See references/passthrough.md for the full enabling steps and Quest-specific notes.
Godot OpenXR Vendors (or install from AssetLib)OpenXROptional or RequiredOptional (if needed)| Setting | Recommended Value | |---------|-------------------| | Renderer | Mobile | | MSAA | 2x or 4x (VR needs antialiasing) | | Texture Compression | ETC2/ASTC | | Target FPS | 72 (Quest 2) or 90 (Quest 3) |
> Critical: VR must maintain consistent frame rate. Dropped frames cause nausea. Profile aggressively and keep draw calls low.
> ⚠️ Changed in Godot 4.7: New project settings xr/openxr/foveation_eye_tracked and xr/openxr/foveation_with_subsampled_images both default to true — when the foveation level is not "Disabled", eye-tracked foveation is used where the headset supports it, and subsampled images are used on Vulkan for a bigger foveation win. Subsampled images are incompatible with many screen-space features (e.g., FXAA, glow); if any are enabled, subsampled images are automatically disabled with a log warning. Set either setting to false to opt out. See GH-117868.
Godot 4.5 adds a D3D12 OpenXR backend on Windows (Quest Link / SteamVR alternative to Vulkan), foveated rendering on the Mobile Vulkan renderer, Application SpaceWarp frame synthesis for Quest/Pico, OpenXR Render Models for platform-native controller meshes, and native visionOS export via the Apple Embedded preset. All are enabled through Project Settings or the Godot OpenXR Vendors plugin — no engine-level code changes.
> See references/godot-4-5-features.md for enabling steps, GDScript + C# render-model snippets, and per-feature caveats.
Godot 4.6 ships with native OpenXR 1.1 runtime support. Devices and runtimes that implement OpenXR 1.1 automatically unlock 1.1 features (improved compositor layers, updated interaction profiles, etc.) without any project-level change. No API change is required — the engine negotiates the spec version with the runtime at startup.
> Note: OpenXR 1.1 was introduced in Godot 4.6 (beta as of this writing). API behaviour may evolve before the stable release — see https://godotengine.org/article/dev-snapshot-godot-4-6-beta-1/ for current details.
Godot 4.6 stabilises the XR Spatial Entities extension, enabling:
XRSpatialAnchor)Basic spatial anchor usage:
gdscript# Requires: OpenXR Spatial Entities extension enabled in the vendor plugin # XRSpatialAnchor is a Node3D placed in your scene that the runtime keeps locked # to a real-world location. extends Node3D @export var anchor_scene: PackedScene # Scene containing XRSpatialAnchor func place_anchor_at(world_position: Vector3) -> void: var anchor: XRSpatialAnchor = XRSpatialAnchor.new() anchor.position = world_position add_child(anchor) # The XR runtime takes over tracking once the node is added to the scene tree. # Persist the anchor UUID to restore it on next launch (platform-specific API).
csharp// Requires: OpenXR Spatial Entities extension enabled in the vendor plugin public partial class SpatialAnchorManager : Node3D { public void PlaceAnchorAt(Vector3 worldPosition) { var anchor = new XRSpatialAnchor(); anchor.Position = worldPosition; AddChild(anchor); // The XR runtime takes over tracking once added to the scene tree. // Persist anchor UUID via platform-specific API for cross-session recall. } }
> Note: XRSpatialAnchor, plane tracking, and marker tracking APIs were introduced in Godot 4.6 (beta as of this writing). The full API surface — especially persistence, query callbacks, and plane/marker node types — may change before the stable release. See https://godotengine.org/article/dev-snapshot-godot-4-6-beta-1/ for current signatures and the Godot OpenXR Vendors plugin for platform-specific spatial entity setup.
OpenXRInterface exposes the OpenXR user presence extension: the user_presence_changed(is_user_present: bool) signal fires when the user puts on or removes the headset, is_user_presence_supported() reports whether the extension is supported and enabled, and is_user_present() polls the current state (both only return valid values after OpenXR is initialized). Typical use: pause the game and mute audio when the headset comes off.
> Note: The signal is not emitted during application startup or shutdown — assume user presence is gained on startup and lost on shutdown.
gdscriptfunc _ready() -> void: var xr_interface: OpenXRInterface = XRServer.find_interface("OpenXR") if xr_interface and xr_interface.is_user_presence_supported(): xr_interface.user_presence_changed.connect(_on_user_presence_changed) func _on_user_presence_changed(is_user_present: bool) -> void: get_tree().paused = not is_user_present
csharppublic override void _Ready() { var xrInterface = XRServer.FindInterface("OpenXR") as OpenXRInterface; if (xrInterface != null && xrInterface.IsUserPresenceSupported()) xrInterface.UserPresenceChanged += OnUserPresenceChanged; } private void OnUserPresenceChanged(bool isUserPresent) { GetTree().Paused = !isUserPresent; }
OpenXRCompositionLayer gains eye_visibility (EyeVisibility enum: EYE_VISIBILITY_BOTH = 0 default, EYE_VISIBILITY_LEFT = 1, EYE_VISIBILITY_RIGHT = 2) — the eye(s) the composition layer is visible to. Renders a quad/cylinder/equirect layer to one eye only, e.g. for per-eye calibration screens or stereo content authored per eye.
gdscript$OpenXRCompositionLayerQuad.eye_visibility = OpenXRCompositionLayer.EYE_VISIBILITY_LEFT
csharpGetNode<OpenXRCompositionLayerQuad>("OpenXRCompositionLayerQuad").EyeVisibility = OpenXRCompositionLayer.EyeVisibilityEnum.Left;
> Note: Not all composition layer types or runtimes support restricting visibility to a single eye.
OpenXRSpatialAnchorCapability.create_new_anchor() gains an optional next parameter — full signature: create_new_anchor(transform: Transform3D, spatial_context: RID = RID(), next: OpenXRStructureBase = null) -> OpenXRAnchorTracker. next must be a valid next object for the XrSpatialAnchorCreateInfoEXT chain, letting vendor-specific create-info structs be appended when creating an anchor. Existing calls are unaffected (compatible change, GH-118128).
> Note: OpenXRSpatialAnchorCapability is still marked experimental — the class may change in future versions. For typical anchor placement, keep using the node-based workflow from Section 9.
| Symptom | Cause | Fix | |---------|-------|-----| | Black screen in headset | use_xr = true not set on viewport | Set in _ready() after checking XR interface | | Controller input not firing | Wrong signal name for the platform | Check OpenXR action map bindings in Project Settings | | Objects scale wrong in VR | Scene not built at real-world scale | Use 1 unit = 1 meter throughout the scene | | Motion sickness from locomotion | Smooth rotation | Use snap turning (30° increments) or add a vignette during movement | | UI unreadable in VR | Panel too far away or too small | Place UI at 1–2m distance, use SubViewport at 1024+ resolution | | Hand tracking jittery | Raw joint data used directly | Apply smoothing (lerp toward new position each frame) | | Export fails on Quest | Missing Android build template or wrong architecture | Install Android Build Template; enable arm64; set API level 29+ |
XROrigin3D → XRCamera3D + XRController3D hierarchyget_viewport().use_xr = true after interface checktrigger_click, grip_click, etc.)XRSpatialAnchor and vendor plugin spatial entities extension (Godot 4.6+)OpenXRInterface.user_presence_changed — pause/mute when the user is away (Godot 4.7+)xr/openxr/foveation_eye_tracked and xr/openxr/foveation_with_subsampled_images are on by default; disable subsampled images if you rely on FXAA/glow (Godot 4.7+)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-05 | fail→pass | 20,103 | 17,760 | -12% | 1 | 1 | 0% | 3,392 | 6,918 | +104% | 0 | 0 | — |
case-06 | fail→pass | 19,536 | 12,892 | -34% | 1 | 1 | 0% | 3,367 | 6,283 | +87% | 0 | 0 | — |
case-01 | pass→pass | 13,299 | 13,032 | -2% | 1 | 1 | 0% | 2,337 | 6,384 | +173% | 0 | 0 | — |
case-02 | pass→pass | 18,007 | 18,969 | +5% | 1 | 1 | 0% | 3,249 | 7,403 | +128% | 0 | 0 | — |
case-03 | pass→pass | 16,688 | 13,378 | -20% | 1 | 1 | 0% | 2,814 | 5,895 | +109% | 0 | 0 | — |
case-04 | fail→pass | 18,781 | 8,387 | -55% | 1 | 1 | 0% | 3,140 | 5,275 | +68% | 0 | 0 | — |
case-07 | pass→pass | 10,747 | 8,594 | -20% | 1 | 1 | 0% | 1,595 | 5,197 | +226% | 0 | 0 | — |
case-08 | pass→pass | 12,923 | 5,533 | -57% | 1 | 1 | 0% | 2,255 | 4,648 | +106% | 0 | 0 | — |
case-09 | pass→pass | 16,145 | 20,320 | +26% | 1 | 1 | 0% | 2,953 | 7,582 | +157% | 0 | 0 | — |
case-10 | fail→pass | 14,490 | 9,948 | -31% | 1 | 1 | 0% | 2,385 | 5,514 | +131% | 0 | 0 | — |
case-11 | fail→pass | 15,753 | 8,108 | -49% | 1 | 1 | 0% | 2,337 | 5,195 | +122% | 0 | 0 | — |
case-12 | pass→pass | 11,878 | 4,000 | -66% | 1 | 1 | 0% | 1,828 | 4,404 | +141% | 0 | 0 | — |
case-13 | fail→pass | 25,034 | 5,509 | -78% | 1 | 1 | 0% | 3,903 | 4,796 | +23% | 0 | 0 | — |
case-14 | pass→pass | 12,693 | 3,138 | -75% | 1 | 1 | 0% | 1,999 | 4,341 | +117% | 0 | 0 | — |
case-15 | pass→pass | 16,840 | 15,568 | -8% | 1 | 1 | 0% | 2,789 | 6,483 | +132% | 0 | 0 | — |
case-16 | pass→pass | 5,101 | 4,433 | -13% | 1 | 1 | 0% | 704 | 4,443 | +531% | 0 | 0 | — |
case-17 | pass→pass | 10,303 | 5,909 | -43% | 1 | 1 | 0% | 1,808 | 4,884 | +170% | 0 | 0 | — |
case-18 | pass→pass | 11,467 | 7,271 | -37% | 1 | 1 | 0% | 1,703 | 4,856 | +185% | 0 | 0 | — |
case-19 | fail→pass | 10,311 | 3,084 | -70% | 1 | 1 | 0% | 1,488 | 4,186 | +181% | 0 | 0 | — |
case-20 | pass→pass | 9,797 | 4,868 | -50% | 1 | 1 | 0% | 1,575 | 4,616 | +193% | 0 | 0 | — |
case-21 | pass→pass | 16,779 | 3,061 | -82% | 1 | 1 | 0% | 3,037 | 4,387 | +44% | 0 | 0 | — |
case-22 | pass→pass | 8,197 | 6,910 | -16% | 1 | 1 | 0% | 1,174 | 4,875 | +315% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.