ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Leap Motion手势投篮:从坐标映射到得分判定

Leap Motion手势投篮:从坐标映射到得分判定

简介:面向Unity与体感交互初学者及VR/AR原型开发者的LeapMotion投篮案例,围绕手势识别驱动虚拟篮球的完整流程展开,可用于课堂演示、课程设计或个人练习。压缩包约35.43MB,文件总数显示为0且类型明细暂无数据,上游文件清单未同步,具体文件构成以下载后的目录为准。已有464人学习,说明该案例在体感游戏入门群体中具备一定参考度。内容重点覆盖LeapMotion SDK接入、托球与投掷手势判定、篮球抛物线物理模拟、C#控制脚本、得分判定及视听反馈,并涉及采样率与性能平衡、跨硬件测试调试等实践环节。读者可据此理解手势数据如何映射到游戏对象,掌握从动作识别到投篮交互落地的关键思路,适合作为体感投篮原型与Unity物理交互的学习样本。

1. 手势投篮的需求,落在"猜"这件事上

手势投篮这个题目,第一眼看像个随手就能做完的玩具,真正坐下来做才发现难点不在投篮,而在猜。键盘投篮有明确的按键上升沿,手柄有扳机键程,Leap Motion 输出的只有一帧一帧的手掌位置、掌面法线和握拳强度,没有"投篮"这个字段。什么叫抓住球、哪一帧算出手、出手瞬间手是在加速还是已经开始收力,全部得自己定义,定义得不好,玩的人会觉得"球不听话"。

这份 Demo 落地的是一条完整链路:Leap Motion 手部数据的接入与坐标映射、抓球到释放的状态机、手掌速度到刚体初速度的换算、篮筐平面穿越判定、命中反馈与出手数据统计。适合拿来做课程设计、毕业设计里的手势交互模块,也适合做互动展台的快速原型——观众不用学任何操作,抬手就能投。

2. 数据链路设计:从手部骨骼到可用的投篮信号

Leap Motion 给出的原始数据比想象中丰富:每帧一个Frame,里面挂着若干Hand,每只手带掌心位置PalmPosition(单位毫米)、掌心速度PalmVelocity(毫米/秒)、掌面法线PalmNormal、手指指向Direction、握拳强度GrabStrength(0 到 1)、捏合强度PinchStrength、以及一个 0 到 1 的置信度Confidence。这套数据在设备自己的坐标系里是连续的,但直接丢进 Unity 场景里用会出问题,因为单位、手性、锚点三件事都得单独处理。

2.1 毫米到米、手性到轴、设备到场景的三步换算

第一步是尺度。Leap 用毫米,Unity 里一个单位默认是一米,一比一映射就意味着所有数值乘 0.001。手掌宽度在真人是 8 到 10 厘米,刚好可以拿来做一次自校准:把PalmWidth和你在场景里设定的虚拟手宽度比一比,得到一个缩放系数,换个人来玩也不会突然变成巨手或者小手。

第二步是手性。Leap 的坐标系是 +X 向右、+Y 向上、+Z 朝向使用者,Unity 是 +X 向右、+Y 向上、+Z 朝前,两者的 Z 轴方向相反。这个差异不处理,做出来的效果就是手往前推、球往后飞。我一般会在场景里放一个半透明小球,位置直接取手掌坐标,先靠眼睛确认三个轴全对,再往上叠玩法。

第三步是锚点。设备在桌上,人站在设备前,场景里的"投篮位"未必和物理位置重合。用一个空物体handAnchor代表虚拟的站立点,把手掌坐标换算成相对锚点的局部坐标再叠加回去,位置关系就稳定了。

