1. 需求拆解与方案选型:为什么偏偏要做“贴着纸面的按钮”
做AR交互的朋友大概都遇到过同一个尴尬:识别图在桌上,内容从图里“长”出来了,用户第一反应是伸手去戳——手指直接穿过去,什么都摸不到。这时候屏幕上的UI按钮就显得很“出戏”,因为它是贴在屏幕玻璃上的,和空间里的模型完全不在一个次元。Vuforia 的虚拟按钮(Virtual Button)就是为了解决这个违和感而存在的:它把一块矩形区域“印”在识别图的纹理坐标上,用户用手指去遮住那块区域,引擎判定为“按下”,于是模型动起来。手指按的是纸,反馈却来自数字内容,这个错觉一旦成立,交互的沉浸感立刻上了一个台阶。
这篇内容讲的是 Vuforia AR 系列里的第四个主题——虚拟按钮。我会从它的判定原理讲起,说清楚灵敏度、区域尺寸这些参数到底在调什么,然后给出一套可以照着复现的 Unity 工程搭建流程和 C# 代码,最后把我在真机上踩过的坑一条条摊开。适合已经跑通过图像识别、想要往上加交互的开发者,也适合被“按不动”“乱触发”折腾过的朋友直接来抄作业。整篇不会停留在 API 罗列,重点在于参数怎么标定、按钮区域怎么摆、出问题从哪一层开始查。
1.1 虚拟按钮解决的到底是什么问题
把话说直白点:虚拟按钮不是“按钮”,是一次空间对齐的遮挡检测。它要解决的核心矛盾是——用户希望交互发生在“物”上,而绝大多数输入方式只能发生在“屏”上。触摸屏坐标是二维屏幕坐标,识别图是三维空间里的一个平面,两者的转换需要一个锚点,虚拟按钮就是这个锚点。
我在一个展陈类的模拟项目里第一次真正用上它。那是个纸质操作卡加设备模型的场景,观众拿起卡片,模型浮在卡片上方,卡片上印着几个灰色的圆形标记位,观众手指按住其中一个,模型对应的部件就开始旋转。整个过程没有屏幕UI,也没有手势学习成本——人天生就会“按住一个圆圈”。这个细节听上去很小,但它把“看AR”变成了“玩AR”,停留时长完全不是一个量级。
需要强调的是,虚拟按钮的适用范围有明确边界。它适合离散的、低频的、需要空间归属感的操作:开/关、切换下一状态、播放音效、上一步下一步。它不适合高频连续输入(比如调节音量滑杆)、不适合精确点选(比如在密集菜单里选第三项)、也不适合长时间按住不放的操作(手指挡着识别图会干扰追踪本身)。想清楚这一点,能省掉后面一半的调优时间。
1.2 三条技术路线的取舍:UI按钮、手势识别、虚拟按钮
同样是“点一下触发事件”,业内有三种常见做法,各有各的脾气。我在不同项目里都用过,这里做一份对照,方便你对着自己的场景做选型。
| 方案 | 触发介质 | 空间感 | 抗遮挡能力 | 实现成本 | 典型适用场景 |
|---|---|---|---|---|---|
| 屏幕UI按钮 | 屏幕像素坐标 | 弱,浮在画面最上层 | 不受识别影响 | 最低 | 设置面板、返回、帮助 |
| 手势识别(点击/拖拽射线) | 屏幕射线与AR平面求交 | 中等 | 依赖平面命中率 | 中等 | 模型旋转、拖拽摆放 |
| 虚拟按钮 | 识别图纹理坐标上的遮挡 | 强,按钮“贴”在图上 | 本身就是遮挡触发,但对追踪有副作用 | 中等 | 卡片类交互、按键式面板、教学道具 |
差别的本质在于锚定物。UI按钮锚在屏幕上,手势锚在射线与平面的交点上,虚拟按钮锚在识别图的具体纹理位置。只要用户手里拿的是那张卡片,按钮就永远“长”在那儿,卡片移动,按钮跟着移动,这种稳定感是前两种方案给不了的。
但也别把它当万能药。手势射线方案在目标距离较远、卡片视角很斜的时候反而更稳,因为用户是在屏幕周围点,不依赖纹理遮挡质量。虚拟按钮在极斜视角下几乎按不动——这是几何决定的,后面第5节会细说。我的经验是:主导航用虚拟按钮,辅助操作用UI按钮兜底。两者并存,用户按不动的时候还有退路,这在展会这种“什么人都有、什么姿势都有”的现场环境中尤其重要。
2. 核心细节解析:虚拟按钮到底在“看”什么
要调好一个虚拟按钮,必须先搞明白它在每一帧做了什么。很多“按不动”的问题,根源在于开发者以为它在检测“手指有没有碰到屏幕那一块”,而它实际检测的是“相机画面里那块区域和原始纹理像不像”。
2.1 遮挡率判定:一次逐帧的纹理比对
处理链路大致是这样的:每一帧,引擎根据识别图在画面中的姿态,把虚拟按钮定义的那块矩形区域从实时相机画面中反投影出来,与目标图像的原始纹理做对比,算出一个差异程度。手指盖上去,那块区域的画面就不再是原始纹理了,差异变大;超过阈值,就判定为 Pressed;手指移开,差异回落,判定为 Released。
由此可以推导出几条硬性约束,都是我在现场真正吃过亏的:
- 按钮区域必须有足够纹理。把按钮放在识别图上的一块纯白或纯黑区域,差异判定会变得极不稳定,因为原始纹理本身就没什么特征,画面里多点少点噪声,算出来的比例波动很大。
- 按钮区域不能贴边或者超出图像边界。超出边界的部分没有原始纹理可对比,判定直接失效;贴边的部分在斜视角下最先被裁出画面,会导致“某些角度按不动,正面能按”。
- 整体的光照变化会影响判定。整张卡片被强光打白、或者投下大面积阴影,所有按钮的差异值都会整体漂移,表现为“什么都没碰,按钮自己跳了一下”。
- 透明或高反光的遮挡物效果差。戴手套、用亚克力棒、用指甲盖去点,遮挡带来的纹理差异可能达不到阈值,用户会觉得“这按钮不灵”。手指肉色、面积够大、轻微用力压平,效果最好。
理解了“它在比对纹理”,很多玄学问题就变成可解释的工程问题了。
2.2 灵敏度与按钮尺寸的联合作用
这两个参数必须一起调,单独调任何一个都会陷入“要么按不动,要么乱触发”的循环。
灵敏度(Sensitivity Setting)本质上是判定阈值的档位。高档位意味着较小的纹理差异就能触发,反应快但容易误判;低档位需要更明显的遮挡才触发,稳但迟钝;中档是默认值,适合大部分场景。别指望记住具体数值,把它理解成“阈值三档”就够了。
按钮尺寸用归一化坐标定义,以识别图纹理的左下角为原点,宽高取值为 0 到 1 的比例。这个坐标系和 Unity 的 UI 坐标(左上角原点、像素单位)完全不同,是最容易搞混的地方之一。
尺寸的选择有个经验区间:按钮单边占识别图宽度的10% 到 20%比较舒服。低于 8% 时,手指的接触面积和按钮区域的交集太小,稍微偏一点就挡不住判定区;高于 25% 时,相邻按钮之间容易串扰,而且大面积遮挡会明显拖累图像追踪的稳定性——这是很多人忽略的副作用:你挡住的不只是按钮区域,还有识别图上的特征点。
2.3 事件回调的执行顺序与生命周期
虚拟按钮的生命周期里,有几个状态必须心里有数:Pressed、Released、以及一个容易被忽视的“目标丢失”。正常的顺序是 Pressed 到 Released 成对出现,但目标一旦丢失,情况就微妙了。
比较稳妥的做法是,自己维护一份按钮的按下状态表,并在追踪丢失的回调里强制复位。原因是:目标丢失后引擎可能不再产生 Released 事件(因为它连按钮在哪都不知道了),用户手指明明已经离开,你的业务逻辑却以为按钮还按着,模型卡在半截动画里转圈。这个 bug 在真机上偶发、在 Editor 里几乎复现不了,非常折磨人。
还有一个细节:Pressed 和 Released 都是逐帧判定的产物,快速的点按可能在两帧之间完成“按下—抬起”,而引擎的状态机存在去抖设计,极端情况下会漏掉一次完整按压。所以对于“必须被记录”的操作(比如计分、答题),不要依赖单次事件,要在业务层做一次幂等或加一个短冷却。
3. 动手搭建:一个可复现的最小可用 Demo
下面这套流程是剥离掉所有业务逻辑的最小骨架,跑通之后往上面加什么都行。我用一个虚构的模拟项目来举例:一张纸质控制卡,上面三个圆形标记位分别控制模型的旋转、缩放和重置。
3.1 工程准备与场景骨架
先确认环境。识别功能要能正常跑起来,这是前提——虚拟按钮是坐在图像识别上的,追踪都不稳的话,调按钮纯属浪费时间。场景骨架包含三层:
- AR Camera:带识别组件的相机,配置好授权与设备数据库。
- ImageTarget:识别目标物体,挂载图像目标行为组件,场景里的模型作为它的子物体。
- VirtualButton:作为 ImageTarget 的子物体,挂载虚拟按钮行为组件,每个按钮一个空物体。
需要提醒的是,按钮物体本身不需要渲染器、不需要碰撞体,它就是一个“坐标占位符”,位置由归一化区域数据决定。很多人下意识地给它加个 SpriteRenderer 想“看见”按钮,结果发现那个精灵的尺寸和判定区域根本没有关系——判定区域是由组件内部数据决定的,跟 Transform 的缩放无关(缩放改的是纹理坐标的映射基准之外的东西)。想在场景里可视化按钮范围,得自己写个 Gizmos 绘制或者在识别图纹理上直接画标记位。
3.2 归一化坐标:按钮区域怎么摆
这是最容易出错的一步。设置区域用的是“中心点 + 尺寸”的归一化写法,原点在识别图左下角,X 向右、Y 向上。举几个具体例子:
- 想要一个位于图像正中央、宽高各占 20% 的按钮:中心点 (0.5, 0.5),尺寸 (0.2, 0.2)。
- 想要左下角一个占宽 15%、高 10% 的按钮:中心点 (0.1, 0.08) 附近,尺寸 (0.15, 0.10)。注意别让中心点减去半个尺寸之后变成负数,那就越界了。
- 三个并排的按钮:假设图像宽度 1,三个按钮各宽 0.15,间距 0.05,总占宽 0.55,从左起第一个中心点 X 约 0.225,第二个 0.5,第三个 0.775,Y 统一 0.3 左右。
我通常会在设计识别图的时候就把按钮位置直接画进纹理——灰色圆环、虚线框、半透明色块都行。这样做有三个好处:一是坐标有参照,你知道 0.225 到底对应画面上哪个点;二是用户有明确的视觉引导,不至于在卡片上乱摸;三是这些人工纹理本身也能提升该区域的遮挡检测灵敏度。
同时,每个按钮的名字必须唯一,这个名字是你后续事件分发和日志排查的唯一标识。命名规范我一般用“功能_位置”,比如rotate_left、zoom_center,日志里一眼就能看出来是哪个按钮出问题。
3.3 代码:事件订阅与状态机
绑定事件有两条路:Inspector 里拖拽(组件自带按下/抬起事件槽),或者用代码注册回调。前期验证用拖拽最快,正式项目建议代码注册,方便统一管理和防漏解绑。下面这段是我常用的骨架,直接抄可以用:
using System.Collections.Generic; using UnityEngine; using Vuforia; public class VirtualButtonRouter : MonoBehaviour { // 记录每个按钮当前是否处于按下状态,用于防抖与丢失复位 private readonly Dictionary<string, bool> pressedState = new Dictionary<string, bool>(); private VirtualButtonBehaviour[] buttons; void Start() { buttons = GetComponentsInChildren<VirtualButtonBehaviour>(true); foreach (var btn in buttons) { pressedState[btn.VirtualButtonName] = false; btn.RegisterOnButtonPressed(HandlePressed); btn.RegisterOnButtonReleased(HandleReleased); } } void OnDestroy() { if (buttons == null) return; foreach (var btn in buttons) { if (btn == null) continue; btn.UnregisterOnButtonPressed(HandlePressed); btn.UnregisterOnButtonReleased(HandleReleased); } } private void HandlePressed(VirtualButtonBehaviour vb) { if (pressedState[vb.VirtualButtonName]) return; // 幂等保护 pressedState[vb.VirtualButtonName] = true; Dispatch(vb.VirtualButtonName, true); } private void HandleReleased(VirtualButtonBehaviour vb) { if (!pressedState[vb.VirtualButtonName]) return; pressedState[vb.VirtualButtonName] = false; Dispatch(vb.VirtualButtonName, false); } private void Dispatch(string name, bool isDown) { switch (name) { case "btn_rotate": if (isDown) RotateOnce(); break; case "btn_zoom": if (isDown) ZoomToggle(); break; case "btn_reset": if (isDown) ResetAll(); break; default: Debug.LogWarning("未注册的虚拟按钮: " + name); break; } } // 追踪丢失时强制复位所有按钮,避免状态残留 public void ForceReleaseAll() { foreach (var kv in pressedState) { if (kv.Value) { pressedState[kv.Key] = false; Dispatch(kv.Key, false); } } } private void RotateOnce() { /* 旋转逻辑 */ } private void ZoomToggle() { /* 缩放切换 */ } private void ResetAll() { /* 复位 */ } }这段代码里有两个地方值得单独说一下。一是ForceReleaseAll(),需要在识别目标的追踪丢失回调里调用它(挂到自己写的事件处理器上,接到追踪丢失的时机),这样目标被移出画面再移回来,按钮状态是干净的。二是pressedState字典的幂等保护,看起来多余,但在快速连点和引擎偶尔重复上报的情况下,它能避免一次按压触发两次业务逻辑——答题类应用里,这直接关系到分数对不对。
还有一点:GetComponentsInChildren(true)的第二个参数带上true,是为了把未激活的子物体也纳入注册范围。场景里如果有隐藏的按钮(比如按阶段解锁的),不带这个参数会漏注册,等到显示的时候发现按不动,排查半天。
3.4 反馈设计:把“按下”变成看得见的反馈
虚拟按钮本身没有任何视觉反馈,用户按下去,全世界安静——这是最容易被忽略的体验断点。必须补上至少一层反馈,否则用户根本不知道自己按没按到。
我的做法是三件套叠加:
- 即时视觉:按下瞬间把按钮对应的模型部件做轻微缩放(0.95 倍)或颜色偏移,抬起恢复。用协程做 0.1 秒的缓动,不要生硬切换。
- 触觉:移动端调用一次轻度震动。这一下非常关键,它是唯一不依赖视觉的确认,用户在低头看卡片、没看屏幕的时候,靠震动就知道成功了。
- 听觉:一个短促的“咔”声,音量不要大。机械感的音效比电子音更适合“按钮”这个隐喻。
三件套不必全开。展会展台环境嘈杂,声音几乎没用,触觉加视觉就够;家用场景安静,声音反而加分。我在模拟项目里最终定的是视觉加触觉,把音频关掉了,因为多个按钮同时被误触时会响成一片。
4. 手感调优:参数标定与多按钮逻辑
参数这件事,看文档只能知道“是什么”,真正的答案得靠一轮一轮实测。下面这套标定流程是我固化下来的,新项目直接跑一遍,半小时能定完。
4.1 一份可执行的标定流程
标定的目标很明确:找到“用户正常按压能稳定触发、轻微碰到不会误触”的那组参数。具体步骤:
- 先定物理尺寸,再定归一化尺寸。把识别图按实际打印尺寸做出来(模拟项目里是 A5 大小),量一下一个成人的指腹接触面直径,大约 1.5 厘米到 2 厘米。按钮的物理边长做到 2.5 厘米到 3 厘米,是比较舒服的。再换算成归一化比例:A5 宽度约 14.8 厘米,3 厘米对应约 0.2,正好落在前面说的 10% 到 20% 区间。
- 灵敏度从中间档开始。三档里先测默认档,如果“十次按压九次成”,就合格了。如果经常按不动,往高档调;如果出现没碰就触发,往低档调。不要一上来就用最高档,误触率会高到没法用。
- 做三种姿态的实测:正面正对、倾斜约 45 度、距离 30 厘米和 60 厘米各一轮。记录每一轮的触发成功率。我的验收标准是正面接近 100%、45 度不低于 80%。低角度必然下降,这是几何决定的,接受它。
- 测干扰:手指悬停在按钮上方 1 厘米但不接触,应该完全不触发;用手指从按钮上快速划过去,应该是短暂的单次触发,而不是连续多次。
- 测连续操作:同一按钮连续按 10 次,看有没有漏事件;三个按钮交替按,看有没有串扰。
把每一轮的结果记在一个小表里,调参就有了依据,而不是“感觉差不多了”。我见过太多项目在办公室测着挺好,到现场灯光一变全崩,问题就是没做多姿态和多光照的标定。
4.2 长按、组合与防误触的处理
虚拟按钮只提供“按下/抬起”两个事件,但用状态机可以做出更多花样。
长按:在 Pressed 里启动一个计时器,达到阈值(我一般用 0.6 秒到 1 秒)后触发长按逻辑,Released 时如果还没到阈值就按短按处理。注意长按期间手指持续遮挡识别图,追踪稳定性会下降,长按阈值不宜超过 1.5 秒。
组合:多个按钮同时处于按下状态时触发特殊功能。这里有个隐患——两个按钮的判定区域如果靠得太近,一根手指可能同时遮挡住两个,导致误判为组合键。按钮之间至少留出半个按钮宽度的间距,这是硬性要求。
防误触:最简单有效的是加一个全局的短冷却(比如 200 毫秒),一次触发后短暂屏蔽其他按钮的输入。另外可以把“按下”和“动作”解耦——按下只做视觉反馈,抬起时才真正执行动作,用户按住不放又反悔移开手指,动作就不执行了。这个模式在展陈场景特别好用,因为观众经常会误碰。
还有一个思路值得试试:把按钮区域和视觉标记错开一点。视觉圆环画在 A 点,判定区域实际覆盖 A 点及其周围一圈(视觉上略小,判定略大),这样用户瞄准的是圆环,但稍微偏一点也能按到,成功率会明显提升。用户从来不会精确地按在正中间。
5. 踩坑实录与排查清单
这一节全是真机上撞出来的。按“从哪一层开始查”的顺序排,照着走能省很多时间。
5.1 完全不触发:从识别到区域逐层排查
遇到“怎么按都没反应”,不要乱改参数,按这个顺序往下走:
| 排查层级 | 检查项 | 判断依据 |
|---|---|---|
| 第一层:追踪 | 识别图是否持续被追踪 | 模型是否稳定显示、追踪状态是否正常 |
| 第二层:组件 | 按钮组件是否挂载并启用 | 组件是否在激活的物体上、名字是否唯一 |
| 第三层:区域 | 归一化坐标是否在合法范围内 | 中心点加减半宽高后是否越出 0 到 1 |
| 第四层:区域质量 | 按钮下方是否有足够纹理 | 纯色区必然失效,换位置试 |
| 第五层:事件 | 回调是否注册成功 | 加日志确认回调有没有进来 |
| 第六层:设备 | 是否在用真机测试 | Editor 中的模拟输入不等价 |
绝大多数“完全不触发”卡在第三层和第四层。尤其是第三层,归一化坐标的 Y 轴方向搞反(以为原点在左上角),按钮被摆到了图像外面,症状就是“永远不触发”,而且代码完全看不出问题。
5.2 误触与“幽灵按压”
比按不动更烦人的是“自己乱触发”。常见的成因有四类:
- 光照突变:整体画面亮度剧变时,所有区域的纹理差异都会跳变。解决办法是把灵敏度降一档,并在业务层加冷却。
- 目标姿态突变:识别图被快速移动或大角度旋转,重新捕获目标的瞬间,画面匹配会出现一次瞬态误差。这类误触通常在刚识别上的一两帧内发生,可以在追踪稳定后再启用按钮检测(追踪刚获取的前 3 到 5 帧内忽略按钮事件)。
- 手指划过的余波:手指快速划过按钮,判定区被短暂遮挡,可能连续上报多次。用前面代码里的幂等保护加冷却能压住。
- 按钮区域串扰:两个按钮区域重叠或者间距过小,一根手指盖住两个。这是布局问题,只能改坐标,没有代码层面的解法。
判断到底是哪一类,最快的办法是在按钮回调里打上时间戳和按钮名,真机跑一轮,日志里密集出现的就是嫌疑对象。
5.3 Editor 与真机的行为差异
这一点必须提前说清楚,否则会浪费大量时间在“为什么编辑器里好好的”上。
Editor 里的摄像画面来源和真机不同。如果用的是实时摄像头,可以用手指去遮摄像头前的卡片,理论上能触发,但延迟、视野、光照和真机完全不是一回事;如果用的是图片序列或视频回放作为输入源,画面是预录死的,手指遮挡根本不会进入画面,按钮永远触发不了。
所以我的建议很直接:虚拟按钮的调参和验收,一律在真机上做。Editor 只用来验证代码逻辑是否跑通、事件是否注册成功、有没有空引用异常。真机调试建议直接打真包,用数据线连上跑日志,别指望 Editor 里的表现能代表用户手感。
如果真机测试条件实在紧张(比如手边只有一两台设备),可以把 Vuforia 的输入源切成录制好的图片序列,用“预录画面 + 固定遮挡帧”的方式做半自动回归——但这也只能验证“代码在特定输入下会怎么响应”,手感还是得靠人按。
5.4 目标丢失后的状态残留
这是第 2.3 节提过的问题,值得再展开一遍,因为它的症状特别隐蔽。
用户按住按钮 → 卡片被碰倒或移出画面 → 追踪丢失 → 用户松手 → 卡片放回去 → 追踪恢复。此时如果没有强制复位,你的业务逻辑仍然认为按钮处于按下状态,可能出现:动画卡在半截、音乐一直在播、计时器还在跑。更糟的是,某些情况下用户再按一次,你收到的第一个事件是 Released 而不是 Pressed(因为状态反了),逻辑彻底乱套。
解法就是前面代码里的ForceReleaseAll(),挂在追踪丢失的时机上。写的时候注意一点:追踪丢失的回调可能在一秒内触发多次,ForceReleaseAll()要有幂等性,已经是抬起状态的按钮不要重复派发。
顺手再提一个相关的坑:如果识别目标有多个,且共用一套按钮命名,那追踪切换时两个目标的按钮状态可能互相污染。我的做法是把按钮路由脚本挂在每个识别目标自己的层级下,各管各的,不做全局单例。多花几行代码,省掉一堆跨目标的状态 bug。
6. 虚拟按钮的边界与后续扩展
用到现在,我对虚拟按钮的定位已经很清楚了:它是AR交互里“性价比很高但天花板也很明显”的一环。性价比高,是因为几行代码加几个坐标就能让纸质物料变成输入设备;天花板明显,是因为它的输入带宽极低——只能表达“按了”和“没按”,且每次按压都会消耗追踪稳定性。
真要把交互做扎实,通常会往两个方向扩展。一个是加一层空间计算,用按钮触发一个位于识别图平面上的准星或指示器,再用射线去命中模型的具体部位,这样“按一下”就能升级成“选一处”。另一个是把按钮做成隐形的热区,视觉上不画圆圈,而是让卡片上的图案本身承担按钮功能,用户按的是“那台机器的红色开关图案”,交互隐喻更自然,但这要求图案设计阶段就参与进来,不是纯程序能决定的。
我在最近一轮打磨里做了一件小事,效果意外地好:给每个按钮加了一个极短的“准备态”——手指刚进入按钮上方但还没形成有效遮挡时,视觉标记会微微发亮。用户能提前知道“我快按到了”,命中率的主观感受提升了一大截,虽然实现的只是差异值的一个中间阈值判断。这类细节在文档里永远找不到,只能靠自己在真机上一遍遍地按、一遍遍地改,按到手指发酸为止。