Written by: Ellie Na (2026)
In this tutorial, I will walk through how to create a 3D annotation drawing system in Unity that is triggered by a custom hand gesture created in the previous step. (Note that this implementation assumes the custom gesture setup described earlier, so it may require adjustments depending on your own project configuration.)
Meta Quest 3 headset
Unity
Hand tracking is enabled in the Unity project
(refer to the Unity Hand Gesture Tracking Setup page for setup instructions)
A custom hand gesture has been created
(refer to the Custom Hand Gesture Creation tutorial)
To create a pen-like object,
- create a cylinder as a body
- create capsule as a pointer
- create an empty game object as a tip that will be the exact point where the drawing starts
2. Create an empty object that will manage the drawing logic. Create an empty GameObject > Rename it Drawing System > Add Component (I named it 'drawWhileThreePinch' as a new empty script > Replace the script with this code
(Brief decription of the script: It creates a 3D drawing system triggered by a three-pinch gesture. The pencil position is derived from the midpoint between the thumb and index finger, while the finger directions define the orientation. A LineRenderer generates the stroke as the pencil tip moves through space.)
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.Hands;
public class DrawWhileThreePinch : MonoBehaviour
{
[Header("Hand Source")]
public XRHandTrackingEvents handTrackingEvents; // Right or left hand tracking source
[Header("Gesture")]
public DetectGesture threePinchDetector; // DetectGestures_Right / DetectGestures_Left
[Header("Pointer (Pencil)")]
public Transform pencilRoot; // Pencil_R / Pencil_L
public Transform pencilTip; // PencilRoot/Tip
public Vector3 tipPositionOffset = new Vector3(0, 0, 0.02f);
[Header("Drawing")]
public LineRenderer linePrefab; // StrokePrefab
public float minDistance = 0.01f;
public float lineWidth = 0.01f;
[Header("Stabilization")]
[Tooltip("0 = no filtering. Recommended range: 0.15 ~ 0.3 (higher = smoother motion)")]
public float rotationSmoothing = 0.2f;
[Tooltip("Apply correction here if the pencil model's local axis is not aligned with forward (e.g., (0,90,0))")]
public Vector3 pencilModelEulerOffset = Vector3.zero;
[Header("Grab Placement")]
[Tooltip("How far the pencil root should move from the thumb-index midpoint (in pencil local space)")]
public Vector3 gripLocalOffset = new Vector3(0f, 0f, 0.08f);
[Tooltip("Push the pencil slightly inward between the fingers (+ moves toward palmNormal direction)")]
public float pinchInward = 0.005f;
[Header("Latency Fix: Hard Stop")]
[Tooltip("Immediately stop drawing if the thumb-index distance exceeds this value (tune between 0.03~0.06 meters)")]
public float thumbIndexReleaseDistance = 0.04f;
LineRenderer current;
readonly List<Vector3> points = new();
bool wasDrawingMode;
Quaternion smoothedRotation = Quaternion.identity;
bool hasSmoothedRotation;
void OnEnable() => handTrackingEvents.jointsUpdated.AddListener(OnJointsUpdated);
void OnDisable() => handTrackingEvents.jointsUpdated.RemoveListener(OnJointsUpdated);
void OnJointsUpdated(XRHandJointsUpdatedEventArgs args)
{
bool drawingMode = threePinchDetector != null && threePinchDetector.IsDetected;
// Stop drawing if hand tracking is lost
if (!handTrackingEvents.handIsTracked) drawingMode = false;
// Hard stop: even if the gesture detector is slow to update,
// immediately stop drawing when thumb and index separate
if (drawingMode)
{
if (TryGetJointPose(args.hand, XRHandJointID.ThumbTip, out Pose thumbTipPose) &&
TryGetJointPose(args.hand, XRHandJointID.IndexTip, out Pose indexTipPoseForStop))
{
float d = Vector3.Distance(thumbTipPose.position, indexTipPoseForStop.position);
if (d > thumbIndexReleaseDistance)
drawingMode = false;
}
}
// Start drawing when gesture begins
if (drawingMode && !wasDrawingMode)
{
SetPencilVisible(true);
BeginStroke();
}
// Stop drawing when gesture ends
else if (!drawingMode && wasDrawingMode)
{
SetPencilVisible(false);
EndStroke();
hasSmoothedRotation = false;
wasDrawingMode = drawingMode;
return;
}
wasDrawingMode = drawingMode;
if (!drawingMode) return;
// ===== 1) Retrieve required joint poses =====
if (!TryGetJointPose(args.hand, XRHandJointID.IndexTip, out Pose indexTipPose)) return;
// Retrieve joints to compute thumb and index directions
// thumb: ThumbMetacarpal -> ThumbTip
// index: IndexProximal -> IndexTip
if (!TryGetJointPose(args.hand, XRHandJointID.ThumbMetacarpal, out Pose thumbBase)) return;
if (!TryGetJointPose(args.hand, XRHandJointID.ThumbTip, out Pose thumbTip)) return;
if (!TryGetJointPose(args.hand, XRHandJointID.IndexProximal, out Pose indexBase)) return;
if (!TryGetJointPose(args.hand, XRHandJointID.IndexTip, out Pose indexTip2)) return;
Vector3 thumbDir = (thumbTip.position - thumbBase.position);
Vector3 indexDir = (indexTip2.position - indexBase.position);
if (thumbDir.sqrMagnitude < 1e-6f || indexDir.sqrMagnitude < 1e-6f) return;
thumbDir.Normalize();
indexDir.Normalize();
// ===== 2) Pencil forward direction: average of thumb and index directions =====
Vector3 forward = (thumbDir + indexDir);
// If the vectors oppose each other, fall back to index direction
if (forward.sqrMagnitude < 1e-6f)
{
forward = indexDir;
}
forward.Normalize();
// ===== 3) Pencil up direction: normal of the pinch plane =====
Vector3 palmNormal = Vector3.Cross(indexDir, thumbDir);
// If vectors are nearly collinear, fall back to hand up direction
if (palmNormal.sqrMagnitude < 1e-6f)
{
palmNormal = args.hand.rootPose.rotation * Vector3.up;
}
palmNormal.Normalize();
// Ensure consistent normal orientation between left and right hands
if (Vector3.Dot(Vector3.Cross(forward, palmNormal), args.hand.rootPose.rotation * Vector3.up) < 0f)
palmNormal = -palmNormal;
Quaternion targetRot = Quaternion.LookRotation(forward, palmNormal);
// Apply model axis correction if the pencil prefab is not aligned with +Z
if (pencilModelEulerOffset != Vector3.zero)
targetRot = targetRot * Quaternion.Euler(pencilModelEulerOffset);
// ===== 4) Rotation smoothing to reduce jitter =====
if (!hasSmoothedRotation)
{
smoothedRotation = targetRot;
hasSmoothedRotation = true;
}
else if (rotationSmoothing > 0f)
{
float t = 1f - Mathf.Exp(-rotationSmoothing / Mathf.Max(Time.deltaTime, 1e-6f));
smoothedRotation = Quaternion.Slerp(smoothedRotation, targetRot, t);
}
else
{
smoothedRotation = targetRot;
}
// ===== 5) Pencil position: midpoint of thumb and index =====
Vector3 gripMid = (thumbTip.position + indexTip2.position) * 0.5f;
UpdatePencilPose(gripMid, palmNormal, smoothedRotation);
// ===== 6) Add drawing point =====
Vector3 drawPoint = (pencilTip != null) ? pencilTip.position : indexTipPose.position;
AddPoint(drawPoint);
}
static bool TryGetJointPose(XRHand hand, XRHandJointID id, out Pose pose)
{
pose = default;
XRHandJoint j = hand.GetJoint(id);
return j.TryGetPose(out pose);
}
void UpdatePencilPose(Vector3 gripMidPos, Vector3 palmNormal, Quaternion rot)
{
if (pencilRoot == null) return;
// 1) Use the midpoint between thumb and index as the anchor
Vector3 pos = gripMidPos;
// 2) Push the pencil slightly inward toward the pinch plane
pos += palmNormal * pinchInward;
// 3) Adjust position so the pencil root aligns with the grip position
pos += rot * gripLocalOffset;
pencilRoot.SetPositionAndRotation(pos, rot);
}
void SetPencilVisible(bool visible)
{
if (pencilRoot != null) pencilRoot.gameObject.SetActive(visible);
}
void BeginStroke()
{
if (linePrefab == null) return;
current = Instantiate(linePrefab);
current.numCapVertices = 12;
current.numCornerVertices = 12;
current.useWorldSpace = true;
current.startWidth = lineWidth;
current.endWidth = lineWidth;
points.Clear();
current.positionCount = 0;
}
void AddPoint(Vector3 p)
{
if (current == null) return;
if (points.Count > 0 && Vector3.Distance(points[^1], p) < minDistance) return;
points.Add(p);
current.positionCount = points.Count;
current.SetPosition(points.Count - 1, p);
}
void EndStroke()
{
current = null;
points.Clear();
}
}
3. After adding the script, connect the required objects in the Inspector.
- Hand Source → Right Hand Tracking
- Three Pinch Detector → DetectGestures_Right
- Pencil Root → Cylinder
- Pencil Tip → tip
4. To prepare the stroke prefab, You should create an object that will be used to render annotation lines.
Create an empty GameObject > Rename it StrokePrefab > Add a Line Renderer component > Enable Use World Space > Set a small line width (I set this to 0.004) > Drag this object into the Assets folder to create a prefab
5. Connect the stroke prefab. Return to Drawing System and assign the prefab. Line Prefab → StrokePrefab.
5. Run the scene and perform the three-pinch gesture.