using Leap; using Leap.Unity; using UnityEngine; public class HandSampler : MonoBehaviour { public LeapProvider provider; // 场景中 LeapServiceProvider 的引用 public Transform handAnchor; // 虚拟站立点,代表投篮者在场景中的位置 public float worldScale = 1.0f; // 1.0 为一比一,展台小屏可缩到 0.6 public float minConfidence = 0.5f; // 低于此置信度的帧直接丢弃 // 取右手,返回是否取到 public bool TryGetRightHand(out Hand hand, out Vector3 palmWorld) { hand = null; palmWorld = default; Frame frame = provider.CurrentFrame; if (frame == null || frame.Hands == null) return false; foreach (var h in frame.Hands) { if (!h.IsTracked) continue; // 未追踪到的手直接跳过 if (h.Confidence < minConfidence) continue; if (!h.IsRight) continue; // 本 Demo 只处理右手 hand = h; // 毫米转米,Z 轴取负修正手性差异 Vector3 local = new Vector3( h.PalmPosition.x, h.PalmPosition.y, -h.PalmPosition.z) * 0.001f * worldScale; palmWorld = handAnchor.position + handAnchor.rotation * local; return true; } return false; } }

minConfidence卡在 0.5 是个经验值,设太高手会频繁消失,设太低会出现手已经离开视场、位置还在原地漂移的鬼帧。worldScale决定手感轻重,一比一映射最自然,但展台上人离设备近、动作幅度小的时候,缩到 0.6 反而更容易把球投到篮筐高度。handAnchor的旋转一定要保持单位四元数,除非你确实想让虚拟投篮位和现实朝向有个夹角。

顺带说一句,不同版本的 Leap Motion Unity 插件里,向量扩展方法可能已经替你做了单位换算甚至手性翻转。先按上面这段显式转换跑一次,看球是不是往正确方向走,再决定要不要改用插件自带的ToVector3()。

2.2 抓球与释放:一个四状态的判定机

手感好坏八成取决于这一段。直接拿GrabStrength > 0.5当抓球条件,手只要在视场里半握一下就触发,玩的人会觉得球黏在手上甩不掉。合理的做法是双阈值加保持时间,也就是迟滞判定。

状态进入条件退出条件该状态下做什么
Idle 空闲默认状态握拳强度大于 0.85 且持续 3 帧球停在球架,不响应任何手部动作
Hold 持球进入 Idle 的退出条件成立握拳强度小于 0.45球跟随掌心做平滑跟随
Armed 待发持球中,掌心速度向上分量大于 0.3 m/s回到 Hold 或进入 Release开始记录速度滑窗,准备出手
Release 出手持球中握拳强度掉到 0.45 以下立即回到 Idle施加初速度,球交给物理引擎

迟滞的两个阈值 0.85 和 0.45 之间隔了 0.4,看起来夸张,但这正是防误触的关键:手张开时GrabStrength通常只有 0.1 到 0.3,握紧能到 0.9 以上,中间留出足够宽的缓冲区,手在自然状态下晃不会反复触发。3 帧的保持时间对应大约 50 毫秒,能滤掉单帧的数值尖峰。

Armed这个中间状态容易被省掉,但它的作用是提前锁定滑窗起点。没有它,释放那一帧才开始采样,拿到的只是最后一段正在减速的手,投出去的球必定短一截。

2.3 速度采样窗口与降噪

速度有两个来源:设备直接给的PalmVelocity,和自己对位置做差分。前者的好处是不用管平滑,坏处是抖动被放大,而且和你在场景里做过平滑的位置不是同一套数据。我倾向自己差分,保证"看到的手"和"投出的球"用的是同一条曲线。

差分必须配平滑,否则球出手方向会有随机偏摆。一阶指数平滑够用,不需要上卡尔曼:

// 指数平滑,smoothFactor 越大越跟手,越小越平滑但延迟越大 float alpha = 1f - Mathf.Exp(-smoothFactor * Time.deltaTime); smoothedPos = Vector3.Lerp(smoothedPos, rawPos, alpha); // 环形缓冲保存最近 N 帧的位置,用于释放时求平均速度 posHistory[posIndex] = smoothedPos; timeHistory[posIndex] = Time.time; posIndex = (posIndex + 1) % windowSize;

smoothFactor取 12 到 20 之间,对应大约 50 到 80 毫秒的时间常数,手感上既能压住抖动,也不至于让手看起来拖泥带水。窗口长度我一般取 0.08 到 0.12 秒,按 60 帧算就是 5 到 8 帧。窗口太短,单帧噪声直接进速度;窗口太长,会把出手前的减速段也平均进去,球飞不远,于是有人开始瞎调重力,越调越怪。

释放时的速度取窗口内的平均值,而不是峰值。峰值看着"有力量",但真人的出手动作在最后几帧已经明显减速,取峰值会让出手轻重完全不可控。

3. 投篮物理实现:手掌速度怎么变成一条稳定的抛物线

物理部分要解决的核心矛盾是:手上的动作在厘米级、速度在每秒一米上下,篮筐在三米开外、三米多高。中间缺一个换算关系,而这个关系不能靠拍脑袋,得有一张参数表和一套可回归的调试方法。

3.1 手感参数表:球、重力、篮筐的取值

这些数字不用自己量,按真实篮球的规格来,只把重力当成手感旋钮。

参数取值说明
球半径0.12 m七号球直径约 0.246 m
球质量0.6 kg真实篮球约 0.6 kg,影响碰撞后的反弹表现
篮圈内半径0.225 m内径 0.45 m,比球大一圈,这是真实的容错空间
篮圈高度3.05 m标准高度,不要为了好投而降低,否则一眼假
出手点水平距离3.0 到 4.0 m罚球线 4.6 m 略远,Demo 场景常收到 3 米左右
物理重力-9.81 到 -16越大手感越"脆",抛物线更陡,进球窗口更小
出手系数0.6 到 0.9手掌速度乘这个系数得到出手速度
抬升补偿1.0 到 2.0 m/s固定叠加在 Y 方向,抵消人手向上速度不足
初速度上限12 m/s防止误判导致球飞出场景

重力是这里唯一可以大幅偏离现实的参数。设 -9.81 时球在空中飘得久,看起来慢动作;加到 -14 左右,抛物线收紧,投篮节奏更像真实球场。代价是进球窗口变窄,出手系数的容差也变小,所以只要调了重力,出手系数必须重新回归一遍。

3.2 释放瞬间:初速度、上限截断与后旋

手掌速度是三维的,直接乘系数丢给刚体,会得到一条斜着飞出去的直线——因为人手投篮时向前的分量远大于向上,而真实的投篮需要仰角。抬升补偿就是补这个差:把手掌速度的 Y 分量抬到一个合理区间,同时保留 X、Z 的方向信息。

using UnityEngine; public class BallThrower : MonoBehaviour { public Rigidbody ballRb; public float throwScale = 0.75f; // 出手系数 public float liftBias = 1.5f; // Y 方向固定补偿 public float maxSpeed = 12f; // 初速度上限 public float backspin = 12f; // 后旋角速度,弧度/秒 // handVelocity 来自 2.3 的速度滑窗,单位 m/s public void Release(Vector3 handVelocity) { ballRb.isKinematic = false; // 必须先解除运动学,速度才会被物理引擎接受 Vector3 v = handVelocity * throwScale; v.y = Mathf.Max(v.y, 0f) + liftBias; // 向上的分量不低于 0,再叠补偿 v = Vector3.ClampMagnitude(v, maxSpeed); // 截断,防飞出场景 ballRb.linearVelocity = v; // Unity 6 起 velocity 更名为 linearVelocity // 后旋让球在接近篮筐时下坠更快,也更好看 ballRb.angularVelocity = -ballRb.transform.right * backspin; } }

throwScale是最核心的旋钮。偏小,所有人都投不到篮筐;偏大,随便一挥手就砸板。我的做法是找一个人正常投篮十次,统计落点分布,把系数调到能让十次里有四到六次碰到篮圈区域。这个命中率不是难度指标,是手感合理性指标。

isKinematic的赋值顺序是踩过坑的:先把isKinematic置为 false,再写速度;反过来写,速度会被运动学刚体直接吞掉,现象是球落在原地不动,看起来像物理引擎坏了。Unity 6 之后Rigidbody.velocity改名为linearVelocity,旧代码会编译报错或者被警告,迁移时统一改掉,别一半一半混着写。

3.3 持球跟随与穿模回避

持球阶段最省事也最稳的做法是把球设为运动学刚体,位置由代码给,不参与物理模拟。这样手穿过球、球穿过篮架之类的情况都不会发生。

跟随不要直接等于掌心位置,要加一点滞后。真实的手抓球,球在手心里会有微小滑移,完全刚性跟随反而显得假;另外 Leap 的数据有小幅抖动,刚性跟随会把抖动全放大到球上。用Vector3.Lerp追赶,阻尼系数取 20 到 30,效果是球跟手但不抖。

位置要写Rigidbody.position而不是transform.position。运动学刚体在勾选了插值的情况下,物理引擎按Rigidbody.position做插值渲染,直接改transform会让球在高帧率下出现细微的抖动,肉眼看得出来。

还有一点容易忽略:球被抓起时要把碰撞体关掉,或者把它放到一个独立的物理层,和玩家的手部代理、球架做层碰撞矩阵的排除。否则球被吸过去的途中会跟球架打架,一帧弹飞十几米。

4. 判定与反馈:篮筐、篮板、计分与演示界面

判定这块是演示时最尴尬的地方——球明明穿过去了,分数没加。原因基本都出在"用触发器判进球"这个思路上。

4.1 平面穿越式得分判定

高速运动的球在一帧内可能前进半米,篮圈内径才 0.45 米。用OnTriggerEnter判定,帧率一掉、物理步长一大,球直接从触发器里穿过去,一个回调都没有。正确做法是记录球在相邻两帧的位置,判断它的运动线段是否穿过了篮圈所在的水平面,再求交点,看交点落没落在圈内。

using UnityEngine; public static class ScoreJudge { // 返回 true 表示这一帧完成了一次有效进球 public static bool CrossRim(Vector3 prevPos, Vector3 curPos, Vector3 velocity, Vector3 rimCenter, float rimY, float rimRadius, float ballRadius) { if (velocity.y >= 0f) return false; // 必须是下落中穿过 if (prevPos.y <= rimY || curPos.y > rimY) return false; // 线段必须跨过平面 // 线性插值求交点,高速下这个近似足够准 float t = (prevPos.y - rimY) / (prevPos.y - curPos.y); Vector3 cross = Vector3.Lerp(prevPos, curPos, t); // 只看水平距离 float dx = cross.x - rimCenter.x; float dz = cross.z - rimCenter.z; float allow = rimRadius - ballRadius * 0.5f; // 容错半径 return (dx * dx + dz * dz) < allow * allow; } }

allow的取值直接决定"手感宽容度"。理论上球心落在 0.225 - 0.12 = 0.105 米以内,球整体才肯定不碰框;按 0.5 倍球半径取 0.165 米会偏宽松,稍微擦框也算进。Demo 更适合宽松一点,因为手势投篮的精度本来就不如手柄。这个值建议作为公开参数放出来,方便演示前微调。

velocity.y >= 0f这一条不能省。球被篮筐弹起后从下往上二次穿过平面,不判方向就会出现一次投篮加两分的情况。

判定的调用位置也有讲究。放在FixedUpdate里,用当前帧位置和上一帧缓存位置做比较;放Update里会因为渲染帧和物理帧不同步,出现漏判和重复判定。球的质量、阻力这些参数在FixedUpdate里更稳定,判定也放这儿。

4.2 篮筐、篮板、球架的碰撞体搭建

篮圈不是一个简单的圆环碰撞体,Unity 没有现成的环形碰撞体。常见做法是用 8 到 12 个胶囊体沿圆周摆一圈,首尾相接,每个胶囊的半径等于篮圈管径的一半(约 0.01 到 0.015 米)。段数太少球会卡进去,段数太多物理开销上来,10 段是个平衡点。

篮板用一个 BoxCollider 就够,厚度给 0.03 米。注意篮板和篮圈之间要留出真实间隙,篮圈后沿到篮板有十几厘米,别做成贴死的,否则球从侧面擦过来会卡在缝里。

球本身必须开连续碰撞检测。刚体上collisionDetectionMode设为ContinuousDynamic,interpolation设为Interpolate。前者防高速穿透,后者保证渲染位置平滑,不然球飞起来是一跳一跳的。

篮网不需要碰撞体,用一串带约束的线段或者直接一个半透明圆柱体做视觉即可,加碰撞会让球在网里乱弹。

4.3 命中反馈与出手数据

反馈分三层:球的物理表现、屏幕上的即时提示、统计面板。

即时提示用事件驱动,进球时触发一次音效加一个文字弹出,弹出动画 0.3 秒内结束,别做长动画,连续投几次会互相打断。落到地上的球三秒后自动回收,回收到球架位置时把刚体重新设为运动学、速度清零、角速度清零,否则回收后的球会带着上一次的残余速度乱滚,下一次抓球时直接把球甩飞。

统计面板看着是加分项,答辩时其实是保命符。至少记录:总出手数、命中数、命中率、平均出手速度(米/秒)、平均出手仰角(度)。有了这几个数,演示时能说出"平均出手速度 4.2 米/秒,仰角 52 度,接近真实罚球的出手参数",而不是只能说"你看它能投进"。

// 出手瞬间记录一次样本 shotCount++; float speed = v.magnitude; float angle = Vector3.Angle(v, Vector3.up); // 与竖直方向夹角,真实投篮约 45-55 度 sumSpeed += speed; sumAngle += angle;

Vector3.Angle(v, Vector3.up)得到的是与竖直方向的夹角,这个定义和篮球里说的出手仰角一致(相对水平面是 35 到 45 度,对应与竖直方向 45 到 55 度),写在代码注释里,答辩时不被追问定义就行。

5. 避坑与排查:手势 Demo 最容易翻车的五个点

5.1 手在视场里一晃,球就被抓走了

现象是球莫名其妙黏到手上,或者抓着抓着突然掉。原因有两层:一是GrabStrength在手掌张开时也有 0.1 到 0.3 的底噪,单阈值判定必然误触;二是手进入视场边缘时置信度骤降,位置数据开始漂移,判定条件被噪声满足。

解决办法就是 2.2 里的迟滞加帧数保持。除此之外,再加一个空间门限:手掌必须在handAnchor前方一定范围内才允许抓球,把视场边缘那圈不可靠区域直接排除掉。这个门限用一个球体触发器或者一次距离判断都行,简单粗暴但有效。

5.2 球总是投短,砸在篮圈前沿

这是出现频率最高的抱怨。原因几乎都是速度采样点选错——取的是释放那一帧的瞬时速度,而人在出手的最后阶段手指已经离开球,手的运动在减速,取到的值天然偏小。

改法是把采样窗口往前挪:释放判定触发时,取滑窗内最近 80 到 120 毫秒的平均速度,再叠上抬升补偿。如果改完还是偏短,先别急着调重力,去看出手系数。我一般固定重力,只动throwScale一个变量,一次调 0.05,投十次看落点中位数,两三轮就能收敛。同时动两个参数是自找麻烦,永远调不准。

5.3 球穿过了篮筐,分数没加

现象是动画上球明明进了,计数不动,偶尔还会一次投篮加两分。原因有三个:判定写在Update里导致和物理帧错位;用了触发器而球速太快导致穿透;没有过滤速度方向,球被弹起后二次穿面被重复计数。

按 4.1 的线段穿越法重写判定,放进FixedUpdate,加上velocity.y < 0的方向过滤,三个问题一起解决。额外加一个冷却时间,一次投篮后 0.5 秒内不再接受新的进球判定,防止极端情况下的重复触发。

5.4 换了台机器,球的方向全反了

现象是同一份工程,在这台机器上往前投,换一台就变成往后投。原因是坐标系的手性处理没有统一——有的地方用了插件自带的向量扩展(可能已经翻转过 Z),有的地方手写了new Vector3(x, y, z)。

解决办法是全工程只保留一处坐标转换,写成一个静态方法,所有地方都调它。转换里显式写清楚每个轴的处理,注释标出手性修正的那一行。调完拿调试小球验证:手往前推,小球应该朝场景前方移动;手抬高,小球上升。三个方向逐个确认,别只测一个方向就收工。

5.5 帧率一掉,手感立刻崩

现象是笔记本上跑 60 帧很顺,接上投影仪掉到 30 帧,球开始飘、判定开始漏。原因有两个:一是在Update里做了大量临时对象分配,垃圾回收一触发就是几十毫秒的卡顿;二是手部数据轮询和物理模拟挤在同一帧里。

改法是缓存所有Transform和组件引用,避免在循环里GetComponent;速度滑窗用固定长度数组,不要用List.Add加RemoveAt;把手部采样留在Update,物理和判定留在FixedUpdate,两边通过一个小的数据桥接结构传值。展台机器最好锁 60 帧垂直同步,帧率稳定比帧率高更重要,手感是靠稳定的帧间隔养出来的。

6. 验收与答辩:用数据证明这套手势投篮不是"看着能跑"

答辩现场最容易被问倒的不是代码,是"你怎么知道它做对了"。准备一张验收表,每一项都有指标和测法,问什么都能对上。

验收项指标测法
手部追踪可用率视场内 ≥ 95% 的帧能取到有效手连续录制 60 秒,统计有效帧占比
抓球误触发≤ 2 次/分钟手在视场内自然晃动,不做出手动作,计数误抓
释放判定延迟≤ 1 个物理帧(约 16 ms)高速摄像机或手柄同步信号对比
手感合理性十次投篮命中 4 到 6 次找三到五个不同的人各投十次,取平均
判定准确率视觉进球与计分一致率 100%投 50 次,逐次核对
运行帧率稳定 60 fps,最低不低于 45统计面板常驻显示帧率
连续运行30 分钟不崩、不漂移挂机跑半小时,观察球架回收和内存

演示流程我一般这么排:先展示手张开在视场里晃,球纹丝不动,证明没有误触;再慢慢握拳,球跟手移动,展示跟随的平滑度;然后正常投三次,其中至少一次进球,展示物理和判定;最后打开统计面板,把命中率、平均出手速度、平均仰角念一遍。整个流程控制在两分钟以内,留出时间应对提问。

提问集中在三个方向。问手感怎么调的,答参数表加单一变量回归,说清楚先定重力再调出手系数的顺序。问为什么不用触发器判进球,答高速穿透加帧率依赖,说出线段穿越法的两个判断条件。问这套东西能扩展什么,答左手干扰、多人轮流、不同距离的分值,别答"可以加个排行榜"这种没技术含量的。

从那以后我每次做手势类 Demo,都会先写一个只显示手掌位置的调试小球,把坐标轴、缩放、方向逐个确认对了,再往上叠抓球和物理。这个习惯救过我很多次,因为手势项目里最难排查的问题,永远在最底下那一层数据换算上。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进