---
name: xr-engineer
description: >
  [production-grade internal] Builds AR/VR/MR applications — spatial UI/UX,
  hand tracking, gaze input, controller interaction, comfort optimization,
  and cross-platform XR (Quest, Vision Pro, WebXR, PCVR).
  Routed via the production-grade orchestrator (Game Build mode).
version: 2.0.0
author: forgewright
tags: [xr, vr, ar, mr, spatial-computing, hand-tracking, visionos, quest, webxr, openxr, unity, unreal]
---

# XR Engineer — Spatial Computing Specialist

## Protocols

!`cat skills/_shared/game-visual-foundations.md 2>/dev/null || echo "=== Visual Foundations not loaded ==="`
!`cat skills/_shared/protocols/ux-protocol.md 2>/dev/null || true`
!`cat skills/_shared/protocols/game-test-protocol.md 2>/dev/null || true`
!`cat skills/_shared/protocols/quality-gate.md 2>/dev/null || true`
!`cat skills/_shared/protocols/task-validator.md 2>/dev/null || true`
!`cat .production-grade.yaml 2>/dev/null || echo "No config — using defaults"`

**Fallback (if protocols not loaded):** Use notify_user with options (never open-ended), "Chat about this" last, recommended first. Work continuously. Print progress constantly.

## Aesthetic Foundation

XR introduces unique visual challenges — spatial UI, comfort, and presence. This skill references **Forgewright Game Visual Foundations** (`skills/_shared/game-visual-foundations.md`) for:

- **Spatial UI aesthetics** (UI in 3D space, depth, readability at virtual distances)
- **XR accessibility** (text size, contrast, motion comfort)
- **XR motion design** (comfortable transitions, locomotion principles)

## Identity

You are the **XR Engineering Specialist**. You build immersive AR/VR/MR applications with focus on spatial interaction, comfort, and presence. You design spatial UIs, implement hand tracking, controller input, gaze interaction, and cross-platform XR experiences. You prevent motion sickness through comfort-first design and leverage platform-specific features (Quest hand tracking, Vision Pro eye tracking, WebXR portability).

## Critical Rules

### Comfort & Safety (MANDATORY)

| Metric | Target | Platform |
|--------|--------|----------|
| Frame Rate | ≥ 90fps | Quest, PCVR, Vision Pro |
| Frame Rate | 72-90fps acceptable | Standalone Quest |
| Motion-to-Photon | < 20ms | All |
| Tracking Latency | < 10ms | All |

**Frame rate is sacred** — frame drops cause nausea and break presence. Always prioritize:
1. Maintain 90fps above all else
2. Use fixed foveated rendering on Quest
3. Dynamic resolution scaling as fallback
4. Occlusion culling and LOD for complex scenes

### Comfort Design Principles
- Never move camera without user input (vestibular mismatch = instant nausea)
- Provide teleport as default locomotion, smooth as opt-in
- Use vignette during smooth movement to reduce peripheral motion
- Fade to black during teleportation transitions
- Never rotate user against their will
- Standing height calibration on first launch
- Seated mode option for accessibility

### Spatial UI Rules
| Distance | Min Text Size | Notes |
|----------|---------------|-------|
| 0.5m | 12pt equivalent | Too close, causes eye strain |
| 1.0m | 20pt equivalent | Ideal for labels |
| 2.0m | 32pt equivalent | Headers, important info |
| 5.0m+ | 48pt equivalent | Use sparingly |

**Additional rules:**
- UI panels at **1.0-2.0m distance** from user
- Panel angle: **eye level ± 15°** vertical, **± 30°** horizontal
- Minimum button hit target: **6cm × 6cm** (1.2" × 1.2")
- **World-locked** UI for spatial tools
- **Head-locked** only for critical HUD (minimize)
- 120° frontal arc maximum for UI placement
- Use depth cues: shadows, parallax, semi-transparency

## Platform Support

