DEV Community

vmodal_ai
vmodal_ai

Posted on

Using VR Controllers for Robot Teleoperation

Using VR Controllers for Robot Teleoperation

VR controllers (Meta Quest, HTC Vive, Valve Index, etc.) have become a popular input device for robot teleoperation, and for good reason: they provide precise 6-DOF tracking, built-in buttons/triggers for gripper control, and an intuitive mapping between human hand motion and robot end-effector motion. This tutorial covers how to integrate VR controllers into a teleoperation pipeline.

Why VR Controllers?

Compared to a spacemouse or gamepad, VR controllers give you:

  • Direct spatial mapping — moving your hand forward moves the end-effector forward, with no mode-switching between axes
  • Sub-millimeter to centimeter tracking accuracy, depending on the headset's tracking system
  • Built-in trigger and grip buttons, which map naturally to gripper open/close
  • Stereo visual feedback if you pair it with a headset display fed by the robot's cameras, enabling immersive teleoperation

The trade-off is added system complexity: you now depend on a VR runtime, and you need to handle coordinate frame conversions between VR space and robot space carefully.

Step 1: Reading Controller Pose and Input

Most VR SDKs (OpenXR, SteamVR, Oculus/Meta SDK) expose controller pose as a position + quaternion, plus button/trigger states. Here's a generic wrapper:

class VRController:
    def __init__(self, sdk_client, hand="right"):
        self.sdk_client = sdk_client
        self.hand = hand

    def read(self) -> dict:
        pose = self.sdk_client.get_controller_pose(self.hand)
        return {
            "position": pose.position,       # (x, y, z) in VR world frame
            "orientation": pose.quaternion,  # (x, y, z, w)
            "trigger": self.sdk_client.get_trigger_value(self.hand),
            "grip": self.sdk_client.get_grip_value(self.hand),
            "buttons": self.sdk_client.get_buttons(self.hand),
        }
Enter fullscreen mode Exit fullscreen mode

Step 2: Coordinate Frame Alignment

This is the step most tutorials skip and where most bugs live. The VR headset's world frame, the controller's local frame, and the robot's base frame are all different, and none of them naturally line up. You need a calibration transform.

A simple and effective approach is a one-time origin alignment: ask the operator to hold the controller at a known reference pose (e.g., directly above the robot's end-effector), then compute the transform between VR space and robot space at that moment.

import numpy as np
from scipy.spatial.transform import Rotation as R

def compute_calibration(vr_pose_at_ref, robot_pose_at_ref):
    """Both poses given as (position, quaternion) at the same physical moment."""
    vr_pos, vr_quat = vr_pose_at_ref
    robot_pos, robot_quat = robot_pose_at_ref

    R_vr = R.from_quat(vr_quat)
    R_robot = R.from_quat(robot_quat)

    R_offset = R_robot * R_vr.inv()
    t_offset = np.array(robot_pos) - R_offset.apply(vr_pos)

    return R_offset, t_offset

def vr_to_robot_frame(vr_pos, vr_quat, R_offset, t_offset):
    R_vr = R.from_quat(vr_quat)
    robot_pos = R_offset.apply(vr_pos) + t_offset
    robot_quat = (R_offset * R_vr).as_quat()
    return robot_pos, robot_quat
Enter fullscreen mode Exit fullscreen mode

Run this calibration at the start of every session — controller tracking origins can shift between headset power cycles.

Step 3: Delta-Pose Teleoperation

Rather than mapping VR pose directly to robot pose 1:1 (which can be jarring if the operator's arm reach doesn't match the robot's workspace), use relative/delta control: press and hold the grip button to "engage" control, and only the change in controller pose since engagement is applied to the robot.

class VRDeltaTeleop:
    def __init__(self, controller, robot, position_scale=1.0):
        self.controller = controller
        self.robot = robot
        self.position_scale = position_scale
        self.engaged = False
        self.ref_vr_pos = None
        self.ref_robot_pose = None

    def step(self):
        state = self.controller.read()
        engage = state["grip"] > 0.5

        if engage and not self.engaged:
            # Just started engaging: capture reference poses
            self.ref_vr_pos = np.array(state["position"])
            self.ref_robot_pose = self.robot.get_state()["ee_pose"]
            self.engaged = True

        elif not engage:
            self.engaged = False
            return None  # no motion when disengaged

        delta = (np.array(state["position"]) - self.ref_vr_pos) * self.position_scale
        target_pose = self.ref_robot_pose.copy()
        target_pose[:3] += delta

        gripper_cmd = state["trigger"]
        return target_pose, gripper_cmd
Enter fullscreen mode Exit fullscreen mode

This "clutch" mechanism (engage/disengage like picking up a mouse to reposition it) is exactly how experienced teleoperators handle limited workspace — and it noticeably reduces fatigue over long collection sessions.

Step 4: Adding Haptic and Visual Feedback

If your VR SDK exposes haptics, use them to signal contact events (e.g., gripper closing on an object, or approaching a force limit read from the robot's F/T sensor):

def send_contact_feedback(sdk_client, hand, force_magnitude):
    intensity = min(force_magnitude / 20.0, 1.0)  # normalize to [0, 1]
    sdk_client.trigger_haptic_pulse(hand, duration_ms=50, amplitude=intensity)
Enter fullscreen mode Exit fullscreen mode

For visual feedback, streaming the robot's wrist camera to the headset display (rather than a fixed monitor) significantly improves depth perception and precision for fine manipulation tasks.

Step 5: Integrating with the Data Collection Pipeline

VR teleoperation slots directly into the teleoperation and logging pipeline from the earlier tutorials — swap in VRDeltaTeleop as the input source:

def vr_teleop_loop(vr_teleop, robot, camera, logger, hz=20):
    dt = 1.0 / hz
    while True:
        start = time.time()
        result = vr_teleop.step()

        if result is not None:
            target_pose, gripper_cmd = result
            robot.send_command(target_pose, gripper_cmd)

            obs = {"image": camera.get_frame(), "ee_pose": robot.get_state()["ee_pose"]}
            logger.log(obs, target_pose, time.time())

        time.sleep(max(0, dt - (time.time() - start)))
Enter fullscreen mode Exit fullscreen mode

Practical Tips

  • Filter controller jitter. Raw VR tracking has small-scale noise; a simple exponential moving average on position/orientation smooths it out without adding noticeable lag.
  • Cap velocity, not just position. Clamp the per-step delta to a maximum magnitude to prevent sudden large jumps from tracking glitches.
  • Test calibration drift. Long sessions (30+ minutes) can accumulate small drift; consider a quick re-calibration checkpoint between recording batches.
  • Mind the latency budget. VR-to-robot pipelines add tracking + wireless (if applicable) + rendering latency on top of robot control latency — measure end-to-end latency and keep it under ~100ms for comfortable operation.

What's Next

VR teleoperation is especially powerful when paired with sensor recording and replay for debugging — covered in the next tutorial — since you can visually replay exactly what the operator saw and did.

Useful Links

Website: www.v-modal.com
SDK Flutter: https://github.com/v-modal/vmodal_sdk_flutter
SDK Android: https://github.com/v-modal/vmodal_sdk_android
Discord: https://discord.gg/K72z28KUx

Top comments (0)