在浏览器实现多点触控

解读

面试官问“在浏览器实现多点触控”,并不是让你背一段 MDN 文档,而是想确认三件事:

  1. 你是否知道 Unity WebGL 导出后,浏览器里真正的触控事件来源只有 HTML5 的 TouchEvent,而不是 Input.GetTouch;
  2. 你是否能把 TouchEvent 的 identifier、clientX/clientY、radiusX/radiusY 等原始数据,无损地传给 Unity 的 C# 层,并在 C# 侧还原出与 Input.GetTouch 一致的“手指生命周期”;
  3. 你是否能在 iOS WKWebView、Android Chrome、微信内置浏览器、钉钉内核 等国产主流容器里,把 300 ms 延迟、被动监听器、preventDefault 限制、RootVisual 缩放偏移一次性解决,保证 5 指以上不掉点、不漂移、不报错
    答不到这三层,基本会被认为“只是跑过 Demo”。

知识点

  1. WebGL 的输入链路
    Unity WebGL 的 Input.GetTouch 在 2021.3 之后才真正走 Emscripten 的 HTML5 模块;老版本(≤2020 LTS)只能拿到鼠标模拟,必须手写 JS 插件注入。
  2. JS 插件注入姿势
    在 Assets/Plugins/WebGL/MyTouch.jslib 里用 mergeInto 注入:
    MyTouch_SendDown: function (id, x, y, pr) { … }
    
    然后在 C# 侧用 [DllImport("__Internal")] 引入,必须在 #if UNITY_WEBGL && !UNITY_EDITOR 宏内调用,否则打包会炸。
  3. TouchEvent 与 PointerEvent 的取舍
    国产超级 App 的内核(微信 8.0+、钉钉 6.5+)对 pointerrawupdate 支持残缺,必须同时监听 touchstart/move/end/cancel 四个事件,并用 passive:false 主动调用 preventDefault() 来禁止页面滚动;iOS 16 开始必须在 iframe 标签上加 allow="touch-action=none" 才能 preventDefault 成功。
  4. 坐标系对齐
    浏览器返回的是 CSS 像素,而 Unity WebGL 的 canvas 大小受 devicePixelRatio 与 export 设置里的 “WebGL Canvas Width/Height” 共同影响;需要把 clientX * window.devicePixelRatio 后再减去 canvas.getBoundingClientRect() 的左偏移,最后除以 canvas.width 做归一化,才能与 Camera.ScreenToWorldPoint 对齐。
  5. 热更新兼容
    如果项目用 HybridCLR 或 lua 热更,JS 插件必须走反射调用,不能把 SendMessage 写死到某个热更脚本类名,否则热更后类名被裁剪会直接导致“触控失灵”线上事故。
  6. 性能陷阱
    在低端 Android(骁龙 4 系)上,touchmove 事件 60 fps 持续触发会把单核占满,需要自己做 30 fps 降频或 Unity-WebGL 侧开启 –O3 的 Emscripten 优化;否则帧时间会从 16 ms 飙到 30 ms 以上,被业务方直接投诉“Unity 方案卡顿”

答案

  1. 创建 JS 插件
    Assets/Plugins/WebGL/MultiTouch.jslib

    var 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);
    
  2. 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;
        }
    }
    
  3. 业务脚本消费
    在需要触控的 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拖拽……
            }
        }
    }
    
  4. 打包验证

    • PC Chrome 开 DevTools → Rendering → Emulate touch screen,必须看到 5 指同时打印日志
    • 微信开发者工具 → 真机调试 → 打开“显示性能面板”,确认帧率不掉到 30 fps 以下
    • 钉钉微应用 → 用钉钉 6.5 容器打开,4 指快速滑动页面不整页滚动即算通过。

拓展思考

  1. 如果项目还要支持 Windows 触控屏 + Edge 的 PointerEvent,可以把上述方案再包一层“统一输入层”:
    JS 侧同时监听 pointerdown/move/up/cancel,用 pointerId 代替 identifier,C# 层用同一套字典管理;这样 WebGL 构建既能跑移动端浏览器,也能跑 Surface Pro。
  2. 当业务需要 “双指缩放” 手势时,不要自己在 JS 算距离,而是把两个手指的 screenPos 原样抛给 Unity,在 C# 侧用 Vector2.Distance 计算 pinch 值,这样热更时可以随时改手势阈值,避免 JS 插件重新打包 WebGL 的 20 分钟等待
  3. 若未来升级到 Unity 2023 LTS 的 WebGPU 后端,官方已实验性支持 Input System 的 Enhanced Touch,可逐步把 JS 插件降级为兜底方案,主路径走官方 API,减少维护成本。