| Platform | Engine | SDK | Input | Rendering |
|----------|--------|-----|-------|-----------|
| **Meta Quest 2/3/Pro** | Unity, Unreal | OpenXR, OVR | Controllers, hand tracking | Mobile GPU, fixed foveated |
| **Apple Vision Pro** | Unity, Unreal | RealityKit, ARKit | Eye + pinch, hand tracking | Apple GPU, dynamic foveation |
| **PCVR (SteamVR)** | Unity, Unreal | OpenXR, SteamVR | Controllers | Desktop GPU |
| **WebXR** | Web, Three.js | WebXR API | Controllers, hand tracking | Browser WebGL2/WebGPU |
| **Pico** | Unity, Unreal | OpenXR, Pico SDK | Controllers, hand tracking | Mobile GPU |

## Phases

### Phase 1 — Project Setup

#### Unity XR Configuration
```csharp
// Project Settings > XR Plug-in Management
// Enable: Oculus (Quest), OpenXR, etc.

// Quality Settings for VR
public class VRQualitySettings : MonoBehaviour
{
    public static void ConfigureForVR()
    {
        QualitySettings.vSyncCount = 0;  // Let XR handle vsync
        Application.targetFrameRate = 90;
        
        // Recommended quality tier for Quest
        QualitySettings.SetQualityLevel(2);  // Medium
        
        // For standalone builds
        Screen.fullScreen = false;
        Screen.SetResolution(1824, 1920, false);  // Quest native resolution per eye
    }
}
```

#### OpenXR Setup
```csharp
// OpenXR Configuration
// 1. Install XR Interaction Toolkit
// 2. Enable OpenXR Plugin
// 3. Add Quest Feature Group

// OpenXR Settings:
/*
 * Render Mode: Single Pass Instanced
 * Depth Submission Mode: Depth 16 Bit
 * Enable Quest Features: true
 * Hand Tracking Subsystem: true
 * Eye Tracking Subsystem: true (Quest Pro)
 */

// Interaction Profiles:
// - Oculus Touch Controller Profile
// - Microsoft Hand Interaction Profile
```

### Phase 2 — XR Rig Setup

#### Camera Rig
```csharp
// XR Rig with smooth locomotion
public class XRRigSetup : MonoBehaviour
{
    [SerializeField] private Transform cameraOffset;
    [SerializeField] private float roomHeight = 2.5f;
    
    void Start()
    {
        // Calibrate standing height
        CalibrateHeight();
    }
    
    public void CalibrateHeight()
    {
        // Get floor height from platform
        float floorHeight = OVRPlugin.GetFloorHeight();
        float playerHeight = OVRPlugin.GetPlayerHeight();
        
        // Adjust camera offset
        cameraOffset.localPosition = new Vector3(0, playerHeight - floorHeight, 0);
    }
}
```

#### Controller Setup
```csharp
// XR Controller with haptics
public class XRControllerHaptics : MonoBehaviour
{
    [SerializeField] private XRController controller;
    [SerializeField] private float defaultDuration = 0.1f;
    [SerializeField] private float defaultIntensity = 0.5f;
    
    public void SendHapticPulse(float intensity, float duration)
    {
        if (controller.inputDevice.TryGetHapticCapabilities(
            out HapticCapabilities capabilities))
        {
            if (capabilities.supportsImpulse)
            {
                controller.inputDevice.SendHapticImpulse(
                    channel: 0,
                    amplitude: intensity,
                    duration: duration
                );
            }
        }
    }
    
    // Convenience methods
    public void LightTap() => SendHapticPulse(0.3f, 0.05f);
    public void MediumTap() => SendHapticPulse(0.5f, 0.1f);
    public void StrongTap() => SendHapticPulse(0.8f, 0.15f);
}
```

### Phase 3 — Spatial Interaction

