在浏览器实现多点触控
解读
面试官问“在浏览器实现多点触控”,并不是让你背一段 MDN 文档,而是想确认三件事:
- 你是否知道 Unity WebGL 导出后,浏览器里真正的触控事件来源只有 HTML5 的 TouchEvent,而不是 Input.GetTouch;
- 你是否能把 TouchEvent 的 identifier、clientX/clientY、radiusX/radiusY 等原始数据,无损地传给 Unity 的 C# 层,并在 C# 侧还原出与 Input.GetTouch 一致的“手指生命周期”;
- 你是否能在 iOS WKWebView、Android Chrome、微信内置浏览器、钉钉内核 等国产主流容器里,把 300 ms 延迟、被动监听器、preventDefault 限制、RootVisual 缩放偏移一次性解决,保证 5 指以上不掉点、不漂移、不报错。
答不到这三层,基本会被认为“只是跑过 Demo”。
知识点
- WebGL 的输入链路:
Unity WebGL 的 Input.GetTouch 在 2021.3 之后才真正走 Emscripten 的 HTML5 模块;老版本(≤2020 LTS)只能拿到鼠标模拟,必须手写 JS 插件注入。 - JS 插件注入姿势:
在 Assets/Plugins/WebGL/MyTouch.jslib 里用 mergeInto 注入:
然后在 C# 侧用 [DllImport("__Internal")] 引入,必须在 #if UNITY_WEBGL && !UNITY_EDITOR 宏内调用,否则打包会炸。MyTouch_SendDown: function (id, x, y, pr) { … } - TouchEvent 与 PointerEvent 的取舍:
国产超级 App 的内核(微信 8.0+、钉钉 6.5+)对 pointerrawupdate 支持残缺,必须同时监听 touchstart/move/end/cancel 四个事件,并用 passive:false 主动调用 preventDefault() 来禁止页面滚动;iOS 16 开始必须在 iframe 标签上加 allow="touch-action=none" 才能 preventDefault 成功。 - 坐标系对齐:
浏览器返回的是 CSS 像素,而 Unity WebGL 的 canvas 大小受 devicePixelRatio 与 export 设置里的 “WebGL Canvas Width/Height” 共同影响;需要把 clientX * window.devicePixelRatio 后再减去 canvas.getBoundingClientRect() 的左偏移,最后除以 canvas.width 做归一化,才能与 Camera.ScreenToWorldPoint 对齐。 - 热更新兼容:
如果项目用 HybridCLR 或 lua 热更,JS 插件必须走反射调用,不能把 SendMessage 写死到某个热更脚本类名,否则热更后类名被裁剪会直接导致“触控失灵”线上事故。 - 性能陷阱:
在低端 Android(骁龙 4 系)上,touchmove 事件 60 fps 持续触发会把单核占满,需要自己做 30 fps 降频或 Unity-WebGL 侧开启 –O3 的 Emscripten 优化;否则帧时间会从 16 ms 飙到 30 ms 以上,被业务方直接投诉“Unity 方案卡顿”。
答案
-
创建 JS 插件
Assets/Plugins/WebGL/MultiTouch.jslibvar MultiTouchLib = { $touchBuffer: [], $touchBufferLock: false, InitMultiTouch: function () { var canvas = document.getElementById('#canvas'); var sendDown = Module.cwrap('TouchDown', null, ['number','number','number','number']); var sendUp = Module.cwrap('TouchUp', null, ['number','number','number','number']); var sendMove = Module.cwrap('TouchMove', null, ['number','number','number','number']); var hook = function(e){ e.preventDefault(); var rect = canvas.getBoundingClientRect(); var dpr = window.devicePixelRatio || 1; for(var i=0;i<e.changedTouches.length;i++){ var t = e.changedTouches[i]; var x = (t.clientX - rect.left) * dpr; var y = (t.clientY - rect.top) * dpr; var id= t.identifier; if(e.type=='touchstart') sendDown(id,x,y,1); if(e.type=='touchmove') sendMove(id,x,y,1); if(e.type=='touchend'||e.type=='touchcancel') sendUp(id,x,y,0); } }; canvas.addEventListener('touchstart', hook, {passive:false}); canvas.addEventListener('touchmove', hook, {passive:false}); canvas.addEventListener('touchend', hook, {passive:false}); canvas.addEventListener('touchcancel', hook, {passive:false}); } }; mergeInto(LibraryManager.library, MultiTouchLib); -
C# 层桥接
Assets/Scripts/WebGLTouchBridge.cs#if UNITY_WEBGL && !UNITY_EDITOR using System.Runtime.InteropServices; #endif using UnityEngine; public class WebGLTouchBridge : MonoBehaviour { public static WebGLTouchBridge Instance{get;private set;} private void Awake(){Instance=this;} #if UNITY_WEBGL && !UNITY_EDITOR [DllImport("__Internal")] private static extern void InitMultiTouch(); [MonoPInvokeCallback(typeof(System.Action<int,float,float,float>))] public static void TouchDown(int id,float x,float y,float p){ Instance.RecordTouch(id, new Vector2(x,y), TouchPhase.Began); } [MonoPInvokeCallback(typeof(System.Action<int,float,float,float>))] public static void TouchMove(int id,float x,float y,float p){ Instance.RecordTouch(id, new Vector2(x,y), TouchPhase.Moved); } [MonoPInvokeCallback(typeof(System.Action<int,float,float,float>))] public static void TouchUp(int id,float x,float y,float p){ Instance.RecordTouch(id, new Vector2(x,y), TouchPhase.Ended); } void Start(){ InitMultiTouch(); } #endif private struct TouchData{ public Vector2 pos; public TouchPhase phase; } private Dictionary<int,TouchData> dic = new Dictionary<int,TouchData>(); private void RecordTouch(int id,Vector2 pos,TouchPhase phase){ dic[id]=new TouchData{pos=pos,phase=phase}; if(phase==TouchPhase.Ended) dic.Remove(id); } public bool GetWebTouch(int id, out Vector2 pos, out TouchPhase phase){ if(dic.TryGetValue(id, out var t)){ pos=t.pos; phase=t.phase; return true;} pos=Vector2.zero; phase=TouchPhase.Canceled; return false; } } -
业务脚本消费
在需要触控的 MonoBehaviour 里:void Update(){ for(int i=0;i<5;i++){ if(WebGLTouchBridge.Instance.GetWebTouch(i, out var p, out var ph)){ Debug.Log($"手指{i} 相位{ph} 坐标{p}"); // TODO: 射线检测、手势识别、UI拖拽…… } } } -
打包验证
- PC Chrome 开 DevTools → Rendering → Emulate touch screen,必须看到 5 指同时打印日志;
- 微信开发者工具 → 真机调试 → 打开“显示性能面板”,确认帧率不掉到 30 fps 以下;
- 钉钉微应用 → 用钉钉 6.5 容器打开,4 指快速滑动页面不整页滚动即算通过。
拓展思考
- 如果项目还要支持 Windows 触控屏 + Edge 的 PointerEvent,可以把上述方案再包一层“统一输入层”:
JS 侧同时监听 pointerdown/move/up/cancel,用 pointerId 代替 identifier,C# 层用同一套字典管理;这样 WebGL 构建既能跑移动端浏览器,也能跑 Surface Pro。 - 当业务需要 “双指缩放” 手势时,不要自己在 JS 算距离,而是把两个手指的 screenPos 原样抛给 Unity,在 C# 侧用 Vector2.Distance 计算 pinch 值,这样热更时可以随时改手势阈值,避免 JS 插件重新打包 WebGL 的 20 分钟等待。
- 若未来升级到 Unity 2023 LTS 的 WebGPU 后端,官方已实验性支持 Input System 的 Enhanced Touch,可逐步把 JS 插件降级为兜底方案,主路径走官方 API,减少维护成本。