简介:一份面向Unity开发者与体感交互初学者的LeapMotion投篮Demo,基于Unity引擎和LeapMotion手势识别设备,模拟从托球到投掷的完整篮球动作,可帮助快速理解手势控制类游戏的核心实现路径。压缩包以zip形式提供,体积35.43MB,当前文件总数及类型明细暂未随资源信息公开。已有463人浏览学习,适合作为Unity与LeapMotion集成的入门案例。Demo围绕手势识别、物理模拟和C#控制展开,覆盖SDK接入、指尖弯曲追踪、投篮力度与角度计算、实时得分反馈等关键知识点;通过拆解其场景逻辑与脚本写法,能掌握如何将现实手部动作映射到虚拟对象,并理解体感交互游戏的调试与性能优化思路。实际动手运行后,可复现一套可玩的投篮流程,为后续设计更多手势交互玩法打下基础。
1. Unity LeapMotion 投篮Demo:为什么这是最值得抄的手势交互样板
做Unity手势交互的人,最后都会绕回同一个需求:把手部动作变成游戏里可信、可玩的输入。投篮Demo是这类项目里非常典型的样板,因为它在几十行逻辑里同时压住了速度检测、方向映射、出手时机、物理轨迹四个问题,哪个环节掉链子,手感立刻现原形。这篇文章不打算讲空洞概念,直接拆解做这类项目时的常规流程:先摸清Leap Motion的数据特性,再给出可复制的出手判定脚本,最后把PC和真机上最容易翻车的地方一个一个挑出来。适合两类读者:刚接触LeapMotion、想要一个能跑的投篮Demo的Unity开发者;已经跑通基础功能、但觉得手感“玄学”的人。阅读前建议准备好一台可用的LeapMotion设备和安装了对应SDK的Unity工程,跟着后面的代码边改边看效果。
2. 投篮Demo的技术底盘:Leap Motion怎么把手变成可靠输入源
2.1 光学手部追踪的本质:为什么投篮手势适合但它不是完美输入
Leap Motion依靠两颗红外摄像头和主动红外光源,在不贴任何标记的情况下对手部做立体视觉重建。每帧先锁定皮肤区域,再从深度信息里拟合出手掌平面、关节位置、手指屈伸状态,并给出每个关节的置信度。相比普通RGB摄像头识别手势,它的优势在于不必训练模型、不依赖环境可见光,到手的数据是连续的骨骼坐标,非常适合需要实时跟手的交互。
但是对投篮这个特定动作,有两个特性必须先接受。第一,追踪视场有限。设备对近距离(大约25到60厘米)的立体覆盖率最好,超过范围后关节抖动会明显增大。投篮动作如果挥舞幅度过大,球出手的瞬间手往往已经接近视场边界,速度向量会失真。第二,速度向量并不完全等于真实世界中的出手速度。光学追踪的“掌速”是基于连续帧位置差分得到的,帧间抖动会被放大,直接拿这个值给球体做初速,经常会出现“明明动作不大球却飞得很远”或者反过来“用力挥手球软绵绵”的怪现象。
因此在这类项目里,我习惯把Leap Motion定位成“提供手部姿势和趋势的传感器”,而不是“提供精确弹道参数的测量仪”。出手向量可以用,但要做滤波和峰值缓冲,具体做法放在第三章讲。手部追踪的置信度数据同样重要,官方SDK会给每只手一个有效性标记,在关键帧判定时一定要先查它,否则一旦追踪短暂丢失,球会直接停留在半空或飞出场景。
2.2 Unity集成新旧两套SDK路线:选错等于给自己挖坑
集成Leap Motion到Unity,现在主要有两条路线,选择之前先把项目定位想清楚。
一条是传统的老版本SDK部署方式:把依赖包导入项目,场景中挂一个服务提供者组件,它会自动连接USB设备并产生手部数据流,之后在任意脚本里通过同一个数据提供者访问当前帧。另一条是新版XR服务路线,它把LeapMotion作为独立手势追踪服务接入Unity的XR体系,好处是可以和现成的VR交互框架并存,缺点是初始化、权限、会话管理的概念更多,对只想做桌面演示的人来说精神负担偏大。
我的建议很直接:如果目标就是PC上插一个LeapMotion、做一个投篮Demo,选传统服务提供者路线就够了。步骤是:先把SDK包导入工程,确保场景里有对应的服务组件在运行;再从组件上暴露的帧接口获取手部数据;最后用Unity的坐标转换把手的骨骼点转成场景坐标。整个过程不需要碰XR概念,调试时也容易定位是硬件问题还是代码问题。
如果项目后续要同时接入另一套交互方案,或者要跑在VR一体机上,这时候再切到XR服务路线。注意两条路线在脚本里获取手部数据的写法不完全一样,提前定好路线能省掉后面改数据源的时间。本章下文和第三章的代码都基于传统服务提供者路线来写,迁移到新路线时只需要替换手部数据的获取方式,状态机、出手判定、物理赋值这些核心逻辑完全不变。
2.3 坐标系与追踪放置:让虚拟球落在手能扫过的空间
Leap Motion的坐标系是X向右、Y向上、Z朝设备前方,默认情况下它在Unity里作为组件挂在一个场景GameObject上,其局部坐标和世界坐标的映射取决于这个组件的旋转和位置。实际项目中经常看到的问题是:Leap Motion组件没有做任何适配,直接放在原点,而虚拟球篮放在前方3米高处,结果追踪到的“手”出现在场景下方,根本对不上。
我在做投篮Demo时一般把Leap Motion组件挂在一个专门管理追踪空间的空物体上,先在编辑器里调整它的旋转使设备Y轴朝上、Z轴朝向玩家站立方向,再把这个空物体放置到和真实设备一致的位置。如果你用的是头戴式VR那样的配置,设备随头移动,场景中也需要让这个组件看向玩家面对的方向。摆放高度也值得费心思。这段时间的手部追踪最佳范围大约是设备正上方20到60厘米,投篮动作的手部起点大约在胸口高度、出手点在头顶前方。如果是桌面放置,我会把设备架在约80厘米高的台面上,让手在设备上方40到80厘米范围内活动,这样从抓球到出手的整段路径都能被持续追踪。
代码层面,至少要在开始时确认追踪设备连接和位置是否有效。用一个简短的检查脚本就能做到:
using UnityEngine; using Leap.Unity; public class LeapTrackingCheck : MonoBehaviour { public LeapServiceProvider provider; void Start() { if (provider == null) provider = FindObjectOfType<LeapServiceProvider>(); if (provider == null) { Debug.LogError("未找到 LeapServiceProvider,确认SDK组件已挂在场景中"); return; } } void Update() { var frame = provider.CurrentFrame; if (frame != null) { Debug.Log("当前帧在手数量:" + frame.Hands.Count); } } }这段代码的作用有两个:启动时确保数据源组件在场景中;运行时每帧打印手数量以便快速判断设备是否被识别。如果日志刷屏,就加一个时间间隔再打印,只保留手数为0时的报警。注意当前帧接口在多线程环境下有读写限制,不要在固定更新之外的随机线程里调用,排查阶段用Update里直接读就够了。
3. 从手势到出手:投篮Demo的核心链路怎么实现
3.1 抓球与举球:握力阈值与球跟随
现实中投篮有三个自然阶段:手指弯曲抓住球、手臂上行把球带到出手点、手指张开让球离手。在Leap Motion的数据里,手部模型有一个握紧强度的量化值,通常在0到1之间,手全张开接近0,全握拳接近1。我们可以用它作为状态机的输入。
先明确状态流转:初始状态,手张开,球不出现在手上;当握紧强度超过一个阈值,状态切到“持球”,场景里在手掌位置生成一个篮球;持球期间,球的位置跟随手掌位置和旋转;当握紧强度下降或手掌速度超过设定值,状态切到“出手”,把之前记录到的速度赋给球体,让物理引擎接管。
球生成的位置我选择放在手掌心的斜上方一点。原因很实际:直接把球放在掌心的精确坐标上,手稍微一晃球就嵌进手模型里,视觉穿帮很严重;放在上方约5到8厘米处既不会穿入掌面,又能明显地表达“托着球”的感觉。这个偏移值可以当成调试参数,玩家手型不同时调整。
3.2 出手判定:用“峰值速度缓冲”而不是瞬时速度
投篮Demo里最容易翻车的点就是出手判定。如果每一帧直接把当前手掌速度当作出手速度,你会遇到两类问题:一是手在持球过程中轻微晃动就可能触发误判,二是在真正出手瞬间手已经减速了,读到的瞬时速度比实际出手速度低一大截。真实投篮的物理过程是“加速到峰值、手指松开、手继续向前但速度下降”,球离手的时刻实际上等于手部速度峰值附近。
所以常规做法是引入一个“速度峰值缓冲”:从进入持球状态开始,把每一帧的手掌速度都保存下来,只保留一个最高值和对应的速度向量。当出手条件满足时,把保存在缓冲里的最高速度当作球体的初始速度。这样就算判定发生时手已经在减速,球也能拿到完整的手臂挥动速度。
窗口长度也需要控制。太短,比如只取1到2帧,抖动影响大且峰值可能没到;太长,比如持续记录到出手前2秒,那玩家慢悠悠举球后突然加速,缓冲里存的还是之前慢动作的旧峰值。我一般把窗口设在12帧上下,也就是大约0.2秒。这个值对应“快速抬臂出手”这种动作节奏,如果以后支持慢速轻投,需要把窗口继续加长。
3.3 核心脚本:BallThrowController的完整实现与参数解读
下面是我做投篮Demo时常用的一套核心控制器,它把上面说的状态机和峰值速度缓冲都整合进去。代码里注释了关键参数,你可以直接复制到Unity工程里改。
using UnityEngine; using Leap; using Leap.Unity; /// <summary> /// 投篮 Demo 核心控制器。 /// 状态机:Idle -> Holding -> Throwing /// 数据源:LeapServiceProvider /// </summary> public class BallThrowController : MonoBehaviour { [Header("手部数据源")] public LeapProvider leapProvider; public bool useRightHand = true; // 默认右手控球,可现场切换 [Header("球体")] public GameObject ballPrefab; public float ballUpOffset = 0.06f; // 球相对掌心的垂直偏移,防穿插 [Header("出手判定")] [Tooltip("握持强度超过该值后开始持球")] public float grabStrengthHold = 0.85f; [Tooltip("握持强度低于该值时认为手指张开、准备出手")] public float grabStrengthRelease = 0.35f; [Tooltip("出手需要的最低手掌速度(米/秒)")] public float releaseSpeedMin = 0.6f; [Tooltip("速度峰值采样窗口帧数,约0.2秒")] public int peakVelocityFrames = 12; private GameObject _currentBall; private Rigidbody _ballRigidbody; private bool _holding; private Vector3 _releaseVelocity; private float _releaseSpeedRecord; private int _frameCount; void Update() { if (leapProvider == null) return; Frame frame = leapProvider.CurrentFrame; Hand hand = GetHandBySide(frame); if (hand == null) { // 手丢了就回到待机,防止球悬在半空 _holding = false; return; } float grip = hand.GrabStrength; Vector3 palmPos = hand.PalmPosition.ToVector3() + leapProvider.transform.position; if (!_holding) { // 手指弯曲到足够程度,生成球并进入持球状态 if (grip > grabStrengthHold) { _holding = true; SpawnBall(palmPos); } } else { // 持球期间球跟随手掌,并持续记录速度峰值 _currentBall.transform.position = palmPos + Vector3.up * ballUpOffset; _currentBall.transform.rotation = hand.PalmRotation.ToQuaternion(); Vector3 palmVel = hand.PalmVelocity.ToVector3(); if (palmVel.magnitude > _releaseSpeedRecord) { _releaseSpeedRecord = palmVel.magnitude; _releaseVelocity = palmVel; } _frameCount++; // 出手:手指张开,或速度已经冲过阈值且采样了足够帧数 bool releaseByGrip = grip < grabStrengthRelease; bool releaseBySpeed = _releaseSpeedRecord > releaseSpeedMin && _frameCount > 3; if (releaseByGrip || releaseBySpeed) { ThrowBall(); } } } private Hand GetHandBySide(Frame frame) { foreach (Hand h in frame.Hands) { if (h.IsValid && h.IsRight == useRightHand) { return h; } } return null; } private void SpawnBall(Vector3 spawnPos) { _currentBall = Instantiate(ballPrefab, spawnPos, Quaternion.identity); _ballRigidbody = _currentBall.GetComponent<Rigidbody>(); _ballRigidbody.isKinematic = true; // 持球阶段不受物理影响 _releaseVelocity = Vector3.zero; _releaseSpeedRecord = 0f; _frameCount = 0; } private void ThrowBall() { _ballRigidbody.isKinematic = false; _ballRigidbody.velocity = _releaseVelocity; _holding = false; _currentBall = null; _ballRigidbody = null; _releaseSpeedRecord = 0f; _frameCount = 0; } }逻辑说明分三块:数据获取、持球、出手。数据获取部分在Update里读当前帧,按左右手筛选出目标手;手丢失时直接重置持球状态,避免球停在半空。持球部分把球放到掌心偏移位置,同时每帧采样手掌速度,保留峰值。出手部分对握持强度或峰值速度做判断,一旦条件满足就把峰值速度赋给球体刚体,切换成非运动学,让物理引擎接管。
这里有四个参数需要按实际环境调:grabStrengthHold决定多紧才算抓球,grabStrengthRelease决定手指张到什么程度算出手,releaseSpeedMin决定出手的最低速度下限,peakVelocityFrames决定速度峰值窗口长度。如果新手玩家总是抖动误触,调高grabStrengthHold和releaseSpeedMin;如果老手觉得出手不够灵敏、球发不出来,把releaseSpeedMin降到0.4左右,同时缩短窗口。
4. 手感调优:让投篮Demo不“飘”的物理与反馈设计
4.1 出手阈值大扫描:灵敏度过高和过低都是一样的灾难
出手阈值的调节是这个Demo里对“手感”影响最大的操作。阈值定得低,手还没挥起来球就飞出去,玩家觉得球“活在指尖”;阈值定得高,手都甩出视野了球还没离手,玩家觉得系统“反应迟钝”。两种情况在测试中都会被当成同一类问题:手感差。
比较稳妥的调法是做一次阈值扫描。先把releaseSpeedMin从0.4开始,以0.1步长递增测试,每次固定只投10个球,记录两个数据:误触次数和球到达篮筐的命中率。正常成年人投篮挥腕的峰值速度大约在每秒1到3米之间,所以0.6到0.8通常是比较舒服的入门区间。同时grabStrengthRelease和grabStrengthHold之间要留出滞回区间,比如抓球阈值0.85、释放阈值0.35,中间留0.5的空隙,这样手在抖动时不会反复切换状态。
一个容易忽略的细节是出手方向也应该做平滑。直接使用峰值速度向量,偶尔会出现球离手方向偏左或偏右的情况。我通常会把峰值速度向量和出手时手掌的朝前方向做一次插值,比如按6比4混合,这样球的初速度方向更贴近玩家真实出手意图。实验下来,这个混合比例在“直接投”和“带弧线抛”两种风格下都受用。
4.2 球体物理参数:质量、摩擦和连续碰撞怎么配
球体物理参数参考以下表格,这是我从大量调试里筛出来的一套相对稳妥的组合:
| 参数 | 建议取值 | 说明 |
|---|---|---|
| 球体半径 | 0.11米左右 | 接近真实篮球尺寸,视虚拟场景缩放而定 |
| 球体质量 | 0.3到0.6 | 质量太大出手速度不够推不远,太小容易飘 |
| 线性阻尼 | 0 | 空气阻力会让轨迹变闷,默认不需要 |
| 角阻尼 | 0.05到0.1 | 保留一点旋转衰减更自然 |
| 物理材质弹力 | 0.6到0.8 | 太高会在篮筐或地面来回跳 |
| 物理材质摩擦 | 0.4左右 | 太高的摩擦会使擦筐进球明显减速 |
| 碰撞检测模式 | ContinuousDynamic | 防止高速球穿透篮筐 |
此外,刚体在持球期间要设为运动学,出手瞬间再改成非运动学并赋速度。如果持球期间不设运动学,球会因为重力作用而自己往下掉,视觉上就是“球没被托住”。两个刚体状态切换在Update和FixedUpdate混用时也要小心,如果赋速度发生在Update里而物理步进在FixedUpdate,瞬移效果会延迟几帧,看起来像是球“顿了一下”才飞出去。常规处理是把速度赋值放到FixedUpdate里,或者至少保证赋速度的那一帧物理引擎立刻执行一次模拟步。
4.3 视觉反馈:命中、偏离和“差一点”怎么让人有感知
投篮不只是把球发出去就行,玩家需要知道自己投得怎么样。反馈系统在我这里分三层:命中得分、擦筐预警、轨迹可见度。
命中判定实现起来很简单,给篮筐边缘做一个环形的碰撞体,挂一个触发器脚本,检测球体进入时加一分,同时播放一个粒子效果。但要注意别让触发器太宽,否则球从筐边擦过也算进球。我一般把触发环放在篮筐圆形边缘内侧,再用篮筐平面法线方向过滤:球必须从上方进入才算有效。
擦筐预警的做法是给篮筐外围加第二个更大的触发环,不进球的球体碰到这个环时,播放一次偏沉的撞击声并让篮筐模型轻微抖动。这个反馈听起来简单,却对“手感”提升特别明显,因为玩家能感觉到球差一点就进了,而不是“完全没缘分”。轨迹可见度方面,在球出手后生成一条淡出的抛物线拖尾,或者只显示两个关键点,玩家就能直观看到刚才的出手角度和力度对弧线的影响。拖尾可以用简单的LineRenderer,每帧记录球的位置,两秒后逐渐消失,性能开销很小。
5. 避坑指南:投篮Demo从PC到真机最容易翻车的5个场景
5.1 现象:手刚加速球就飞出去 原因:出手判定取了瞬时速度
这是新手最常碰到的情况:明明球员只是把手往前一递,球却早早离手飞出场外。原因在于出手判定直接用了当前帧的手掌速度,而这个速度在手臂加速抬升时已经超过了阈值,但此时手指还没张开,球还在手里。解决方法是延长峰值速度窗口,同时加入“握持强度低于释放阈值”这一条件作为第二道闸门,只有手指张开了或者窗口内的峰值速度足够大时才允许出手。注意_frameCount > 3这个条件不要去掉,否则手刚生成球的那一两帧里采样到的噪声峰值就会立刻触发投篮。
5.2 现象:追踪显示正常但球不跟手 原因:手部ID和球体所有权没对应
追踪日志里手一直都在,球却没反应,或者球一直黏在场景原点。这个问题的根源多半是代码里从框架中取手的方式写错了,常见写法是frame.Hands[0],但Leap Motion并不保证每帧第一只手都是同一只手。解决方法是像第三章里那样用IsRight或IsLeft来筛选手势,而不是取列表下标。如果左右手都出现在视场内,还要进一步确认hand.IsValid,追踪置信度过低的手不能用来操纵球。另一个常见原因是球生成时没有正确设置父节点和初始位置,球出现在场景原点;要把球的初始位置和手掌位置绑定,并确保坐标转换里加上了LeapProvider组件的世界坐标偏移。
5.3 现象:双手交错时方向乱跳 原因:追踪切换了手ID
实际测试中,玩家用右手投完一颗球,左手伸过来接球,这时候右手的数据可能会突然变成左手的数据,导致球的方向和速度完全混乱。Leap Motion的对手重建在双手靠近时有时会出现ID互换。应对办法是不要在手部交互的每一帧都重新判断“哪只手是主手”,而是在持球开始时绑定一次手ID,整个持球过程都用这个ID去查询手部数据。如果这个ID丢了,就回到待机状态,允许下一次重新抓球。这样即使追踪短暂丢失,也不会把左右手数据错接到同一段投篮动作上。
5.4 现象:球穿筐或者落地乱弹 原因:物理碰撞组和刚体参数没配好
篮筐是一个细环,球以每秒数米的速度撞过去时,如果球体碰撞检测没开连续模式,物理引擎在两步模拟之间可能直接穿模。解决依次做三件事:给球刚体设置Continuous Dynamic;给篮筐边缘用较薄的胶囊体碰撞器而不是精细的网格碰撞器;球体物理材质的弹力设置在0.6到0.8,不要超过0.9。落地乱弹通常是因为弹力过高或者阻尼为零,球接触地面后反弹多次才停下。给地面单独配一个弹力略低的物理材质,或者给球增加一点点角阻尼,都能让落地后的表现更像真实篮球。如果球在持球阶段就频繁抖动,那多半是球体和手模型发生了碰撞,记得把持球期间的球设为运动学刚体,并加一个碰撞忽略逻辑。
5.5 现象:帧率掉到满足不了投篮 原因:每帧分配球体/粒子
如果每次抓球都实例化新的球预制体,每投一球就销毁,频繁的创建销毁会让GC频繁触发,帧率起伏明显。解决方法是做简单的对象池:场景里预生成20个球,抓球时从池里取一个,球落地或进筐后延迟3秒回收到池中。粒子效果也尽量用池化,避免在进球瞬间创建大量粒子。还有一种隐藏性能问题来自日志:我在检查脚本里每帧打印手数量,如果忘记删除,日志系统本身就会吃掉不少帧时间。上线测试前把这些调试日志全部去掉或改成只在出错时打印。
6. 进阶:给投篮Demo加上记录回放和个性化出手弧线
6.1 出手轨迹记录回放怎么做
当基础投篮玩法已经稳定,我强烈建议加一个“动作回放”功能。做法很简单:以出手时刻为中心,记录出手前0.5秒到出手后0.2秒的手掌位置、速度、手指屈伸状态,存成一个序列化对象。每次投篮都把这段序列写进一个列表里,就可以在场景里拉出一条出手轨迹线,或者用一个半透明的手模型重新播放刚才的动作。这款功能对调试的价值极高:你会发现很多“玄学”手感问题,比如球老是偏左,回放一看原来是出手瞬间手指并没有完全张开,球蹭着指尖出去的,方向自然歪了。
[System.Serializable] public class HandSample { public Vector3 position; public Quaternion rotation; public float palmVelocityMagnitude; public float grabStrength; public float timestamp; }回放代码的核心逻辑是把这一段HandSample数组按时间戳插值,驱动一个用于显示的手部模型。不用精确到每帧采样,每0.03秒一个样本就足够平滑。这个功能也能用来做自动测试:反复回放同一段动作,验证多次投篮的球轨迹是否一致,从而判断物理模拟的确定性。
6.2 用玩家历史出手数据自动调整球弧线
更进一步,你可以收集每个玩家的出手速度、出手角度和命中结果,做一次简单的统计拟合。比如当玩家连续5次投篮都因为是力度偏小而砸在前筐上,系统就自动增加出手速度的倍率系数,而不是让玩家自己去适应。这个“自适应”功能不需要复杂的AI,只是一个滑动平均:speedMultiplier = 上次命中时的速度 / 本段平均出手速度,再限制在0.8到1.2的范围内。投篮Demo如果只是固定难度,玩家一会儿就腻了;有了这个微调,不同身高、不同臂展的玩家都能找到适合自己的手感。
我一开始做这个Demo时,总觉得从LeapMotion出来的数据应该直接当作精确测量结果来用,结果在物理和手感上反复翻车。后来才领悟到一个习惯:把追踪数据当成一个带噪声的趋势源,关键节点先过滤、再缓冲、后赋值,很多坑就自然地消失了。做这类手势交互项目,真正的难点其实不是把球投进去,而是让每个玩家都觉得球是他自己投进去的。希望这个投篮Demo的拆解和踩坑记录能帮你少走一段弯路。
本文还有配套的精品资源,点击获取