简介:面向Unity开发者与体感交互爱好者的投篮Demo,基于LeapMotion手势识别设备,展示了无需物理控制器、仅靠双手即可完成托球与投掷动作的自然交互方式,也可作为VR/AR场景中手势控制原型的验证样例。压缩包约35.43MB,已有463人学习/下载,适合初中级Unity开发者作为手势交互入门的实践参考。资源从LeapMotion SDK接入、手部指节数据解析开始,结合Unity物理引擎模拟篮球抛物线,并配套C#脚本实现投篮时机、发力方向与得分判定等核心逻辑;同时涵盖互动反馈、实时性能优化和不同硬件环境下的调试要点。通过研读整个Demo,可快速建立从手势捕捉到虚拟物体响应的完整链路认知,为后续开发更复杂的LeapMotion体感应用打下扎实基础。
1. 为什么投篮是最适合验证 LeapMotion 的小 Demo:从“看到手”到“玩起来”
LeapMotion 这类手势追踪设备“看着热闹,做起来容易翻车”:手模明明出来了,球却怎么也抓不住;好不容易抓住,松手又成了“飘”出去。投篮 Demo 正好是把“手部追踪 + 物理交互 + 判定反馈”穿起来的最小闭环。捏合算抓取,抬手算蓄力,松手算出手,进球算得分。它适合刚接触手势交互的开发者:先验证传感器精度、手势状态机和物理手感这三件事,再用这套基础去搭 VR 小游戏或展览互动装置。下面讲从环境搭建到物理调参的完整路径,最后把常见的坑一并列出。
2. 先把手模立起来:Unity 版本选择、SDK 导入与坐标空间对齐
2.1 桌面模式还是 VR 模式:先想清楚手放在哪个空间
LeapMotion 的接入方式主要分两种:桌面模式,设备放在显示器或面板下方,手在固定的传感器视野里活动;VR 模式,设备挂在头显前面,坐标跟着头显走。投篮 Demo 我强烈建议先做桌面模式,原因很实际:手、篮球、篮筐全都在静止坐标系里,排除了头显位移的干扰。这样如果球没投进,你能直接判断是“手势判定问题”还是“物理参数问题”,不用同时排查两套坐标系。
Unity 版本不要追新。选你手上最熟的 LTS 版本即可,重点是把新输入系统关掉,使用传统输入系统加 Leap 自己的回调。SDK 选官方最新稳定版,不要用 preview 包。预览版虽然在手势追踪上可能更快,但常常和输入系统、URP 管线起冲突,投篮 Demo 要的是稳定复现,不是追最新手势特征。
2.2 导入 LeapMotion SDK 并把最小手模拖进场景
把官方 SDK 包导入工程后,项目里会多出一个 LeapMotion 目录,里面带着预制件和示例场景。我通常不直接用示例场景,而是在空场景里创建一个新节点,然后从预制件库里挑CapsuleHand拖进去。CapsuleHand用胶囊体模拟手指骨骼,每帧实时贴合手部数据,不需要额外绑定美术模型,非常适合做原型调试。如果团队已经有完整手模,再换RiggedHand也不迟。
手模出来以后,第一件事不是写投篮,而是写一个最简单的检查脚本,确认真实手部数据已经进入场景:
using Leap; using Leap.Unity; using UnityEngine; using System.Linq; public class HandPresenceCheck : MonoBehaviour { public LeapProvider LeapProvider; // 场景中的 LeapProvider 或 HandModelManager 会赋值 public Transform SceneRoot; // 表示设备原点的空物体 void Update() { Frame current = LeapProvider.CurrentFrame; // 当前帧的手部快照 if (current == null) { Debug.Log("未拿到帧数据"); return; } if (!current.Hands.Any()) { Debug.Log("Leap 已连接,但未检测到手"); return; } Hand rightHand = current.Get(Chirality.Right); if (rightHand != null) { // Leap 返回的位置单位不是米,转入场景前先换算 Vector3 leapPos = rightHand.PalmPosition.ToVector3() * 0.001f; Vector3 scenePos = SceneRoot.TransformPoint(leapPos); transform.position = scenePos; } } }这段代码把右手掌心位置从 Leap 设备坐标转成场景坐标,并把当前物体挪过去。CurrentFrame是 LeapProvider 每帧从设备层拿到的快照,Chirality.Right指定右手,PalmPosition是掌心在设备坐标系中的位置。
这里最容易被忽略的就是* 0.001f。Leap 的坐标数值习惯按毫米记录,Unity 场景按米,直接丢进去会让手模在场景里大得像一栋楼。很多项目手模看起来“飘在半空且巨大”,原因根本不在追踪,而是没做单位换算。
2.3 设备坐标转场景坐标:别让手模浮在天花板上
桌面模式下,SceneRoot这个空物体的摆放位置,决定了手模在场景里的最终空间。我一般把它放在“显示器下沿中线向后 10 厘米”的位置,也就是现实中 LeapMotion 设备所在的地方。旋转也要一致:Leap 设备平放时,手在设备上方运动,场景里就应该是 Y 轴朝上的状态。
换成 VR 模式时,SceneRoot要改成头显或者相机子节点,设备坐标随着相机一起移动。但桌面模式不需要这层复杂度。为了快速确认坐标对齐,可以加一个 Gizmo 可视化:
void OnDrawGizmos() { if (rightHand != null) { Gizmos.color = Color.green; Gizmos.DrawSphere(transform.position, 0.03f); Gizmos.DrawLine(transform.position, transform.position + transform.right * 0.1f); } }在 Scene 视图里,绿色圆点应该出现在玩家手的附近,短线顺着手掌向右。建议先让手在传感器上方静止 5 秒,观察 Gizmo 是否稳定。这个步骤通过之后,再开始写抓球逻辑,否则后面所有报错都会指向 SDK,实际却是坐标没对齐。
还有一个容易被忽略的点:不要在手业务脚本里写死“偏移多少度、平移多少米”。把SceneRoot在 Inspector 里调整到正确位置和旋转,存进场景;这样换设备、换显示环境时,只需要调场景节点,不需要改代码。
3. 抓球、抬手、松手:投篮交互状态机与核心代码
3.1 捏合判定:用拇食指尖距离而不是手掌开合
投篮交互可以拆成一个四状态机:Idle -> Pinch -> Holding -> Released。状态切换的核心依据是“捏合”。判断捏合有两种常见做法:一种是用 SDK 自带的PinchDetector组件,另一种是自己算拇指指尖和食指指尖的距离。我推荐自己算,因为投篮需要控制“捏住”和“松开”两个阈值,自带的探测器往往只给一个阈值。
public class PinchDetector : MonoBehaviour { [Header("抓取阈值,单位:米")] public float pinchThreshold = 0.03f; public bool IsPinching(Hand hand) { // 拇指指尖和食指指尖的距离是抓取判定主依据 Vector3 thumbTip = hand.GetThumb().TipPosition.ToVector3() * 0.001f; Vector3 indexTip = hand.GetIndex().TipPosition.ToVector3() * 0.001f; return Vector3.Distance(thumbTip, indexTip) < pinchThreshold; } }代码逻辑:取拇指与食指指尖位置,计算距离,小于阈值就认为捏合。GetThumb().TipPosition拿到的是指尖在 Leap 坐标系下的点,乘以 0.001 转成米。阈值不建议抄死,0.03 米只是一个起点。不同玩家的手指宽度、不同握姿会让距离浮动,实际调试时我会给 0.025 到 0.035 的区间,让你的判断有一定冗余。
3.2 球“跟手”的三种方案与为什么我选速度跟随
捏合确认后,球需要跟着手移动。这里有三种典型做法,踩坑程度差别很大:
第一种是直接transform.position = handPosition。效果最直观,但球和手的碰撞体会相互挤压,球会抖,而且每帧直接改位置,容易让物理引擎产生穿模。第二种是把球设为手的子物体,简单省事,但释放那一帧球的位置和速度会出现跳变,出手方向很难控制。第三种是用Rigidbody.MovePosition做平滑跟随,这是我推荐的,它把球的运动交给物理线程,保持手感连贯。
public class BallFollow : MonoBehaviour { public Rigidbody ballRb; public Hand trackedHand; public Vector3 holdOffset = new Vector3(0f, 0.08f, 0.06f); void FixedUpdate() { if (trackedHand == null) return; // 掌心位置转场景坐标,并叠加持有偏移 Vector3 handPos = trackedHand.PalmPosition.ToVector3() * 0.001f; handPos = transform.localToWorldMatrix.MultiplyPoint(handPos); Vector3 target = handPos + trackedHand.Rotation.ToQuaternion() * holdOffset; ballRb.isKinematic = true; ballRb.MovePosition(Vector3.Lerp(ballRb.position, target, 0.25f)); } public void Release() { ballRb.isKinematic = false; trackedHand = null; } }握持期间把isKinematic设为 true,球不再受重力,不会从手里滑掉;MovePosition只负责把刚体推向目标位置,Vector3.Lerp的 0.25 系数让球跟手不会太生硬。holdOffset非常重要,它决定球在掌心前还是掌心后。我一般把球放在食指中段下方,也就是“托球”而不是“抓球”的视觉位置,这样玩家看到球的位置和自己手的位置重合度高,更容易产生掌控感。
3.3 出手判定与初速度估算:迟滞阈值和帧历史
投篮最核心的瞬间是出手。新手 Demo 最容易在这翻车:手已经松开了,球却没有得到速度,或者球在手心“抖了一下”才飞出去。原因是松开判定用了一个和捏合一样的阈值,临界点来回切换,状态机不稳定。
解决方法是迟滞:捏合阈值为 0.03 米,松开阈值设为 0.04 米左右。捏合时低于 0.03 触发,松开时要超过 0.04 才触发,中间保留 0.01 米的缓冲,避免临界点抖动。
出手瞬间的速度也不能只取当前帧,Leap 的数据虽然有帧率,但手部快速运动时单帧抖动很大。我会记录出手前几帧的掌心位置,用一阶差分估算速度:
Queue<Vector3> _positionHistory = new Queue<Vector3>(); public Vector3 CalculateReleaseVelocity(int frames = 4) { if (_positionHistory.Count < frames + 1) return Vector3.zero; Vector3 oldest = _positionHistory.ElementAt(_positionHistory.Count - frames); Vector3 newest = _positionHistory.Peek(); float dt = frames * Time.fixedDeltaTime; // 这里用场景坐标,单位已经是米 return (newest - oldest) / dt; }这个函数算的是“过去 4 帧的平均速度”,而不是当前帧瞬时速度,能过滤掉大部分抖动。frames参数就是手感的一部分:动作迅猛的玩家用 2 帧,动作舒缓的用 5 到 6 帧,需要真机试。注意这里用的是Time.fixedDeltaTime,因为投篮释放要在 FixedUpdate 里做,和物理引擎保持一致。
出手方向建议不要用整个手掌的旋转方向,而用食指中段到指尖的方向做基准。原因很简单:投篮瞬间玩家手掌通常已经朝前,掌心法线是斜上方的,直接用掌心方向会把球带偏。
4. 篮球物理手感与进球判定:从初速度到计分都按参数调
4.1 刚体与碰撞器参数表:质量、阻力、插值怎么配
投篮手感的好坏,七成在刚体参数上。调参顺序有讲究:先调质量,再调阻力,最后调重力手感。顺序反了,你会陷入“方向不对改力,力不对改碰撞器”的循环。
下面是一份常用参数表,适合中距离投篮场景:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| Mass | 0.6 ~ 0.8 | 接近真实篮球,碰撞体之间交互比较自然 |
| Drag | 0.02 ~ 0.08 | 阻力小,飞行距离远;阻力大,抛物线变平 |
| Angular Drag | 0.1 ~ 0.5 | 控制球在空中自旋,太大出手后像“冻住” |
| Gravity Scale | 1.0 ~ 1.3 | 用 Rigidbody 的 gravityScale 调整 |
| Interpolation | Interpolate | 减少物理更新和渲染之间的抖动 |
质量不要设成 1 以上,否则手模碰到球时,球像砖头一样难推。重力缩放是手感的大杀器:如果玩家用自然挥臂怎么投都短,把 gravityScale 从 1.0 调到 0.85 到 0.9,球走得更远,弧线更弯。VR 模式下我建议保留 1.0,因为 VR 里玩家有完整空间感知,调弱重力会让人觉得球“变轻了”而困惑。
4.2 出手角度与力量映射:抛物线估算和手感系数
投篮能不能进,取决于出手速度向量。这里不需要真的解物理方程,用高中抛物线公式估算一个初速度就够了:
float EstimateLaunchSpeed(Vector3 from, Vector3 to, float angleDeg) { float angle = angleDeg * Mathf.Deg2Rad; float dx = to.x - from.x; float dz = to.z - from.z; float flatDist = Mathf.Sqrt(dx * dx + dz * dz); float dy = to.y - from.y; // 忽略空气阻力的抛体公式反解初速度 float vSqr = 9.81f * flatDist * flatDist / (2 * (dy - flatDist * Mathf.Tan(angle)) * Mathf.Cos(angle) * Mathf.Cos(angle)); return Mathf.Sqrt(vSqr); }角度通常用 45 到 50 度。这个算法给出的是一个基准速度,真正赋值时还要加一个powerScale,它代表玩家挥臂的力度。Leap 能识别手速,但玩家习惯不同:有人抬手快,有人抬手慢。我会把手速映射到 0.8 到 1.3 倍,映射曲线用二次曲线,让慢速出手的变化更细腻、快速出手不至于砸到篮筐后板弹飞。
4.3 进球检测用触发器加状态锁,不做穿筐
进球判定的最佳实践是触发器加状态锁。在篮筐内壁放一个Sphere Collider且Is Trigger = true,球从上方进入时触发得分;从下往上穿筐则不触发。
public class BasketDetector : MonoBehaviour { public UnityEngine.Events.UnityEvent onScore; private bool _scored = false; void OnTriggerEnter(Collider other) { if (!other.CompareTag("Ball") || _scored) return; // 只认自上而下穿过篮筐的球 if (other.attachedRigidbody.velocity.y > 0f) return; _scored = true; onScore.Invoke(); } public void ResetBasket() { _scored = false; } }velocity.y > 0这个判断是防穿筐的关键。没有它,球从篮筐底部反弹经过触发器也会被算分。_scored状态锁防止球在篮筐边缘反复抖动触发多次加分,重置逻辑在每次重新开球时调用。分数反馈建议先做 UI 数字变化,再做音效和篮网动画,反馈链路越短越容易定位是哪一环没生效。
5. LeapMotion 投篮 Demo 避坑清单:五个我反复踩过的问题
5.1 手模静止时漂移、抖动
现象:手放在传感器上方完全不动,场景里的手模却在缓慢漂移,带角度抖动。
原因:桌面模式下,背景反光、强光照射、传感器视野边缘的物体都会干扰手部识别。最常见的是白色桌面或镜面屏幕反射,让 Leap 把桌面误识别成手的一部分。
解决:先调整物理环境,把设备放在手部正下方 30 到 40 厘米处,避免背景出现强反光物体。然后给手部位置加指数平滑,在Update里做:
smoothPos = Vector3.Lerp(smoothPos, rawPos, Time.deltaTime * 20f);平滑系数 20 左右对静止抖动效果明显,又不至于让快速投篮显得迟钝。注意这个平滑只应该作用于手模渲染,不作用于抓球逻辑里的手部位置,否则出手判定会变慢半拍。
5.2 球抓进手里还是会往下滑
现象:明明显示捏合成功,球也进入握持状态,但球还是从掌心缝隙里慢慢滑出去。
原因:球在握持状态仍受到重力,或者手部碰撞体和球的碰撞体互相挤压。单纯改成isKinematic = true还不够,手模的胶囊体碰撞器会持续推挤球。
解决:在握持期间让球不参与手部碰撞。一个干净的做法是把球和手模放到不同碰撞层,然后关闭这两层的碰撞响应:
Physics.IgnoreLayerCollision(handLayer, ballLayer, true);释放瞬间再把忽略关系关掉,让球正常和篮筐、地面碰撞。很多项目用父子化解决这个问题,但父子化会让释放速度计算变得复杂,不如分层干净。
5.3 明明松手了,球却“飘”着出去
现象:手指已经张开,球也从手里脱开了,但球没有获得应有的初速度,像被轻轻放出去一样。
原因:一是松开阈值和捏合阈值相同,临界点附近状态机反复切换,球脱手时没有进入正确释放状态;二是出手速度只取了当前帧瞬时速度,那一帧恰好手速被追踪器平滑掉了。
解决:给出手判定加迟滞,松开阈值设为捏合阈值的 1.3 倍左右。并保证脱手的第一帧就给球一个最低初速度,这个最小速度用“释放前 4 帧的平均手速”,不要用最后一帧。如果玩家动作确实太轻,还可以设置一个minimumReleaseSpeed,低于这个值就向上补足,保证球至少是一段可识别的抛物线,而不是无力的漂移。
5.4 出手方向偏左或偏右:不是设备玄学,是指向基准没选对
现象:同样一个投篮动作,球总是往身体左右两侧偏,修正角度也偏。
原因:Leap 提供的Hand.Rotation是整个手掌的旋转,但投篮者出手时手掌会自然向外翻滚,掌心法线并不等于出手方向。如果直接用掌心法线做方向向量,偏差可达十几度。
解决:改用食指骨骼方向作为瞄准基准。获取食指中段和指尖的方向,再和掌心方向做向量混合:
Vector3 fingerDir = hand.GetIndex().Direction.ToVector3(); Vector3 palmDir = hand.PalmNormal.ToVector3(); Vector3 aimDir = Vector3.Slerp(palmDir, fingerDir, 0.7f).normalized;0.7 这个权重意味着主要参考食指方向,但还会保留掌心方向的稳定性。实调时可以根据玩家反馈改成 0.6 到 0.8,这个值不是物理必需,纯粹是手感偏好的调节。
5.5 运行一段时间后手模突然消失
现象:程序运行半小时后,Leap 设备还在,但手模突然消失,重启 Unity 又恢复。
原因:桌面模式下,系统 USB 节能策略可能让设备进入休眠;或者 Leap 后台服务在长时间无人操作后自动暂停了数据流。
解决:在操作系统层面关闭该 USB 端口的节能模式。代码层面,在Update里监测LeapProvider.CurrentFrame是否持续为空,超过 30 帧就在屏幕上提示“请重新上手”,并把手模复位到默认姿态。这不算修复,但对展项型 Demo 很实用:观众能自己发现要重新把手放回识别区域,不用每次找工作人员重启程序。
6. 进阶成可玩原型:瞄准辅助、数据回放和难度曲线
6.1 出手前的弹道预览:采样而不是跑物理模拟
投篮 Demo 一旦能稳定投进,下一步就是让玩家“知道自己投歪在哪”。弹道预览是最有效的反馈。不要为了预览去创建一个临时刚体做物理模拟,那样又慢又会干扰现有物理。直接用初速度和重力做参数化采样:
for (int i = 0; i < previewSteps; i++) { float t = i * 0.05f; Vector3 p = startPos + velocity * t + 0.5f * Physics.gravity * (t * t); previewPoints[i] = p; }previewSteps取 30 到 50 就够了。把点集交给LineRenderer渲染,线的末端落在篮筐周围时,玩家会明显改善出手力度的一致性。这个功能对新手特别友好,它相当于把“手感”显性化。
6.2 把每一投记下来:手感调参不能靠感觉
手感调参最忌讳的是“投进去感觉顺了”就停手。我会在 Demo 里内置一条轻量数据记录:每次出手记录手速、方向角度、球初速度、投篮结果,写进 JSON 文件。调参时连续投 20 次,快速看哪些出手方向集中在偏差较大的区域,哪些力度区间命中率异常。这一套东西做完之后,你会发现很多“玄学”问题其实只是阈值设置不合理。
数据量不需要做得很重,每条记录包含时间戳、出手位置、速度向量、是否命中即可。先能把一次 Demo 的调试过程量化,后面接数据传输或回放系统都会很自然。
6.3 难度曲线与玩法扩展:让 Demo 值得放进展位
当核心手感稳定,就可以从“能投”走向“想再投一次”。难度曲线有几个低成本的调法:移动篮筐位置,让它左右横移或前后摆动;缩短投篮计时,12 秒内必须出手;把出手速度映射曲线调陡,让慢速出手命中率下降、快速出手更吃香。还有一个非常有效的扩展是把篮筐高度从固定值改成随连击数上升,连进三球筐升高 20 厘米,视觉压力比任何计分板都直接。
这个投篮 Demo 的核心资产不是那块篮筐,而是那套“手部状态机 + 初速度估算 + 物理调参”的交互链路。之后无论做飞盘、套圈、保龄球,换掉场景模型和物理参数就能复用。
我自己的习惯是:真正动代码前,先对着摄像头连续投 20 次,观察自己动作里最不稳的环节,把它翻译成状态机里的一个分支或一个阈值。没有这一步,后面所有调参都是在猜。希望帮到你。
本文还有配套的精品资源,点击获取