简介:面向Unity开发者的触摸屏物体识别桌工程资源,解决互动桌面、AR/VR场景中触摸定位并交互3D物体的核心问题。包内包含完整Unity示例场景、C#识别脚本、Lean Touch插件集成代码及射线检测实现,涵盖材质、预制体、着色器等配置资源,适合有基础、希望快速实现触摸交互的开发者参考。压缩包共3640个文件,以Unity项目元数据为主,包含大量cs脚本、dll库、unity场景、prefab预制体、mat材质、shader着色器等,完整还原工程结构,包体大小24.54MB,可直接在Unity中打开研究。已有2466人学习下载,开发者在互动展项、教育多媒体、智能硬件触控等领域均可复用识别思路。资源内含Lean Touch监听器继承示例、屏幕坐标转世界坐标、Physics.Raycast射线检测、UI触摸优化、性能过滤等关键实现,可帮助理解触摸识别全流程并快速移植到自身项目。
1. 触摸屏物体识别桌:Unity C# 把“触点”和“身份”合并成一套算法
这套项目解决一个很具体的场景问题:当用户把积木块放到桌面上时,系统不仅要知道“指到哪个坐标”,还要能认出“放在这的是红色圆形标记的积木,还是蓝色方形标记的积木”。项目把红外触摸框产生的触点数据,和安装在桌内朝上拍摄的摄像头识别结果合并,让普通互动桌升级成能识别物体身份、位置、朝向的交互装置。做商业展厅互动、教育实体教具、新零售陈列互动的开发者,可以直接在它上面改交互逻辑。整套系统以 Unity 与 C# 为主语言,识别部分不依赖付费插件,用颜色阈值加质心提取就能跑通,很适合想快速搭原型的团队。下面我按拆过的实际工程,从硬件选型一路讲到二次开发。
2. 技术选型与工程结构:为什么用“触摸框 + 底部摄像头”双数据源
2.1 硬件方案:纯触摸框做不到的“身份识别”
触摸屏物体识别桌最忌讳一开始就往纯视觉方向走。只用摄像头做手势识别,环境光和桌面上其他图案会带来大量误判,做成商业展示时稳定性很差。只用红外触摸框,虽然能拿到触点坐标,但触摸框只报告“这里被按住了”,它不知道按住的是一根手指还是一个贴了标记的实体积木。这套项目采用双数据源结构:红外触摸框负责高刷新率的位置数据,底部摄像头配合物体底部标记负责“身份”信息。
常见配置是一个 40 点以上的红外触摸框、一个普通 USB 摄像头和一个底部带彩色标记的积木套件。触摸框输出触点 ID、坐标和按下/抬起状态,刷新率通常在 100Hz 以上;摄像头拍的是桌子内部的画面,看不到人手,只能看到透过桌面的透明区域成像的标记,因此不容易被遮挡。
2.2 技术栈:Unity C# 为主线,识别算法零第三方依赖
主工程跑在 Unity 里,脚本语言是 C#。触摸框通过串口或 USB 转串口输出 ASCII 协议,直接用 System.IO.Ports 读取;摄像头用 WebCamTexture 抓帧,识别逻辑基于 Texture2D.GetPixel 手工实现。识别为什么要自己写而不是上 OpenCV for Unity?因为商业展厅项目经常要打包到多台一体机,OpenCV 插件体积、授权和版本兼容都会拖慢开发节奏。自己写一个面向固定标记的简化识别,在标记颜色和形状可控的前提下,效果完全够用,而且后续想换算法随时能换。
// 关键依赖只有 Unity 原生 API 和 .NET 串口库 using System.IO.Ports; using UnityEngine; using System.Collections.Generic;这个项目里没有引入任何第三方 SDK,所有通信和识别代码都是 C# 脚本,所以下载后改改端口号和相机参数就能跑到自己的机器上。
2.3 工程目录结构:从硬件驱动到交互层的四层分离
拆开工程看,代码按职责分成四层,这个分层决定了你后面改功能时不用翻遍整个项目。
Assets/ ├── Scripts/ │ ├── Hardware/ // 硬件驱动层 │ │ ├── SerialTouchReader.cs │ │ └── CameraFrameProvider.cs │ ├── Recognition/ // 识别算法层 │ │ ├── ColorMarkerDetector.cs │ │ └── MarkerData.cs │ ├── Mapping/ // 坐标映射层 │ │ └── CoordinateMapper.cs │ └── Interaction/ // 交互逻辑层 │ ├── ObjectStateMachine.cs │ └── GestureController.cs ├── Prefabs/ │ ├── TouchCursor.prefab │ └── InteractiveObject.prefab └── Scenes/ └── MainTable.unityHardware 层只负责数据采集,不参与任何业务判断;Recognition 层只输出标记列表;Mapping 层把触摸坐标、相机坐标统一换算到 Unity 世界坐标;Interaction 层消费这些数据做拖拽、旋转、缩放。改任何一层都不会牵连其他层,这是做这套项目时最值得保留的设计决策。
3. 核心模块拆解:触摸读取、物体识别到坐标映射的完整链路
3.1 触摸数据读取:后台线程读串口,主线程消费
触摸框串口协议一般是一行一帧的 ASCII 文本,常见格式类似T|3|1000|2000|1,含义分别是帧头、触点 ID、X 坐标、Y 坐标、触点状态。第一版我直接把 SerialPort 的 DataReceived 事件里拿到的数据在回调内解析,结果发现触点数据时有时无——回调线程和 Unity 主线程跨线程操作对象,不定期翻车。
正确做法是独立线程读数据,先塞进队列,主线程周期消费。这套方案对 100Hz 以上的触摸框完全够用。
public class SerialTouchReader : MonoBehaviour { public string portName = "COM3"; public int baudRate = 115200; private SerialPort serialPort; private Queue<string> receiveQueue = new Queue<string>(); private Thread readThread; private bool isRunning; void Start() { serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); serialPort.ReadTimeout = 50; serialPort.Open(); isRunning = true; readThread = new Thread(ReadLoop); readThread.Start(); } void ReadLoop() { while (isRunning) { try { string line = serialPort.ReadLine(); if (!string.IsNullOrEmpty(line)) { lock (receiveQueue) { receiveQueue.Enqueue(line); } } } catch (TimeoutException) { // 没有数据到达是正常情况,继续等待下一帧 } } } void Update() { lock (receiveQueue) { while (receiveQueue.Count > 0) { string line = receiveQueue.Dequeue(); ParseTouchLine(line); } } } }串口参数里,ReadTimeout设成 50 毫秒是为了让读线程在无数据时能快速退出,否则阻塞会导致关机或重新连接困难。baudRate必须和触摸框说明书一致,常见型号多为 115200 或 9600,这里设错了最常见的就是读出乱码。读线程产生的数据通过lock保护入队,Update 里在 Unity 主线程出队,这样所有触摸事件都在主线程触发,不会出现跨线程调 Unity API 的异常。
解析函数要做得尽量防御性,一帧数据里字段数量不对直接跳过,不要尝试脑补修复,触摸数据是高频实时流,丢一帧远比比硬解错帧安全。
private void ParseTouchLine(string line) { // 协议示例: T|3|1000|2000|1 if (!line.StartsWith("T")) return; string[] parts = line.Split('|'); if (parts.Length < 5) return; int touchId = int.Parse(parts[1]); float rawX = float.Parse(parts[2]); float rawY = float.Parse(parts[3]); int state = int.Parse(parts[4]); // 1 按下,0 抬起 DispatchTouchEvent(touchId, rawX, rawY, state); }这套解析只做了最基本的协议解析,真正的坐标换算在 Mapping 层完成。如果后续要接 TUIO 协议的触摸框,只需要把ParseTouchLine内部替换成 TUIO 解析,上层事件接口完全不用动。
3.2 标记识别:HSV 空间过滤加质心提取
摄像头识别物体底部标记,第一版用 RGB 判断颜色,在展厅灯光调整后识别率骤降。后来把颜色判断改成 HSV 空间,识别稳定性明显提升。HSV 把色相、饱和度、明度分开,纯红在 RGB 里可能是(200,30,30)也可能是(255,80,80),但在 HSV 里色相约等于同一个区间,这对光照变化有天生抗性。
识别流程分三步:取帧、按颜色阈值过滤、求连通域质心。
public class MarkerData { public int markerId; // 标记编号,对应积木 ID public Vector2 centerPx; // 标记中心在相机画面中的像素坐标 public float areaPx; // 命中像素面积,用来过滤噪点 } public class ColorMarkerDetector { // 目标标记使用纯红色,HSV 范围允许一定容差 private bool IsMatch(Color rgb) { Color.RGBToHSV(rgb, out float h, out float s, out float v); // 红色在 HSV 色环的 0 度附近,需要同时判断 0.0 和 1.0 两端 bool hueMatch = (h < 0.04f || h > 0.96f); bool saturationMatch = s > 0.35f; bool valueMatch = v > 0.15f; return hueMatch && saturationMatch && valueMatch; } }h的取值范围是 0 到 1,对应色环 0 到 360 度,红色恰好落在 0 度附近,所以必须做环状判断。saturationMatch是过滤“白色像素被误判为浅红”的关键,桌面材质如果偏暖白,饱和度阈值落到 0.35 以上基本可以排除。valueMatch用于排除黑暗死角的像素,这个值太低时整块黑斑都会被捞进来。
拿到二值化结果之后,对命中像素做一次聚类求质心。简化版实现遍历所有像素,按相邻关系凑成连通块,过滤面积过小的噪声块,剩下的连通块质心就是标记中心。
public List<MarkerData> Detect(Texture2D frame) { List<MarkerData> results = new List<MarkerData>(); bool[] visited = new bool[frame.width * frame.height]; for (int y = 0; y < frame.height; y++) { for (int x = 0; x < frame.width; x++) { int index = y * frame.width + x; if (visited[index]) continue; Color c = frame.GetPixel(x, y); if (!IsMatch(c)) continue; // BFS 收集相邻命中像素,计算质心 int count = 0; float sumX = 0, sumY = 0; Queue<Vector2Int> queue = new Queue<Vector2Int>(); queue.Enqueue(new Vector2Int(x, y)); visited[index] = true; while (queue.Count > 0) { Vector2Int p = queue.Dequeue(); count++; sumX += p.x; sumY += p.y; // 检查四邻域,命中且未访问的像素入队 TryEnqueue(p.x + 1, p.y); TryEnqueue(p.x - 1, p.y); TryEnqueue(p.x, p.y + 1); TryEnqueue(p.x, p.y - 1); } if (count > 50) // 面积阈值,过滤椒盐噪声 { results.Add(new MarkerData { markerId = 0, centerPx = new Vector2(sumX / count, sumY / count), areaPx = count }); } } } return results; }count > 50这个面积阈值需要按实际相机分辨率和标记大小调整,一般原则是让它大到你抬手路过桌面时不会触发,小到积木远端边缘仍然能被识别。这套 BFS 实现效率不算高,但用在 WebCamTexture 降到 640x480 时单帧处理在可接受范围内。颜色识别只能区分颜色,如果有多套积木,每个积木底部贴不同色相的标记,markerId直接由色相区间决定。
3.3 坐标映射:触摸框坐标系到 Unity 世界坐标
触摸框拿到的坐标一般是所在硬件原始分辨率下的逻辑值,常见是 0 到 32767,且坐标原点在左上角,y 轴向下;Unity 的屏幕坐标原点在左下角,y 轴向上。直接把触摸框坐标丢给 UI 或世界坐标,第一反应就是触点整体上下颠倒。映射函数要做两件事:归一化到屏幕范围,然后翻转 y 轴。
public Vector2 MapTouchToScreen(float rawX, float rawY) { // 触摸框逻辑分辨率通常为 32767x32767 float normalizedX = rawX / 32767f; float normalizedY = rawY / 32767f; float screenX = normalizedX * Screen.width; float screenY = normalizedY * Screen.height; // 触摸框原点在左上角,Unity 屏幕原点在左下角,翻转 Y screenY = Screen.height - screenY; return new Vector2(screenX, screenY); }如果触摸框支持通过配置命令改成其他原点模式,关闭反转反而会导致触摸位置跟光标错位,排查时必须先确认触摸框当前工作在哪种原点模式,再决定是否保留Screen.height - screenY这一步。
有了屏幕坐标,投影到正交相机的世界坐标就顺理成章。正交相机配合ScreenToWorldPoint得到世界坐标时,z 值要用相机到桌面的距离,常见做法是固定传入10f,保证结果落在桌面所在平面上。
Vector3 GetWorldPos(Vector2 screenPos) { Vector3 worldPos = Camera.main.ScreenToWorldPoint( new Vector3(screenPos.x, screenPos.y, 10f) ); return new Vector3(worldPos.x, worldPos.y, 0f); }z传不传对正交相机而言,出来的坐标差异很大,很多人在这里踩坑后以为是相机设置问题,实际上是ScreenToWorldPoint的 z 参数代表相机前方的深度,必须和桌面平面所在深度对齐。
3.4 双数据源融合:判断物体拿起、放下还是移动
有了触摸点坐标和识别标记坐标,接下来做融合判定。一个物体放在桌上的完整生命周期动作是:识别到标记 → 触摸框报告大触点 → 判定物体放置;触点消失 → 判定拿起。触摸框会同时报告手指触点和物体压住桌面的触点,光看坐标重叠不够,还要看触点面积,物体压在桌面上形成的触点直径通常比手指大几倍。
private void UpdateObjectState() { // 遍历当前所有识别到的标记 foreach (MarkerData marker in currentMarkers) { Vector2 markerScreenPos = coordinateMapper.CameraToScreen(marker.centerPx); TouchPoint touch = FindNearestTouch(markerScreenPos, maxDist: 60f); if (touch != null && touch.area > AREA_OBJECT_THRESHOLD) { // 触摸面积够大且接近标记,判定为放置状态 SetState(marker.markerId, ObjState.Placed); } else if (touch == null) { // 标记还在但触摸消失,说明物体被拿起 SetState(marker.markerId, ObjState.Held); } } }AREA_OBJECT_THRESHOLD的取值需要实测,不同触摸框对手指和钝物面积的定义不同,常见做法是在调试面板里画出一个可变阈值圆,实时观察物体放置时触点的面积范围,再写死一个经验值。这个阈值是整个系统好坏的分水岭,阈值太小手指放上去就被当成物体,太大积木放上去无响应。
融合判定是异步的,摄像头帧率通常只有 30fps,触摸框却是 100Hz,所以标记识别结果要存一个短时间缓存。只要缓存期内触摸信号一直存在就维持放置状态,避免摄像头瞬间丢帧导致物体状态抖动。
4. 场景集成与交互:识别到物体之后的拖拽、旋转、缩放
4.1 物体交互状态机:从放置到拖拽的迁移
识别到物体之后,交互逻辑就不能再散落在各处了,我给物体规划了一个状态机。状态包含Inactive未识别、Placed放置、Dragging拖拽、Rotating旋转。触摸数据驱动状态迁移,识别数据负责初始进入Placed以及复用时的身份确认。
public enum ObjState { Inactive, // 未识别到,或物体被拿起 Placed, // 物体静止放置在桌面上 Dragging, // 单指拖动中 Rotating // 双指旋转/缩放中 }单指拖动时,物体的位置直接跟随触点移动,每帧采样触点世界坐标,将物体 transform.position 设置到对应位置。拖拽过程中要持续检查触点是否还存在,触点消失立即回到Placed状态,而不是继续悬空。
private void OnDrag(Vector2 screenPos) { Vector3 worldPos = GetWorldPos(screenPos); currentObject.transform.position = new Vector3(worldPos.x, worldPos.y, 0f); }这一段简洁到似乎没什么好讲,但真正的细节在状态机入口处:只有Placed状态的物体才允许被单指拖拽,Rotating状态下任何拖拽指令都忽略,否则会出现物体边旋转边被拖走,操作手感非常“飘”。
4.2 双指旋转与缩放实现
多指手势是触摸屏互动桌的标配需求。双指手势的数学基础是两个触点向量在前后帧之间的角度差和长度比,角度差驱动旋转,长度比驱动缩放。
private void HandleTwoFingerGesture(Vector2 p1Prev, Vector2 p2Prev, Vector2 p1Cur, Vector2 p2Cur) { Vector2 prevVec = p1Prev - p2Prev; Vector2 currVec = p1Cur - p2Cur; // 当前帧向量相对上一帧向量的旋转角 float angleDelta = Vector2.SignedAngle(prevVec, currVec); // 两指间距的比例,大于1是放大,小于1是缩小 float distRatio = currVec.magnitude / Mathf.Max(prevVec.magnitude, 0.001f); currentObject.transform.Rotate(0f, 0f, angleDelta); currentObject.transform.localScale *= distRatio; }Vector2.SignedAngle返回带方向的夹角,顺时针为负,逆时针为正,正好对应 Unity 的Rotate约定。distRatio的用途要特别说明:它是比例而不是差值,所以缩放用乘法,且每次要在上一帧 scale 的基础上乘,不能每次从原始 scale 出发,否则物体缩放速度会越来越离谱。限制缩放范围的经验值是 0.3 到 3.0 倍之间,超出范围钳制,否则物体缩没了或大到冲出桌面边界。
物体旋转中心默认是 transform 的 pivot 点,即物体的几何中心。如果积木标记贴在物体底部边缘,旋转中心会偏离标记点,需要用transform.localScale和偏移量补偿一下,具体做法是把标记位置存下来,旋转后重新计算偏移。
4.3 调试面板:把触点和识别框实时画出来
开发过程中最容易困惑的问题是“触摸到了,但识别框歪了”。我在场景里写了一个调试面板,用OnDrawGizmos把所有中间数据可视化:触摸点画成交叉线、识别标记画成绿色圆圈、融合判定匹配的配对画成连线。这一步在现场调试时价值很大,你能直接看到触摸框的原始坐标映射到屏幕后在哪,摄像头识别出的标记坐标配准后在哪,两者之间的偏移立刻暴露出来。
private void OnDrawGizmos() { Gizmos.color = Color.red; foreach (TouchPoint t in lastTouchPoints) { // 十字线表示触摸框原始触点,经过映射后的屏幕坐标 Vector3 pos = new Vector3(t.screenPos.x, t.screenPos.y, 0f); Gizmos.DrawLine(pos + Vector3.left * 20f, pos + Vector3.right * 20f); Gizmos.DrawLine(pos + Vector3.up * 20f, pos + Vector3.down * 20f); } Gizmos.color = Color.green; foreach (MarkerData m in lastMarkers) { // 绿色圆圈表示摄像头识别到的标记位置 Vector3 pos = new Vector3(m.screenPos.x, m.screenPos.y, 0f); Gizmos.DrawWireSphere(pos, 15f); } }这个调试面板在正式交付时不会删除,而是做成一个开关。现场部署时硬件安装位置不可能完全居中,总需要微调映射参数,调试画面对准坐标偏移非常友好。调试面板输出的坐标还可以通过日志记录回放,方便用户在客户现场出问题时远程分析。
5. 踩坑记录:坐标翻转、乱码、光照漂移的排查清单
5.1 触摸点整体上下颠倒
现象:手触摸桌面底部,光标却跑到屏幕上方的对称位置,整个交互反的。
原因:触摸框原生坐标系原点在左上角,y 轴向下;Unity 屏幕坐标系原点在左下角,y 轴向上。直接按比例映射没有翻转 y 轴就会整体颠倒。
解决:在映射函数中增加screenY = Screen.height - screenY这一步。注意如果触摸框本身已经配置成左下角原点,这一步会再次翻转,所以要先在调试面板里确认方向再启用翻转。
5.2 串口数据乱码、触点乱跳
现象:触摸点位置偶发跳到角落,或触摸框时断时连。
原因:串口波特率设置与触摸框实际不符,或者触摸框 USB 供电不足,线材过长导致信号不稳定。
解决:先核对说明书确认波特率是 9600 还是 115200;其次用独立 USB 口供电,不要和摄像头共用同一个 Hub。触摸框数据如果出现半个中文字符之类的乱码,大概率是波特率错误,数据位也检查一下是否 8 位。
5.3 强光照射下标记识别率骤降
现象:展厅打开射灯后,积木识别变得时灵时不灵,一会识别成其他颜色,一会彻底丢失。
原因:原识别用 RGB 空间判断颜色,光照改变后同一颜色在不同亮度下的 RGB 值差异巨大,固定阈值直接失效。
解决:改用 HSV 空间判断,色相值在不同光照下相对稳定;同时提高饱和度阈值,排除白光干扰。如果标记旁边正好有反光材质,考虑在识别前对画面做一次以当前平均亮度为基准的自动补偿。
5.4 识别标记位置与触摸点位置偏移
现象:物体放在桌面上,Unity 里绿色识别圆圈和红色触摸十字线不重合,相差一段距离。
原因:摄像头成像中心和触摸框感测区域中心不是同一个几何中心,加上广角镜头透视,边缘区域偏移尤其明显。
解决:将摄像头固定在桌子中心正下方,最大化减少透视偏差;在 Mapping 层做线性偏移补偿。要求更高时做四点标定,把摄像头画面坐标映射到触摸框坐标,一次性消除平移、旋转和缩放误差。
5.5 启动时触摸框未就绪直接崩溃
现象:电脑重启后程序启动,串口打开失败,界面一片红,系统无法自恢复。
原因:Unity 脚本在Start里直接调用serialPort.Open(),没有处理端口不存在或串口被其他程序占用的情况。
解决:把 Open 放入 try-catch,端口打开失败时进入重试状态,每 2 秒尝试一次;同时监听串口热插拔事件,触摸框恢复后自动重连,而不是要求用户重启程序。
6. 二次开发与改造思路:从识别彩色积木到任意物体的扩展路径
6.1 拓展一:把颜色标记替换成 AprilTag
当前颜色标记方案只能区分几种颜色,要支持几十种物体时改造成本很高。直接替换 ColorMarkerDetector 对图像处理逻辑的底层实现,把 BFS 聚类换成 AprilTag 检测库,MarkerData 的 markerId 改由二维码内容解码得到。上层交互代码完全不需要动,因为你保留了同一个 markerId 到物体资源的映射表。这样一套积木可以做到每块独立 ID,识别率也几乎不受光照影响。
6.2 拓展二:接入 TUIO 协议标准触摸框
市面上部分高端触摸框不出串口协议,只出 TUIO 网络协议,端口一般是 3333。改造点集中在 SerialTouchReader 内部,把 UDP 接收器替代串口读取,TUIO 消息解析成统一的 TouchPoint 结构。注意 TUIO 的坐标是归一化的 0 到 1 浮点值,映射层要同步调整缩放逻辑。这个改造能让项目适配更多品牌硬件,部署自由度大很多。
6.3 拓展三:事件总线解耦输入与业务
现在触摸事件和识别事件是直接调用,改成事件总线后,新增“双击”或“长按”手势时不需要改动 Core 层。做法是定义TouchEventBus、MarkerEventBus两个静态类,每个数据源只在总线发布,交互层订阅各自关心的事件。事件总线是这个小项目里投入产出比最高的改造,后续加新交互几乎都是即插即用。
之前我做过一版把触摸解析、识别、坐标映射全写进一个 MonoBehaviour 的项目,到后面每加一个功能都在担心碰坏旧逻辑,调试成本越来越失控。从那以后我无论项目多小都强制把输入、识别、映射、交互拆成独立脚本,数据通路画一遍再动手。这套工程源码里的分层和脚本组织就是按这个思路组织的,希望你拿到手改起来也能少走我那些弯路,祝顺利。
本文还有配套的精品资源,点击获取