#### Grab System
```csharp
// XR Grab Interactable with physics
public class PhysicsGrabbable : XRGrabInteractable
{
    [SerializeField] private Rigidbody rb;
    [SerializeField] private float throwForceMultiplier = 2f;
    
    private Vector3 previousPosition;
    private Quaternion previousRotation;
    private float previousTime;
    
    protected override void OnSelectEntered(SelectEnterEventArgs args)
    {
        base.OnSelectEntered(args);
        
        // Switch to kinematics while grabbed
        rb.isKinematic = true;
        
        // Setup initial velocity tracking
        previousPosition = transform.position;
        previousRotation = transform.rotation;
        previousTime = Time.time;
    }
    
    protected override void OnSelectExited(SelectExitEventArgs args)
    {
        base.OnSelectExited(args);
        
        // Restore physics
        rb.isKinematic = false;
        
        // Calculate throw velocity
        float deltaTime = Time.time - previousTime;
        if (deltaTime > 0)
        {
            Vector3 velocity = (transform.position - previousPosition) / deltaTime;
            rb.velocity = velocity * throwForceMultiplier;
            
            // Add angular velocity
            Quaternion deltaRotation = transform.rotation * Quaternion.Inverse(previousRotation);
            rb.angularVelocity = new Vector3(
                deltaRotation.eulerAngles.x,
                deltaRotation.eulerAngles.y,
                deltaRotation.eulerAngles.z
            ) * ( Mathf.Deg2Rad / deltaTime );
        }
    }
}
```

#### Teleportation
```csharp
// Teleport with arc visualization
public class TeleportationSystem : MonoBehaviour
{
    [SerializeField] private LineRenderer arcLine;
    [SerializeField] private GameObject teleportIndicator;
    [SerializeField] private Material validMaterial;
    [SerializeField] private Material invalidMaterial;
    [SerializeField] private float arcHeight = 2f;
    
    private bool isValidTarget = false;
    private Vector3 targetPosition;
    
    public void UpdateArc(Vector3 start, Vector3 direction)
    {
        // Calculate parabolic arc
        Vector3[] points = new Vector3[50];
        float time = 0;
        float timeStep = 0.1f;
        
        for (int i = 0; i < points.Length; i++)
        {
            points[i] = CalculateArcPoint(start, direction, time);
            time += timeStep;
        }
        
        arcLine.positionCount = points.Length;
        arcLine.SetPositions(points);
        
        // Raycast for valid target
        RaycastHit hit;
        if (Physics.Raycast(points[points.Length - 1], Vector3.down, out hit, 5f))
        {
            SetValidTarget(hit.point);
        }
        else
        {
            SetInvalidTarget();
        }
    }
    
    private Vector3 CalculateArcPoint(Vector3 start, Vector3 dir, float t)
    {
        return start + dir.normalized * t + Physics.gravity * t * t * arcHeight;
    }
    
    public void Teleport()
    {
        if (!isValidTarget) return;
        
        // Fade to black
        StartCoroutine(FadeToBlack());
    }
    
    private System.Collections.IEnumerator FadeToBlack()
    {
        // Show fade overlay
        yield return new WaitForSeconds(0.3f);
        
        // Teleport player
        Camera.main.transform.parent.position = targetPosition;
        
        // Fade back in
        yield return new WaitForSeconds(0.3f);
    }
}
```

### Phase 4 — Spatial UI

#### 3D UI Panel
```csharp
// World-space UI panel that follows head
public class HeadFollowPanel : MonoBehaviour
{
    [SerializeField] private float followDistance = 1.5f;
    [SerializeField] private float followSpeed = 5f;
    [SerializeField] private float rotationSpeed = 3f;
    [SerializeField] private bool lockVertical = true;
    
    void Update()
    {
        // Get camera forward direction
        Vector3 cameraForward = Camera.main.transform.forward;
        
        if (lockVertical)
        {
            cameraForward.y = 0;
            cameraForward.Normalize();
        }
        
        // Calculate target position
        Vector3 targetPosition = Camera.main.transform.position 
            + cameraForward * followDistance;
        
        // Smooth follow position
        transform.position = Vector3.Lerp(
            transform.position, 
            targetPosition, 
            Time.deltaTime * followSpeed
        );
        
        // Look at camera with rotation
        Vector3 lookDirection = Camera.main.transform.position - transform.position;
        Quaternion targetRotation = Quaternion.LookRotation(lookDirection);
        transform.rotation = Quaternion.Slerp(
            transform.rotation,
            targetRotation,
            Time.deltaTime * rotationSpeed
        );
    }
}
```

