前阵子我接到一个线下展厅的互动大屏需求:用户站在屏幕前,隔空比划几下,就能完成按钮点击、页面翻页,同时后台还要记录每个人在哪些区域停留、点了哪个按钮、交互时长是多少。甲方一开始想上Kinect或者TOF深度相机,一算硬件成本加开发周期直接劝退了。最后我用了一台普通USB摄像头,配合Unity的UGUI事件系统,再加一层轻量的手部识别方案,把整套东西做了下来。这里就把这套“Unity+普通摄像头实现UI事件分析”的完整思路、核心代码思路和踩坑记录整理一下,给后面做类似项目的朋友一个参考。
这套方案能做什么?简单说就是三件事:第一,用普通摄像头识别用户手部动作,映射成屏幕坐标,替代鼠标触控去操作UGUI界面;第二,把手势状态解析成语义明确的UI事件,比如悬停、点击、拖拽;第三,记录每一次交互行为,生成可分析的数据,用来做热力图、转化漏斗或者用户行为回溯。适合的场景包括数字展厅、智能货架、互动广告屏、线下自助终端,以及任何不想让用户接触屏幕、又希望统计交互效果的场合。
关于技术选型,我先说结论:如果你项目预算有限、设备就是普通摄像头,那MediaPipe手部关键点识别是当前性价比最高的路线,比OpenCV肤色检测稳定得多,也比深度相机便宜得多。下面我把整个项目的推进过程拆开讲。
1. 项目定位:为什么普通摄像头能扛起UI交互分析这杆旗
1.1 方案对比:深度相机太贵,普通摄像头刚刚好
很多人一听隔空交互,第一反应就是要上Kinect、Azure Kinect、Intel RealSense或者奥比中光这类深度相机。确实,深度相机能直接拿到手部在三维空间的坐标,精度高、抗背景干扰强,但问题也很现实:一个深度模组少则几百多则几千,而且大屏互动往往需要多台设备覆盖多个屏幕,成本一下子就上去了。
普通USB摄像头就不一样了。一个720P、30帧的摄像头才几十块钱,而且现在的电脑、一体机、互动大屏基本都自带摄像头,连采购都省了。可能有人会问:没有深度,只有2D画面,够用吗?实际上,对于隔空操作UI这个场景,2D坐标完全够用。你想一下,用户站在大屏前,手的平面位置决定了光标在哪,至于手离屏幕多远根本不重要,只要判定“什么时候算点击”就行。这个“点击时刻”用2D手势变化就能判断,比如握拳、停留超过阈值、或者做一个明确的点击动作。
所以普通摄像头方案的核心逻辑是:用2D坐标定位,用时间或手势语义判定点击。这个思路在交互体验上不如深度相机那么顺滑,但完全够用,而且能省下90%的硬件成本。
1.2 两条主流技术路线:MediaPipe 与 OpenCV 肤色检测
接手项目时我首先梳理了两条技术路线:
第一条是OpenCV肤色检测。思路是在摄像头画面里做HSV色彩空间转换,把肤色像素提取出来,做一个轮廓查找,然后算轮廓质心作为手部位置。这个方案的优点是可控性强,纯CPU计算,不依赖外部库,Unity里有OpenCV for Unity插件可以直接用。缺点也很明显:对光照极其敏感,肤色阈值一调就是半天,遇到深色衣服或复杂背景时误检率很高,更别说多个人同时入镜。热点词里那些“opencv调用电脑摄像头颜色轮廓”的搜索记录,说明有不少人走过这条老路,但我的建议是:除非你的场景光源完全可控、背景完全纯色,否则别把肤色检测当主力方案。
第二条是MediaPipe手部关键点识别方案。MediaPipe是Google开源的一套机器学习方案,其中的Hand Landmarker模型能输出手部21个关键点的坐标,包括指尖、指节、腕部,还能输出掌心朝向和手势置信度。Unity集成方案也比较成熟,通过UPM包引入com.github.homuler.mediapipe,可以跑在Windows、Android、iOS和WebGL上。这套方案的优点是识别稳定、抗干扰强、精度高,一个普通摄像头就能实现很流畅的手势交互。缺点是需要打包模型文件、对硬件有一定要求(不过现在随便一台电脑跑起来都没压力)。
我最终选了MediaPipe路线。原因很简单:稳定性优先。互动大屏是给真实用户用的,不是给实验室环境用的,背景有行人、灯光有明暗,肤色检测那套根本扛不住。MediaPipe模型经过大量数据训练,对复杂环境的适应性远好于手工特征。
1.3 系统整体架构
整套系统的数据流可以这样理解:
摄像头采集RGB帧 → 输入MediaPipe手部检测管线 → 得到手部关键点坐标与手势置信度 → 坐标映射到屏幕空间 → Unity UGUI事件系统模拟虚拟指针 → 触发按钮悬停、点击事件 → 交互数据写入记录模块 → 输出行为分析结果
按模块拆的话,工程里大致有这几个部分:
- 视频采集模块:封装WebCamTexture,处理镜像、分辨率、帧率;
- 手部识别模块:封装MediaPipe Hand Landmarker的初始化、逐帧推理、结果解析;
- 坐标映射模块:把图像坐标转换成屏幕坐标,做平滑滤波;
- 事件桥接模块:模拟PointerEventData,把虚拟指针路由进UGUI事件管道;
- 行为记录模块:监听事件,按时间戳记录字段,输出CSV/JSON;
- 可视化模块:热力图、统计面板(如果需要实时查看数据)。
这样的分层好处是每个环节可以单独替换。比如你以后想换TOF相机,只需要改坐标映射模块,事件桥接和分析模块完全不用动。
2. 摄像头采集与手势识别:从画面到指尖坐标的核心细节
2.1 WebCamTexture 的坑与调优
Unity读取普通摄像头最直接的方式就是WebCamTexture,上手很简单:
WebCamDevice[] devices = WebCamTexture.devices; WebCamTexture camTexture = new WebCamTexture(devices[0].name, 640, 480, 30); camTexture.Play();这里第一个坑就是分辨率选择。很多新手会直接把分辨率开到1920x1080,结果发现MediaPipe识别根本跑不满帧率。我做性能测试时发现,手部关键点检测在640x360分辨率下和1080p下的定位精度差距很小,但帧率能差到三倍以上。原因很好理解:手部占据图像中很小的区域,但缩小的画面反而让模型更容易泛化,同时减少了纹理上传和推理耗时。所以我的建议是摄像头分辨率控制在640x480或640x360,30帧即可,给MediaPipe预留推理时间。
第二个坑是镜像问题。大屏场景通常显示的是镜像画面(就像照镜子),这样用户抬手时动作方向和屏幕方向一致,体验才自然。但MediaPipe检测结果是在原始图像坐标系下的,如果画面做了镜像显示,坐标就必须跟着翻转。实际操作中我直接翻转RawImage的localScale.x为-1来显示镜像画面,然后在坐标映射时做一次x方向翻转:normalizedX = 1.0f - rawNormalizedX。这个细节不处理好,手停在左边,光标却跑到右边,用户直接懵。
第三个坑是相机启动延迟。WebCamTexture.Play()之后并不是立刻有画面,需要等几帧。我遇到这种情况会在启动后做一次等待逻辑,或者先用一个黑屏图像占位,等camTexture.didUpdateThisFrame为true再接MediaPipe流程,避免空帧导致推理崩掉。
2.2 集成 MediaPipe 手部识别:配置与调用
MediaPipe在Unity里的接入流程比较固定。首先通过UPM导入包,在Package Manager里添加Git URL。之后需要下载手部模型文件hand_landmarker.task,放到StreamingAssets目录下。初始化模型时,可以设置几个关键参数:
var task = HandLandmarker.CreateFromOptions(new HandLandmarkerOptions( BaseOptions = new BaseOptions(modelAssetPath: "hand_landmarker.task"), RunningMode = RunningMode.VIDEO, NumHands = 1, MinHandDetectionConfidence = 0.5f, MinHandPresenceConfidence = 0.5f, MinTrackingConfidence = 0.5f ));关于NumHands这里有个取舍。我测试过同时检测两只手,但大屏交互场景下,两个人同时操作会产生光标冲突,体验反而不如只追踪一只手。所以我直接设置NumHands=1,取置信度最高的那只手作为交互手。
每帧推理时,需要把Unity的Texture2D转换成MediaPipe的Image对象。这个转换是性能关键点。最直接的方式是Texture2D.GetPixels32()再转成ByteBuffer,但开销非常大。优化做法是直接用Graphics.CopyTexture把GPU纹理复制成支持读回的格式,或者使用AsyncGPUReadback异步读取。我这里给一个通用但会慢的写法作为参考,等熟悉后再优化:
Texture2D tex = new Texture2D(width, height, TextureFormat.RGBA32, false); RenderTexture.active = renderTexture; tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); tex.Apply();推理完成后,结果里包含21个关键点。每个关键点有归一化的x、y坐标(相对图像宽高)和一个z值(表示深度参考,不是真实距离)。同时还有handedness信息,告诉你是左手还是右手——前置摄像头拍的画面相当于镜像,所以左手的识别结果可能是Right,这个信息我一般不用来判断左右,只看点位。
2.3 理解21个关键点与手势状态
MediaPipe手部模型的21个关键点分布很直观:0号点是腕部,1到4号是拇指(从根部到指尖),5到8号是食指,9到12号是中指,13到16号是无名指,17到20号是小指。
要判断一个手势,最常用的点就是指尖(4、8、12、16、20)和腕部(0)。比如判断“握拳”,可以这样想:握拳时指尖会靠近掌心,所以指尖到腕部的距离会显著缩短。我可以计算每个指尖到腕部的归一化距离,当所有指尖都低于某个阈值时,判定为握拳;当食指尖距离明显大于其他手指时,判定为“食指指向”或“抬手悬停”。
我实际用的手势判断逻辑是:
- 悬停/移动:只要检测到手,默认就是悬停状态,此时控制光标在UI上移动;
- 点击准备:手势变为握拳,且保持稳定;
- 点击触发:握拳状态下,拳头在某个位置停留超过阈值时间,或握拳状态持续时间跨越两个状态帧并及时松开;
- 无效手势:连续帧置信度低于阈值,直接忽略。
这个状态机看起来简单,但实际工程里一定要处理抖振问题。建议一套手势判断要有连续N帧相同结果才能进入对应状态,比如连续3帧都判定为握拳,才真正进入握拳状态,避免单帧误检触发点击。
2.4 坐标映射与平滑
识别出来的关键点坐标是归一化图像坐标(0到1之间浮点数),如果画面是镜像的,先做x翻转。接下来要把它映射到Unity屏幕坐标。
我的映射方式:
Vector3 screenPos = new Vector3( normalizedX * Screen.width, (1.0f - normalizedY) * Screen.height, 0f );注意y轴方向:图像坐标原点在左上角,屏幕坐标原点在左下角,所以y要翻转。
但这里有个大坑:RawImage在Canvas上的显示比例和Screen的比例不一定一致。如果RawImage拉伸填满整个画面,视频画面比例和屏幕比例不同,画面会被拉伸,坐标映射也要跟着拉伸。处理这个问题要么让RawImage保持宽高比(加ContentSizeFitter),要么在映射时按照RawImage的实际显示尺寸做转换。我推荐后者,因为大屏场景往往希望画面全屏展示。
坐标平滑是另一个关键点。摄像头逐帧检测的原始坐标会有不少抖动,直接驱动光标的话,用户会看到光标在目标按钮上疯狂抖动,根本没法进行悬停点击。我用的是一阶指数平滑:
smoothedPos = Vector3.Lerp(smoothedPos, rawTargetPos, 0.3f);这个alpha取值需要现场调,通常在0.2到0.4之间。alpha太小,光标反应迟钝,用户手指移过去了光标还在后面追;alpha太大,抖动过滤不掉。另外,当检测到手部丢失后又重新出现时,要把smoothedPos直接赋值成当前坐标,避免光标从上一帧位置“飞”过来。
3. 把手势变成UGUI事件:从坐标到点击的魔法桥接
3.1 为什么要模拟 PointerEventData
Unity的UGUI事件交互核心是EventSystem,它会维护一个“当前指针”概念,鼠标是最常见的指针。正常情况下,EventSystem把鼠标位置射向UI元素,触发OnPointerEnter、OnPointerClick等回调。现在我们要做的,是把自己的手部坐标伪装成一个鼠标指针,喂给UGUI事件系统。
为什么不直接改Input.mousePosition?有两个原因:一是鼠标指针还在屏幕某个位置,两个指针同时存在会造成混乱;二是UGUI的事件路由涉及hover状态管理,直接写坐标绕过了EventSystem的状态机,很容易出现按钮悬停态不更新、点击事件触发两次之类的问题。
正确做法是实现一个自定义的“虚拟指针”,并给它喂一个构造好的PointerEventData。这样可以复用UGUI的事件路由、GraphicRaycaster的射线检测逻辑,以及所有UI组件的事件接口。简单说,就是把EventSystem的那套“鼠标”替换成“手势光标”,事件链路完全不变。
3.2 实现虚拟指针与事件路由
核心代码思路是这样的:
PointerEventData pointerData = new PointerEventData(EventSystem.current); pointerData.position = currentScreenPos; List<RaycastResult> results = new List<RaycastResult>(); graphicRaycaster.Raycast(pointerData, results);拿到results后,第一项就是当前命中的最上层UI元素。接下来要在这个元素上依次比较“上一帧悬停的元素”和“当前帧悬停的元素”,如果不同,就触发OnPointerExit和OnPointerEnter。
点击逻辑则需要维护一个点击状态机。我的实现是:当手势为握拳且位移小于20像素时,开始累计停留时间;当停留时间超过0.6秒,触发一次OnPointerDown和OnPointerClick。之后即使手没动,也只在松开手势时触发OnPointerUp,避免连续触发。
这里额外要说一个点:UGUI的GraphicRaycaster默认只检测带Graphic组件的UI元素,如果你用了CanvasGroup、按钮嵌套、ScrollView等结构,记得在Canvas上添加合理的Ignore Reversed Graphics设置,避免后面绘制的网格挡住前面按钮。
3.3 点击判定策略:悬停计时还是手势变化
关于“什么时候算点击”,业界大体有两种方案。
第一种是“悬停计时点击”:手在某位置停留超过设定时间即触发点击。优点是用户容易学习,不需要额外手势动作;缺点是容易误触,用户只是停下来看内容就被误判成点击。
第二种是“手势变化点击”:比如从张开手变成握拳算点击,或者食指向下弯曲一次算点击。优点是更符合“隔空点击”的直觉,误触率低;缺点是手势识别会增加延迟,而且用户容易做得不标准导致识别失败。
我自己的项目里用的是混合方案:悬停0.6秒+手部移动距离低于30像素,判定为一次点击。这个方案不需要用户学习手势,稳定性高。在展厅实际测试时,用户第一次使用平均2-3秒就能理解“我停住就是点击”,学习成本很低。如果你希望更准,可以在停留的基础上加入“张开→握拳”的切换作为确认信号,视觉反馈上做一个倒计时圆环,用户看着圆环走完就知道点击生效了。
3.4 命中区域优化:如何扩大按钮点击范围
做大屏互动UI,最容易忽略的是命中区域设计。屏幕上按钮看起来那么大,但在大屏场景下用户手臂悬空,对位置的掌控精度远不如鼠标或手指触摸。我一开始用默认Button组件,结果用户经常点不中,尤其是小按钮。
这里就用上了热搜里那个问题——“unity如何扩大按钮的点击范围”。其实思路很简单:把可视的图形和可点击的命中区分离。我给每个按钮增加了一个不可见的Image组件,设成透明(Alpha=0),但RaycastTarget=true,并把这个透明区域的RectTransform扩展成原来的1.5到2倍大小。这样视觉上按钮是40x40,实际点击区是80x80,点击成功率大幅提高。
另外一个进阶做法是重写RectTransform的扩展方法,动态计算命中区域,但我觉得对多数项目没必要。把透明底图放大2倍,已经能覆盖绝大多数误点场景了。
3.5 交互反馈设计
隔空交互和物理点击的最大区别是没有“手指碰到屏幕”的反馈,所以视觉和听觉反馈必须做得比正常UI更重。我建议每个可交互元素至少有三种状态:
- 未命中:普通状态;
- 悬停:缩放1.1倍或高亮描边,让用户知道“我的手已经对准这个按钮了”;
- 点击生效:变色+音效+可能有震动。
这里要注意反馈延迟。临界点就是0.3秒——反馈的视觉变化必须在手势判断为点击的瞬间出现,而不是等0.6秒的停留计时结束后才变化。所以我的做法是:在手势进入“疑似点击”(握拳且位移小于阈值)状态时,立刻播放一个1.2秒的进度环动画;进度环走完时,点击事件真正触发。这样用户既有即时反馈,又不会因为等待而产生短暂迷茫。
4. UI事件分析:不止是触发交互,还要记录行为
4.1 数据模型:一次交互记录哪些字段
“UI事件分析”真正的价值在于把交互过程变成可量化数据。我给每条交互记录定义了这些字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| sessionId | string | 一次会话的唯一标识,通常按摄像头画面出现手到消失为一次会话 |
| timestamp | DateTime | 事件发生时间,精确到毫秒 |
| eventType | enum | hover_enter、hover_exit、click、drag_start、drag_end |
| uiElementId | string | 命中的UI元素名称,Button的name或路径 |
| normalizedX | float | 事件位置的归一化x坐标 |
| normalizedY | float | 事件位置的归一化y坐标 |
| handConfidence | float | 手部检测置信度,方便事后剔除低质量数据 |
| duration | float | 悬停或点击的持续时长,毫秒 |
建这个模型时有个容易被忽略的细节:uiElementId不能只记录按钮名字,因为不同页面可能有相同名字的按钮。我采用Canvas路径拼上按钮名,比如MainPage/StartButton,这样后期能准确知道用户点的是哪个页面哪个按钮。
另外,任何一次悬停event都要记录进入和离开两条记录,才能算停留时长。光有click记录是没法分析“用户在哪里犹豫了很久”的。
4.2 存储与导出
数据量不大时,直接写成CSV文件就够了。我在Update里收集事件队列,每1秒批量写入一次到Application.persistentDataPath下。这样避免高频WriteAllText导致主线程卡顿。
如果项目需要做更复杂的查询,比如按时间段、按用户、按UI元素筛选,可以用SQLite。Unity里比较成熟的方案是sqlite-net,操作简单,支持线程,适合现场数据分析面板实时查询。
这里要特别提醒一个部署上的坑:如果你把项目发布成WebGL,Application.persistentDataPath对应的是浏览器里的IndexedDB文件系统,写入失败是常见问题。热搜里那个“unity 发布 webgl 使用 idbfs 写入失败”就是这个问题。原因通常是浏览器隐私模式禁用IndexedDB、存储配额已满,或者用户还没触发任何交互就尝试写入。我的建议是:WebGL版本不要依赖本地文件写入,直接把交互记录通过网络请求发到后端API,或者至少在页面关闭前做一次批量上报。
4.3 热力图的实现思路
热力图是UI事件分析中最直观的展示方式。我实现时很简单:把整个屏幕划分为32x18的网格(或者按实际屏幕比例设),每个网格存一个计数数组。每条click或hover_enter记录到达时,根据归一化坐标找到网格索引,计数加一。结束时要显示热力图,就按不同计数值映射成颜色,用Texture2D绘制出来,叠在交互页面上做半透明展示。
这里有个细节:热力图的颜色映射要符合认知。我用的色带从蓝到绿到红,数值越高颜色越暖。为了让数据对比明显,最好做归一化,用最大值把计数缩放到0到1。
热力图不仅能看到“哪里点击多”,还能看到“哪里用户一直悬停但没点击”——后者往往意味着用户尝试寻找一个入口却找不到,是产品设计问题的信号。
4.4 交互漏斗与场景价值
有了数据之后,可以做最有价值的漏斗分析:
摄像头检测到人 → 人手出现 → 首次悬停 → 首次点击 → 完成核心任务
每一层的转化率都能反映交互设计的健康度。比如“人手出现”到“首次悬停”转化率低,说明用户没理解“抬手就能操作”这件事,需要加引导动画;“首次悬停”到“首次点击”转化率低,说明点击判定手感有问题,可能阈值太严或者按钮命中区太小。
这套分析在数字展厅场景里特别有价值。展方需要知道哪块内容受欢迎、观众在哪个展项前停留最久、哪个按钮反复被点。放在传统方案里需要用感知设备加专门的分析系统,现在一个USB摄像头加一套UI事件记录就能闭环。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 画面是反的,光标方向相反 | 摄像头镜像未处理 | RawImage的localScale.x设为-1,同时坐标映射做x翻转 |
| 光标剧烈抖动,按钮悬停态乱跳 | 未做坐标平滑或alpha过大 | 用Lerp做指数平滑,alpha取0.2-0.4;检测丢失后重置位置 |
| 手不动时偶尔触发点击 | 点击判定阈值太小 | 延长停留时间到0.6秒,增加移动距离阈值到30像素 |
| 点击事件触发两次 | 每帧都构造PointerEventData,Down和Up逻辑混乱 | 点击事件用状态机管理,同一手势周期内只触发一次Click |
| MediaPipe检测度很低,手部框跟不上 | 分辨率太高导致帧率低,或置信度阈值过严 | 降到640x480,MinHandDetectionConfidence调到0.4 |
| 背景有其他人,光标乱跑 | NumHands大于1或置信度低 | 设为NumHands=1,忽略低置信度的检测结果 |
| WebGL部署后数据存不下来 | IDBFS写入失败 | 改用网络请求上报,或等用户交互后再写文件 |
| 打开程序黑屏,摄像头无画面 | 摄像头权限未授权或设备被占用 | 检查系统权限,关闭其他占用摄像头的软件 |
5.2 性能优化三板斧
性能是整个方案的生死线。我做的第一版在1080p分辨率下,MediaPipe推理一帧要80毫秒,UI帧率掉到个位数,体验完全不可用。后来做了三个优化才救回来。
第一刀砍分辨率。摄像头从1080p降到640x360,推理耗时砍掉一半多,而手部关键点精度几乎没变。第二刀做跳帧。MediaPipe哪怕在360p下推理也要20毫秒左右,没必要每帧都跑。我改成每2帧跑一次推理,中间帧用上一次的结果做插值,配合平滑滤波,视觉上依然顺滑。第三刀优化纹理读取。不在主线程用ReadPixels,改成在协程里用AsyncGPUReadback,或者只在需要推理的那一帧读取一次,避免每帧都做Texture2D转换。
优化后,整个系统在普通办公电脑上能稳定跑到30到40帧,MediaPipe推理消耗控制在每秒15次以内,UI渲染完全不受影响。
5.3 网络摄像头与RTSP流场景扩展
有些项目场景没有USB摄像头,而是用已有的网络摄像头,比如海康、宇视、大华的枪机或半球摄像头。Unity的WebCamTexture不支持直接拉RTSP流,这是个硬伤。我查到的可行解决方案有这几个:
第一种,用VLC for Unity插件。这个基于libVLC,Unity的VideoPlayer不支持RTSP,但VLC的Unity版本支持,拉流后可以输出到Texture。缺点是这个插件要许可证费,而且WebGL不支持。第二种,自建一个RTSP转虚拟摄像头的中间层。在本地跑一个FFmpeg或者OBS,把RTSP流转成虚拟摄像头源,然后Unity的WebCamTexture列表里就能看到它。这个方法免费、通用、稳定,缺点是部署多了一层。第三种,直接在Unity里集成FFmpeg库来解码RTSP。技术可行但工程量不小,如果没有专门团队,我不建议自己写解码。
如果你只是做原型验证,建议直接上OBS虚拟摄像头方案,20分钟就能通。生产环境再考虑VLC插件或者定制化方案。
5.4 部署到WebGL与移动端的注意点
WebGL部署有三个坑一定要提前规避。第一个是摄像头权限。浏览器必须跑在HTTPS或者localhost环境下才能调用getUserMedia,否则摄像头直接打不开。第二是模型文件加载。StreamingAssets在WebGL下要用UnityWebRequest加载,不能直接用File读取,MediaPipe的Unity插件通常会封装好这一点,但你要确认模型路径配置正确。第三个就是前面说的IDBFS写入失败,交互记录一定要走网络上报,不能依赖本地文件。
移动端部署则主要是权限问题。Android打包需要在Manifest里加上CAMERA权限,并且在运行时申请动态权限。iOS也一样,需要在Info.plist里加NSCameraUsageDescription。如果你是在Pico这类XR设备上跑,要注意摄像头可能被系统占用,建议先用系统自带的Passthrough权限测试,再接管相机源。
5.5 我踩过的几个细节坑
最后分享几个文档里不太会写的细节。
第一个是UI刷新时机。EventSystem的Update和我的手势驱动更新都在Update阶段,如果顺序不对,会经常发生“上一帧的悬停状态还没更新,这一帧的手势已经变了”的错位。我最后的解决办法是,手势驱动的坐标更新放在Update末尾,事件路由放在LateUpdate,保证同一帧的数据完整统一。
第二个是置信度的阈值需要分场景调。展厅现场灯光暗、逆光、玻璃反光都会影响检测。我预置了三档配置:明亮环境、普通环境、暗光环境,现场部署时一键切换,比每次都改代码方便太多。
第三个是手柄光标的位置偏移。MediaPipe返回的腕部坐标和食指尖坐标往往差出几个屏幕像素,你到底用哪个指头当光标?我试过用腕部、用食指、用拇指,最终发现食指指腹最符合“手指点在按钮上”的直觉。如果你发现光标位置总是和用户预期偏差,试试切换一下关键点索引。
结尾
这套“Unity+普通摄像头实现UI事件分析”的方案,我前前后后做了三四个版本,从最早OpenCV肤色检测的痛苦挣扎,到MediaPipe带来的体验跃升,再到最后的UGUI事件桥接和环境适配,每一步都踩过不少坑。现在回看,最值钱的其实是“把摄像头手势变成标准UI事件”这一步——一旦数据进入了UGUI事件系统,后面所有分析、可视化、优化都是水到渠成的事情。
如果你正打算做类似的项目,我个人的建议是:先用最简单的代码把整条链路跑通,哪怕识别不准、事件乱触发都没关系,先看到“摄像头→手势→UI事件→数据记录”的完整闭环,再逐段优化。这个项目后续可以扩展的方向也很多,比如接入数字孪生场景做隔空操控大屏模型,或者把同一套手势方案带到XR设备里做混合现实交互入口。我目前正在做的版本加了双人手势支持,虽然很多细节还没跑顺,但已经能看到它在大屏互动这块的应用潜力了。