简介:Unity LeapMotion投篮Demo是一份面向Unity开发者和体感交互初学者的实操案例包,重点展示如何利用LeapMotion手势识别技术,在虚拟场景中实现自然、直观的投篮操作。压缩包大小约35.43MB,可直接下载后导入Unity运行学习。目前已有463人学习/下载,适合希望掌握手势交互与物理模拟结合方法的读者。Demo完整演示了从手部骨骼数据解析、托球与投掷手势判定,到篮球抛物线飞行与得分判定的整个交互流程,涉及LeapMotion SDK集成、C#控制脚本编写、Unity物理引擎调参、以及音效与UI反馈设计等核心环节。通过研读工程结构和关键脚本,可学会如何平滑映射手部运动、设置投篮力度阈值、利用刚体组件模拟篮球轨迹,同时了解优化手势识别性能与多环境调试的常见策略,为后续开发更复杂的体感游戏打下基础。
1. Unity与LeapMotion投篮Demo:手势识别投篮这件事,值得认真拆一遍
拿到这个Unity与LeapMotion投篮Demo的第一反应,是"这不就是个把识别到的手部动作映射到球上的示例吗"。但真正跑起来之后你会发现,手部识别和投篮手感是两个完全不同的问题:识别只要做到"知道手在动",而投篮要做到"知道手在什么时刻用多大力度往哪个方向甩出去"。这个Demo把这两层分开处理,用LeapMotion的手部骨骼数据做速度判定,用Unity的物理引擎处理抛射轨迹,中间还卡着坐标系对齐、脱手时机、篮筐碰撞几个坑。对想搞手势交互、体感游戏或虚拟现实操作模拟的开发者来说,这套拆解比单个投篮效果本身更有价值,值得照着复现一遍。
2. 环境与数据接入:把LeapMotion骨骼数据变成Unity能用的输入
2.1 版本匹配与SDK初始化
先聊第一个高频翻车点:SDK的版本和Unity版本匹配问题。LeapMotion的SDK在Unity里有对应的核心资源包,导入之后会自动挂上LeapProvider、LeapServiceProvider这类组件。我一般建议先确认两件事:一是Unity本身用的是哪个渲染管线和输入系统,二是Package Manager里已经装过的XR或Input System版本。LeapMotion的例程大多是老式Input Manager写法,如果你项目同时开了新版Input System,又没做兼容,就会出现手部数据一直为零的情况——第一帧就没有Hand,后面所有逻辑全是空转。这个状态不报错,只能靠打印帧数才能发现。
等版本匹配没问题,再看场景搭建。常见做法是场景里挂一个LeapServiceProvider,把Tracking Mode设成Desktop或HeadMounted。桌面模式适合摄像头平放,头戴模式适合头盔上安装。这个Demo是桌面投射场景,摄像头放在屏幕下方或侧前方,朝上倾斜一个角度,让手在抬手投篮的整个轨迹里都不出视野。
接下来初始化代码:
using Leap; using Leap.Unity; using UnityEngine; public class HandInputReader : MonoBehaviour { private LeapProvider provider; private void Start() { // 场景里必须存在 LeapProvider 或 LeapServiceProvider provider = FindObjectOfType<LeapProvider>(); if (provider == null) { Debug.LogError("未找到 LeapProvider,请确认 SDK 组件已挂载"); } } private void Update() { // CurrentFrame 拿到的是一整帧的手部追踪数据 Frame frame = provider.CurrentFrame; if (frame == null || frame.Hands.Count < 1) { return; // 没有手,直接跳过 } Hand hand = frame.Hands[0]; Debug.Log($"掌心位置: {hand.PalmPosition}"); } }这段代码的逻辑很简单:每帧从provider拿一次Frame,Frame里包含手、手指、骨骼等信息。PalmPosition是Leap坐标系下的掌心位置,单位是毫米。注意这里提到毫米,是因为后续如果直接把这个数值塞给Unity的Transform,会出现位置放大了1000倍的怪异现象,需要做一个毫米到米的换算,也就是除以1000,或者记作Vector3.Scale之类的处理。
参数方面,LeapProvider在Inspector面板里有个Tracking Mode选项,Desktop模式下的识别距离一般在25毫米到600毫米之间,超过这个范围会出现骨骼抖动,这个边界在搭场景时就要考虑进去。
2.2 手部骨骼数据读取与映射
LeapMotion的手部模型由掌心加五根手指组成。每根手指有四个骨节:指掌骨、近节指骨、中节指骨、远节指骨。在Unity里对应的是Finger.Bone(BoneType)这个方法,能拿到每一节的长度、方向和旋转矩阵。做投篮Demo时,最常用的是指尖与掌心的相对位置,以及手指弯曲程度。
读取手指骨节的代码可以这样写:
// 以食指为例,读取近节指骨与远节指骨方向 using Leap; using UnityEngine; public class FingerBendReader : MonoBehaviour { public void ReadBend(Hand hand) { Finger finger = hand.Fingers[(int)Finger.FingerType.TYPE_INDEX]; Bone proximal = finger.Bone(Bone.BoneType.TYPE_PROXIMAL); Bone distal = finger.Bone(Bone.BoneType.TYPE_DISTAL); // 两个骨节方向向量的夹角就是弯曲程度 Vector3 proximalDir = proximal.Direction.ToVector3(); Vector3 distalDir = distal.Direction.ToVector3(); float bendAngle = Vector3.Angle(proximalDir, distalDir); Debug.Log($"食指弯曲角: {bendAngle}"); } }逻辑很直接:骨节的Direction是该骨节在Leap坐标系下的朝向向量,两个向量夹角越大,说明手指弯曲越厉害。BendAngle到50度以上基本就属于握拳状态,投篮时手指一般是半屈的,大约30到50度。
这里有个容易忽略的点:Leap坐标系是右手坐标系,X轴向右,Y轴向上,Z轴朝向使用者。Unity是左手坐标系,X轴向右,Y轴向上,Z轴朝向屏幕内。两者不能直接互换。一般情况下,把Leap的旋转量和位置量直接丢给Unity,会出现轴向反转现象。我习惯用一张对照表来对齐:
| Leap轴向 | Unity轴向 | 说明 |
|---|---|---|
| +X | +X | 左右一致 |
| +Y | +Y | 上下一致 |
| +Z | -Z | 朝向相反,需要取反 |
位置坐标同理:Leap的Z值取负之后再赋给Unity的Z轴。方向向量也一样处理,尤其是PalmNormal和PalmVelocity这两个字段,不取反的话球会飞向你身后。
2.3 掌心朝向、手速与手型关键帧
手型识别是做投篮判定前的最后一步。很多开发者会把"握住球"理解成"握拳",实际不是。投篮前的手型应该是五指自然张开、掌心朝前,手指微微弯曲。LeapMotion提供的PalmNormal是掌心平面的法线,也就是掌面朝向,PalmVelocity是手掌移动速度。
判断手型是否满足投篮准备,我用的是三个关键帧条件:
// 判断当前手势是否符合"持球准备"状态 private bool IsHoldingPose(Hand hand) { // 掌心向上倾斜,配合一个合理阈值 Vector3 palmNormal = hand.PalmNormal.ToVector3(); float upFactor = Vector3.Dot(palmNormal, Vector3.up); bool facingUp = upFactor > 0.3f; // 检查拇指、食指、中指的弯曲程度 float bend = 0f; for (int i = 0; i < 3; i++) { Finger f = hand.Fingers[i]; Bone b1 = f.Bone(Bone.BoneType.TYPE_PROXIMAL); Bone b2 = f.Bone(Bone.BoneType.TYPE_MEDIAL); bend += Vector3.Angle(b1.Direction.ToVector3(), b2.Direction.ToVector3()); } bend /= 3f; // 弯曲角在 30~60 之间算半屈,满足持球手型 return facingUp && bend > 30f && bend < 60f; }这段代码把掌心朝向和手指弯曲一起判断,避免把手背朝上或握拳状态误判为投篮准备。向量点积结果在-1到1之间,upFactor大于0.3意味着掌心基本朝上或斜上方。
实际调试的时候,我会在场景里把IsHoldingPose的结果画在屏幕上,写成布尔量。这样自己抬手晃一晃,能看到状态切换点在哪里。LeapMotion的帧率在60到120帧,手型切换的瞬间会有三四帧的过渡期,不要在这个区间做释放判定,否则会把抓球和投篮混在一起。
3. 投篮判定核心:脱手时机、投掷力度与球体物理
3.1 脱手判定:速度峰值与时间窗口
投篮手感最关键的就是出手时机。LeapMotion给的是PalmVelocity,单位是米每秒,但直接看速度很容易被瞬时抖动干扰。我采用的脱手判定算法是这样的:先记录手在持球准备状态下的每一帧速度,设定一个释放阈值,当速度连续几帧高于阈值,并且已经越过峰值开始减速时,才判定为出手。
public class ShootDetector : MonoBehaviour { [SerializeField] private float releaseThreshold = 1.5f; [SerializeField] private float peakWindow = 0.15f; private float peakSpeed = 0f; private bool isArmed = false; private float windowTimer = 0f; private HandInputReader input; private void Update() { Hand hand = input.GetPrimaryHand(); if (hand == null) { isArmed = false; return; } float speed = hand.PalmVelocity.ToVector3().magnitude; // 速度超过阈值,进入待释放状态 if (!isArmed && speed > releaseThreshold) { isArmed = true; peakSpeed = speed; windowTimer = peakWindow; } if (!isArmed) return; // 记录峰值 if (speed > peakSpeed) { peakSpeed = speed; windowTimer = peakWindow; } // 时间窗耗尽,判定为有效出手 windowTimer -= Time.deltaTime; if (windowTimer <= 0f) { OnShoot(hand); isArmed = false; } } }这个逻辑的核心是:一个"峰值窗口"概念。投篮动作从启动到结束大约在0.2到0.3秒之间,真正脱手的一瞬间是速度达到峰值之后自然回落的那一刻。窗口太长会把缓慢抬手也判定成出手,窗口太短又会在速度还没有完全降下来时提前触发。0.15秒是我在多个动作下试出来的比较稳定的值,如果你的项目里手臂动作幅度更大,可以适当放到0.2秒。
触发投篮后,回调里要拿到的是脱手时的速度和方向。这里有个细节:PalmVelocity在脱手瞬间已经比峰值低了一些,直接用当前帧的速度会导致球的初速度偏小。我的做法是把peakSpeed和当前速度做一个加权,大约七三开,让球的初速度更接近真实出手力量。
3.2 投掷方向与力度计算
投掷方向不应该用某一帧的瞬时速度,而应该用出手前一小段位移的平均方向。这里有一个普遍容易踩的坑:手掌最后的朝向不等于球的飞行方向,投出去的那一刻,掌心会有一个向前甩的鞭打动作,瞬时速度方向可能偏上,但整体轨迹方向相对稳定。
计算投掷方向与力度:
// 记录出手前 0.1 秒内的手部位置,计算平均位移 public Vector3 CalculateReleaseVelocity(Hand hand, float historyWindow = 0.1f) { List<Vector3> positions = handPositions.GetRecent(historyWindow); if (positions.Count < 2) return Vector3.zero; Vector3 start = positions[0]; Vector3 end = positions[positions.Count - 1]; // 位移除以时间,得到平均速度向量 Vector3 velocity = (end - start) / historyWindow; // 力度上限,防止快速挥手时初速度爆表 float magnitude = Mathf.Clamp(velocity.magnitude, 3f, 20f); Vector3 direction = velocity.normalized; return direction * magnitude; }代码里用了一段历史位置缓存来计算平均速度,而不是用LeapMotion的PalmVelocity字段。因为PalmVelocity在帧数据丢失或手部短暂遮挡的时候偶尔会蹦出一个异常大值,而用历史位移滑动平均能得到更平滑的结果。这里的handPositions是一个环形缓存队列,我通常保留12帧左右的数据,按帧率60fps算正好覆盖0.1秒到0.2秒的轨迹。
力度范围3到20米每秒是篮球投射的合理区间。太低了球根本到不了篮筐,太高了物理引擎会出问题。物理模拟时,每秒20米相当于一个很重的暴扣,现实当中人类的甩手速度上限大约在每秒十几米,这个上限设得再高也没意义,反而会让球的轨迹失真。
3.3 球体刚体与碰撞体参数设置
投篮Demo里的篮球是一个SphereCollider加Rigidbody。刚体参数看起来简单,但影响手感的主要有三个:碰撞检测模式、插值方式和阻力系数。
private void SetupBasketball(Rigidbody rb) { // 快速运动的球必须用连续碰撞检测 rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; rb.interpolation = RigidbodyInterpolation.Interpolate; rb.mass = 0.6f; rb.drag = 0.05f; rb.angularDrag = 0.1f; }逐项解释一下。碰撞检测模式设成ContinuousDynamic,是因为球在出手瞬间速度可以到每秒十几米,以60帧计算,每帧移动距离超过0.2米,而篮筐边框的厚度可能只有0.05米,离散碰撞检测会直接穿过去。ContinuousDynamic会做射线扫描,避免高速穿透。Interpolate是让刚体位置在渲染帧之间平滑插值,否则看到的球会有轻微顿挫感。质量0.6公斤在现实里偏轻,但在游戏手感里能让球被篮筐弹开时更跟手。阻力值不要设到0,保留一点点空气阻力能让长距离投篮的轨迹末端稍微下垂,更接近真实手感。
篮筐的碰撞体建议用MeshCollider,不要用BoxCollider叠加。MeshCollider贴合篮筐边缘和篮网的形状,球弹到筐边会有一个自然的反弹效果。但MeshCollider不能和普通的Rigidbody球做快速碰撞时开Concave模式,否则又会出现穿透。
物理参数调优的时候,我习惯开一个独立测试场景,屏蔽所有输入逻辑,只用一个初始速度向量把球投出去。这样能先排除识别层的干扰,单独验证物理层是否合理。物理层稳定了,再接回LeapMotion数据,问题定位会清晰很多。
4. 常见问题排查:误投、抖动与篮筐穿透五个实战案例
4.1 手部抖动导致误投
现象:手没有明显投篮动作,球偶尔自己飞出去。尤其是站在电脑前正常晃动鼠标时,球会突然脱手。
原因:LeapMotion在快速变速时会输出瞬时速度尖峰。即使手掌只是快速从画面一侧划到另一侧,速度也可能突破1.5米每秒的阈值。根因是阈值判定只用了当前帧速度,没考虑前后连续性和手型条件。
解决:把脱手条件改成三要素同时满足:手型处于持球准备状态、速度超过阈值、时间窗口内有明显的加速上升段,而不是一个孤立的峰值。另外,将速度阈值从1.5提高到2.0,排除大部分正常移动。
提示:排查的时候可以把当前速度值实时打印在屏幕上,观察正常操作下的速度范围,按这个范围加个20%余量作为阈值。
4.2 脱手判定总是晚半拍
现象:动作已经做完了,球才慢悠悠地飞出来,感觉像是延迟了100到200毫秒。
原因:出手判定放在减速段结束的窗口末点。一旦手在触发投篮后立刻停住,窗口计时还没走完,球就不能及时释放。再加上我前面提到的加权初速度只在回调里算了一次,错过峰值之后速度已经掉了很多。
解决:把出手时机改到速度峰值之后的第一帧,也就是isArmed状态维持到窗口前半段时,检测到速度开始下降就直接trigger,等窗口是为了兜底,不是必须等满。具体做法是:当currentSpeed < peakSpeed * 0.7时提前结束窗口。
4.3 篮球穿透篮筐
现象:正常投篮后球从篮筐中心飞过,没有弹筐,也没有得分,直接从筐下面穿出去了。
原因:球体碰撞检测模式是Discrete。当球速超过每帧移动距离,大于篮筐边框厚度时,物理引擎会认为球在上一帧和下一帧之间直接跳过了碰撞体,于是穿透。
解决:见3.3节,把球体Rigidbody的CollisionDetectionMode改成ContinuousDynamic,同时确保篮筐MeshCollider不是Convex。如果依然穿透,把球体SphereCollider的Radius加大10%,用半径补偿边框的物理厚度。
4.4 坐标系轴向错位导致投篮方向混乱
现象:在画面右侧抬手投篮,球往左前方飞;或者屏幕上方的动作被识别成左右方向。
原因:LeapMotion是右手坐标系,Unity是左手坐标系,没有做Z轴取反和轴向映射。PalmPosition、PalmVelocity、PalmNormal三个字段都必须映射,只映射其中一个会出现混合错乱。
解决:写一个统一的坐标转换函数:
// Leap 右手坐标转 Unity 左手坐标 public Vector3 LeapToUnity(Vector3 leapVec) { return new Vector3(leapVec.x, leapVec.y, -leapVec.z); }所有从Leap拿出来的向量,包括位置、速度、方向,都过这一层校准。最后再乘一个毫米转米的系数,确保位置量级正确。
4.5 不同摆动弧线投出去的球轨迹不稳定
现象:相同手势力度,有时球投得高,有时投得平,落点分散很大。
原因:出手判定点固定在速度峰值帧,但峰值帧的瞬时速度方向受手臂末端动作影响很大。挥手抛物线和前推动作在峰值时的方向差异达到30度以上,直接用峰值做方向和力度计算,轨迹自然不稳定。
解决:用出手前0.1秒的位置平均向量代替瞬时速度向量,也就是3.2节的CalculateReleaseVelocity方法。把峰值帧只当作触发信号,方向和力度从历史轨迹里提取,这样能把落点分散度明显收窄。
5. 投篮精度调优:轨迹可视化、辅助瞄准与手感参数化
5.1 轨迹预测:用物理积分画出一条预测线
调试投篮手感最大的问题是,你永远不知道当前这个力度的球会不会进筐。与其一遍遍实投看结果,不如先在场景里画出预测轨迹。用简单的物理积分就能做,不需要每帧调用Physics.Simulate。
using UnityEngine; public class TrajectoryPreview : MonoBehaviour { public LineRenderer line; [SerializeField] private int steps = 30; [SerializeField] private float stepInterval = 0.02f; public void Draw(Vector3 startPos, Vector3 initialVelocity) { Vector3[] points = new Vector3[steps]; Vector3 pos = startPos; Vector3 vel = initialVelocity; for (int i = 0; i < steps; i++) { // 每步叠加一个重力增量 vel += Physics.gravity * stepInterval; pos += vel * stepInterval; points[i] = pos; } line.positionCount = steps; line.SetPositions(points); } }这段代码把每一帧的位置采样点连成一条线。逻辑可以看出是简化的向前欧拉法,误差比真实物理稍大,但作为调试辅助足够。步骤30步,步长0.02秒,正好覆盖0.6秒飞行时间。篮球从出手到进筐大约需要0.4到0.6秒。
当你让这条线实时跟着手的速度变化时,调试就从"凭感觉扔"变成了"看轨迹曲线调整"。我一般会在场景里用不同颜色区分预测线与实际球的运动轨迹,两者偏差大就说明物理参数不一致,偏差小就说明识别层的出手方向计算是对的。
5.2 辅助瞄准线与投篮点提示
辅助瞄准线可以分为两级。第一级是直接显示从球指向篮筐的虚线,帮助你确认出手方向;第二级是检测预测轨迹是否穿过篮筐平面。穿过篮筐平面的判定逻辑是:对预测点做循环,如果某一段起点在篮筐平面以上、终点在平面以下,就判定这一帧球会穿过篮筐。
// 判断预测轨迹是否经过篮筐平面 public bool IsPassingThroughHoop(Vector3[] points, Vector3 hoopCenter, Vector3 hoopNormal) { for (int i = 0; i < points.Length - 1; i++) { float dotA = Vector3.Dot(points[i] - hoopCenter, hoopNormal); float dotB = Vector3.Dot(points[i + 1] - hoopCenter, hoopNormal); // 符号变化说明轨迹穿过了篮筐所在平面 if (dotA * dotB < 0f) { return true; } } return false; }逻辑说明:把篮筐看作一个无限平面,平面法线指向篮筐正上方。判断球轨迹的相邻两个点是否分别位于平面两侧,如果是,说明球在这一段会穿过篮筐平面。这个判定比碰撞检测更高速,可以提前把辅助精度显示出来,提示玩家瞄准线已经到位。
辅助瞄准线不要一直显示,否则玩家会一直盯着线而不是做动作。我习惯只在持球准备状态下、手部速度超过70%阈值时才显示,速度降下来后自动隐藏。这样不会干扰正常视觉。
5.3 数据记录:每次投篮的参数化存档与回放
投篮手感不好调,还有一个原因是每次出手的轨迹都难以复现。要把手感做成可调参数,就得分批记录每次投篮的关键数据。我在Demo里加了一个简单的CSV记录器,每次触发投篮时把这一帧的速度、方向、位置、偏移量、时间记录下来。
using System.IO; using UnityEngine; public class ShotDataRecorder : MonoBehaviour { private string filePath; private void Start() { // 持久化路径下的日志文件 filePath = Path.Combine(Application.persistentDataPath, "shot_log.csv"); File.AppendAllText(filePath, "time,speed,direction_x,direction_y,direction_z\n"); } public void RecordShot(Vector3 velocity, float speed) { string line = $"{Time.time},{speed},{velocity.x},{velocity.y},{velocity.z}"; File.AppendAllText(filePath, line + "\n"); } }这个记录逻辑的价值在于,把每一条投篮动作变成一个可比较的样本。连续采集四五十条数据后,用表格按速度排序,你就能看出哪个速度区间命中率高,哪个速度区间总是偏左偏右。这个文件也方便后续用外部工具做统计分析。
回放功能我做的比较简单:读取CSV,把每条数据生成一个初始速度向量,复用5.1节的轨迹绘制接口,在场景里同时画出几十条预测线。一串线叠在一起,落点分布一目了然,比一次一次扔球直观得多。从这个角度讲,数据记录不算临时工具,而是调试手感的正式基础设施。
6. 收尾技巧:用强制可视化流程把“手感”变成可调参数
投篮Demo做到后期,最让我头疼的问题已经不是代码逻辑了,而是"手感"两个字。同一套代码,我给同事演示时能连进三球,自己上手就各种打铁。差异就在抬手习惯上:有人手腕动作多,有人整条手臂都在发力。这时候如果只看最终进没进球,根本定位不了问题在哪。
从那以后我每次调这类手势交互项目,都强制自己先过一遍可视化流程,而不是急着改参数。流程固定为四步:第一步,在场景里把手的每秒速度、加速度、脱手标志全部用Debug.DrawLine画出来;第二步,把预测轨迹线和实际球的飞行轨迹同时显示,看偏差出现在出手前还是飞行中;第三步,开CSV记录器,连续投三组,每组投三十次,导出数据对比落点分布;第四步,再改任何一个阈值、阻力、方向权重,改完重复前三步。
这套流程最大的好处是:手感不再是一个不可言说的东西,而是一堆具体数值。比如我改成出手前用0.12秒的历史位置算平均速度,落点从原来的左右偏差40厘米缩小到15厘米。数值上的变化是可比较的,手感变化只是它的副产品。工具层面上,预测线从原有的淡蓝色改成了绿色和红色双状态:绿色表示预测轨迹穿过篮筐平面,红色表示不会穿过。玩家不需要看数值,只要在绿色状态下投篮,命中率就会自然提升,这对演示场景特别重要。
如果你拿这个Demo去做体感教学、VR模拟训练或者投篮数据采集,我建议先保留第5章的数据记录功能,别删。很多游戏在发布时为了性能把调试工具都裁掉,但这个记录器只占很小开销,却能让你在用户反馈"手感不对"时多一个排查入口。
视觉反馈我用的最后一招是:把篮球刚体的线速度实时映射到篮球材质的发光强度上。高速时球体微微发光,低速时恢复原样。这样玩家能从余光感知自己的出手力道,不需要低头看数值面板。把出手动作设计成一个完整的信息闭环:手型准备、速度预警、脱手确认、轨迹预测,每一步都有视觉反馈,手感问题自然就少了。
希望这个LeapMotion投篮Demo的拆解能帮到你,至少下次遇到投篮脱手和球穿筐的时候,你知道先查哪几个参数。
本文还有配套的精品资源,点击获取