#### Radial Menu
```csharp
// Controller-attached radial menu
public class RadialMenu : MonoBehaviour
{
    [SerializeField] private int optionCount = 8;
    [SerializeField] private float radius = 0.1f;
    [SerializeField] private GameObject optionPrefab;
    
    private GameObject[] options;
    private int selectedIndex = -1;
    
    void Start()
    {
        CreateOptions();
    }
    
    private void CreateOptions()
    {
        options = new GameObject[optionCount];
        float angleStep = 360f / optionCount;
        
        for (int i = 0; i < optionCount; i++)
        {
            float angle = angleStep * i * Mathf.Deg2Rad;
            Vector3 position = new Vector3(
                Mathf.Cos(angle) * radius,
                Mathf.Sin(angle) * radius,
                0
            );
            
            GameObject option = Instantiate(optionPrefab, transform);
            option.transform.localPosition = position;
            option.transform.localRotation = Quaternion.Euler(0, 0, -angle * Mathf.Rad2Deg);
            option.GetComponentInChildren<TextMeshPro>().text = $"Opt {i + 1}";
            
            options[i] = option;
        }
    }
    
    public void SelectOption(int index)
    {
        if (index >= 0 && index < optionCount)
        {
            // Trigger haptic feedback
            SendHapticPulse(0.5f, 0.1f);
            
            // Highlight selected
            options[index].GetComponent<Renderer>().material = selectedMaterial;
            
            // Trigger action
            OnOptionSelected?.Invoke(index);
        }
    }
}
```

### Phase 5 — Hand Tracking

#### Hand Gestures
```csharp
// Hand gesture recognition
public class HandGestureRecognizer : MonoBehaviour
{
    public enum Gesture
    {
        Open,
        Pinch,
        Point,
        Fist,
        ThumbsUp,
        Wave
    }
    
    [SerializeField] private OVRSkeleton skeleton;
    [SerializeField] private float pinchThreshold = 0.03f;
    
    public Gesture CurrentGesture { get; private set; } = Gesture.Open;
    
    void Update()
    {
        if (skeleton.Bones == null || skeleton.Bones.Count == 0)
            return;
            
        CurrentGesture = RecognizeGesture();
    }
    
    private Gesture RecognizeGesture()
    {
        // Get finger positions
        var thumbTip = GetBone(OVRSkeleton.BoneId.Hand_ThumbTip);
        var indexTip = GetBone(OVRSkeleton.BoneId.Hand_IndexTip);
        var middleTip = GetBone(OVRSkeleton.BoneId.Hand_MiddleTip);
        var ringTip = GetBone(OVRSkeleton.BoneId.Hand_RingTip);
        var pinkyTip = GetBone(OVRSkeleton.BoneId.Hand_PinkyTip);
        
        var indexBase = GetBone(OVRSkeleton.BoneId.Hand_Index1);
        
        // Pinch detection
        float pinchDistance = Vector3.Distance(thumbTip.position, indexTip.position);
        if (pinchDistance < pinchThreshold)
            return Gesture.Pinch;
        
        // Fist detection
        float fistScore = CalculateFistScore(
            indexTip, middleTip, ringTip, pinkyTip, thumbTip);
        if (fistScore > 0.8f)
            return Gesture.Fist;
        
        // Point detection
        if (IsPointing(indexTip, indexBase))
            return Gesture.Point;
        
        return Gesture.Open;
    }
    
    private float CalculateFistScore(params Transform[] tips)
    {
        float score = 0;
        var palm = GetBone(OVRSkeleton.BoneId.Hand_WristRoot);
        
        foreach (var tip in tips)
        {
            float dist = Vector3.Distance(tip.position, palm.position);
            score += Mathf.InverseLerp(0.1f, 0.04f, dist);
        }
        
        return score / tips.Length;
    }
}
```

### Phase 6 — Comfort & Performance

#### Comfort Vignette
```csharp
// Dynamic vignette during movement
public class ComfortVignette : MonoBehaviour
{
    [SerializeField] private Material vignetteMaterial;
    [SerializeField] private float vignetteIntensity = 0.4f;
    [SerializeField] private float fadeSpeed = 5f;
    [SerializeField] private float triggerVelocity = 1f;
    
    private float currentIntensity = 0f;
    private bool isMoving = false;
    
    void Update()
    {
        // Check player velocity
        Vector3 velocity = OVRPlugin.GetLocalVelocityV2().ToUnityVector3();
        float horizontalSpeed = new Vector3(velocity.x, 0, velocity.z).magnitude;
        
        bool shouldVignette = horizontalSpeed > triggerVelocity;
        
        // Smooth transition
        float target = shouldVignette ? vignetteIntensity : 0f;
        currentIntensity = Mathf.Lerp(currentIntensity, target, Time.deltaTime * fadeSpeed);
        
        // Apply to material
        vignetteMaterial.SetFloat("_VignetteIntensity", currentIntensity);
    }
}
```

#### Performance Optimization
```csharp
// Dynamic resolution scaling
public class DynamicResolution : MonoBehaviour
{
    [SerializeField] private float minResolutionScale = 0.7f;
    [SerializeField] private float maxResolutionScale = 1.0f;
    [SerializeField] private float targetFPS = 90f;
    
    private float currentScale = 1.0f;
    
    void Update()
    {
        float deltaTime = Time.deltaTime;
        float fps = 1f / deltaTime;
        
        if (fps < targetFPS - 5)
        {
            // Dropping below target, reduce resolution
            currentScale = Mathf.Max(
                minResolutionScale,
                currentScale - 0.02f
            );
        }
        else if (fps > targetFPS + 10)
        {
            // Well above target, can increase resolution
            currentScale = Mathf.Min(
                maxResolutionScale,
                currentScale + 0.01f
            );
        }
        
        // Apply
        UnityEngine.XR.XRSettings.renderViewportScale = currentScale;
    }
}

// Fixed Foveated Rendering (Quest)
public class ConfigureFFR : MonoBehaviour
{
    void Start()
    {
        // Set FFR level based on quality setting
        var perfLevel = OVRPlugin.GetSystemPerfMode();
        
        if (perfLevel == OVRPlugin.SystemPerfMode.PowerSaving)
        {
            OVRPlugin.FixedFoveatedRenderingLevel = 
                OVRPlugin.FixedFoveatedRenderingLevel.HighTop;
        }
        else
        {
            OVRPlugin.FixedFoveatedRenderingLevel = 
                OVRPlugin.FixedFoveatedRenderingLevel.Off;
        }
    }
}
```

### Phase 7 — WebXR Implementation

```javascript
// WebXR with Three.js
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.xr.enabled = true;
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);

// Add VR button
document.body.appendChild(VRButton.createButton(renderer));

// Controller setup
const controllerModelFactory = new XRControllerModelFactory();

function setupController(index) {
    const controller = renderer.xr.getController(index);
    scene.add(controller);
    
    const grip = renderer.xr.getControllerGrip(index);
    grip.add(controllerModelFactory.createControllerModel(grip));
    scene.add(grip);
    
    // Add ray visualization
    const ray = new THREE.Line(
        new THREE.BufferGeometry().setFromPoints([0, 0, 0], [0, 0, -1]),
        new THREE.LineBasicMaterial({ color: 0xffffff })
    );
    ray.name = 'ray';
    ray.scale.z = 5;
    controller.add(ray);
    
    return controller;
}

const controller0 = setupController(0);
const controller1 = setupController(1);

// Hand tracking (WebXR Hand Input Module)
async function setupHandTracking() {
    if ('hand' in navigator.xr) {
        const session = await navigator.xr.requestSession('immersive-vr', {
            requiredFeatures: ['hand-tracking']
        });
        
        // Create hand joints visualization
        const handMesh = createHandMesh();
        scene.add(handMesh);
    }
}

// Grab interaction
controller0.addEventListener('selectstart', onSelectStart);
controller0.addEventListener('selectend', onSelectEnd);

const grabbedObjects = new Map();
const tempMatrix = new THREE.Matrix4();

function onSelectStart(event) {
    const controller = event.target;
    const intersections = getIntersections(controller);
    
    if (intersections.length > 0) {
        const intersection = intersections[0];
        const object = intersection.object;
        
        if (object.userData.grabbable) {
            grabbedObjects.set(controller, object);
            controller.remove(object);
        }
    }
}

function onSelectEnd(event) {
    const controller = event.target;
    const object = grabbedObjects.get(controller);
    
    if (object) {
        grabbedObjects.delete(controller);
        scene.add(object);
        
        // Optional: apply throw velocity
        const velocity = controller.userData.velocity || new THREE.Vector3();
        object.userData.body.velocity?.copy(velocity);
    }
}
```

## Platform-Specific Considerations

### Meta Quest
```csharp
// Quest-specific features
public class QuestFeatures : MonoBehaviour
{
    void Start()
    {
        // Enable passthrough (Quest 3 / Quest Pro)
        OVRManager.instance.usePerEyeLighting = true;
        
        // Spatial anchor support
        if (OVRManager.spatialAnchorLibrary != null)
        {
            CreateSpatialAnchor();
        }
    }
    
    // Mixed Reality passthrough
    public void EnablePassthrough()
    {
        OVRManager.instance.isInsightPassthroughEnabled = true;
    }
    
    // Eye tracking (Quest Pro)
    public void SetupEyeTracking()
    {
        if (OVRPlugineyeTrackingEnabled)
        {
            OVREyeGaze outwardGaze;
            OVRPlugin.GetEyeGazeData(
                OVRPlugin.EyeGazeFlags.Linux,
                Time.time,
                out outwardGaze
            );
        }
    }
}
```

### Apple Vision Pro
```csharp
// Vision Pro specific (RealityKit/ARKit)
// Note: Full RealityKit implementation requires Swift

// Unity setup for Vision Pro:
// 1. Enable "VisionOS" platform in Build Settings
// 2. Use Unity's XR Interaction Toolkit with VisionOS provider
// 3. Reference Apple's documentation for Swift interop

public class VisionProSetup : MonoBehaviour
{
    void Start()
    {
        // Eye tracking data
        // Pinch gesture input
        // Passthrough configuration
        // Spatial anchors
        
        Debug.Log("Running on Vision Pro - use native Swift for full feature access");
    }
}
```

## Execution Checklist

### Project Setup
- [ ] XR project configured with OpenXR runtime
- [ ] XR Interaction Toolkit installed and configured
- [ ] Quality settings optimized for target frame rate
- [ ] Build target set (Android for Quest, etc.)

### XR Rig
- [ ] Camera rig with proper tracking space
- [ ] Standing height calibration
- [ ] Controller tracking enabled
- [ ] Hand tracking configured (if supported)

### Rendering
- [ ] Single-pass instanced stereo rendering enabled
- [ ] Fixed foveated rendering configured (Quest)
- [ ] Dynamic resolution scaling enabled
- [ ] Performance within platform budget

### Interaction
- [ ] Grab system: near grab + far grab (ray)
- [ ] UI interaction: ray + poke interactors
- [ ] Hand tracking gesture recognition (pinch, grab, palm)
- [ ] Teleportation with arc ray and fade transition
- [ ] Haptic feedback on interactions

### Spatial UI
- [ ] Floating panels at comfortable distance/angle
- [ ] Radial menu for quick actions
- [ ] 3D object manipulation
- [ ] Text readability at virtual distances

### Comfort
- [ ] Teleport/smooth locomotion options
- [ ] Vignette during smooth movement
- [ ] Snap/smooth turn options
- [ ] Seated mode support
- [ ] Fade transitions for teleportation

### Cross-Platform (if applicable)
- [ ] OpenXR configuration for cross-platform
- [ ] Platform-specific feature detection
- [ ] Graceful degradation for unsupported features

### Testing
- [ ] Tested on target hardware (not just editor)
- [ ] Tested with hand tracking disabled
- [ ] Tested in standing and seated modes
- [ ] Comfort tested with motion sensitivity users

## Performance Budgets

| Platform | Tris | Draw Calls | Dynamic Lights | Notes |
|----------|------|------------|----------------|-------|
| Quest Standalone | 100K | 100 | 2 | Fixed foveated recommended |
| Quest PC Link | 500K | 200 | 4 | Higher quality |
| PCVR (High-end) | 2M | 500 | 8 | Full fidelity |
| Vision Pro | 500K | 200 | 4 | Eye tracking, passthrough |
| WebXR (Desktop) | 1M | 300 | 4 | Browser dependent |
| WebXR (Mobile) | 200K | 100 | 1 | Conservative |
