Unity WebGL中文输入实战:JS桥接与全屏适配方案

发布时间:2026/11/4 12:20:42
Unity WebGL中文输入实战:JS桥接与全屏适配方案

简介:这份资源面向需要在 Unity WebGL 项目中实现中文文本输入的开发者,重点解决浏览器环境下输入法无法唤起、输入框不跟随光标以及全屏模式下定位错乱等常见问题。压缩包共 19 个文件,约 17.4MB,包含 html 入口页、css 样式、js 交互脚本、unityweb 构建产物以及 png 图标素材等,结构上覆盖了 WebGL 构建输出与页面模板的完整组成。已有 940 人学习下载,说明该方案在同类需求中具备一定参考价值。资源通过 JavaScript 与 Unity C# 的相互调用激活浏览器输入法,并借助鼠标事件动态调整输入法窗口位置,使光标移动时输入框能够同步跟随,同时兼顾全屏切换后的定位更新。读者可据此理解 JSIL 通信机制、输入法兼容性处理与延迟加载思路,并参考其中的错误处理与样式定制方式,快速搭建可运行的中文输入示例工程。

1. 需求先立住:Unity WebGL 里那个让人抓狂的中文输入框

做过 Unity WebGL 项目的人大概都遇到过同一个场景:游戏在浏览器里跑起来了,英文输入一切正常,可一旦玩家切到中文输入法,输入框要么完全没反应,要么字符乱码,要么输入法候选框飘在屏幕左上角跟输入位置对不上。更难受的是全屏之后,输入法候选框直接跑到屏幕外面,玩家根本看不见自己在打什么。这不是玄学,是 WebGL 平台下 Unity 的输入事件和浏览器原生输入法之间的桥接问题。这份方案要解决的就是三件事:让 WebGL 构建里能正常输入中文、让输入法候选框跟随输入位置、让全屏状态下输入依然可用。适合正在做 WebGL 项目、需要交付一个能真正跑通中文输入 Demo 的开发者,也适合拿它当课程设计或毕设里「跨平台输入适配」模块的参考实现。

2. 技术选型与总体设计:为什么不能只靠 Input Field

2.1 原生 Input Field 在 WebGL 下的真实短板

Unity 自带的 Input Field 组件在编辑器里和桌面平台表现良好,但 WebGL 构建后,它的输入依赖 Unity 自己维护的隐藏输入框和浏览器事件的映射。问题出在中文输入法的组合输入阶段:输入法在选词之前会有一串「组合中」的字符,这部分内容 Unity 的默认输入通道拿不到完整状态,于是出现候选词丢失、上屏顺序错乱。全屏模式下更麻烦,浏览器全屏后 Unity 画布尺寸变化,隐藏输入框的位置没有同步更新,输入法候选框就失去了锚点。

我一般会先明确一个判断:这不是靠调 Input Field 的某个属性就能修好的问题,需要在浏览器层接管输入事件,再把结果回传给 Unity。也就是说,方案的核心是「JS 侧输入代理 + Unity 侧接收渲染」的双向通道,而不是在 C# 里硬扛。

2.2 三层结构:页面层、桥接层、表现层

整套方案按职责拆成三层,边界清晰,后面排查问题也容易定位。

层级运行位置核心职责关键产出
页面层浏览器 DOM承载真实 input 元素、监听 composition 事件、处理全屏切换组合输入文本、上屏文本、光标坐标
桥接层Unity WebGL 模板 JS把 DOM 事件转成 Unity 可调用的消息,把 Unity 的请求转成 DOM 操作SendMessage 调用、坐标换算
表现层Unity C#接收文本、驱动 UI 显示、请求聚焦与失焦、同步输入框位置输入框视觉、光标、候选框锚点

页面层用的是一个真实挂载在 DOM 上的 input 元素,而不是完全依赖 Unity 的隐藏输入框。这个 input 默认透明、尺寸跟随 Unity 输入框的屏幕位置,玩家看到的是 Unity 渲染的输入框,实际敲键盘的是这个 DOM 元素。桥接层负责把 compositionstart、compositionupdate、compositionend 三个事件里的文本通过 SendMessage 送到 C#,同时把 Unity 侧算好的输入框屏幕坐标回写给 DOM,让输入法候选框有正确的锚点。表现层只做两件事:显示文本、告诉桥接层「我现在在哪、要不要聚焦」。

2.3 全屏适配的关键:坐标换算与事件重绑

全屏不是简单调一个 requestFullscreen 就完事。进入全屏后,画布的 CSS 尺寸、设备像素比、浏览器视口都会变,输入框的屏幕坐标必须重新计算。方案里把坐标换算统一收口到一个函数,输入是 Unity 世界坐标或 UI 局部坐标,输出是相对视口的 CSS 像素坐标,换算时同时考虑 canvas 的 boundingRect 和 devicePixelRatio。全屏切换时监听 fullscreenchange 事件,触发一次坐标重算和输入框位置同步,避免候选框还停在旧位置。

提示:坐标换算不要在两处各写一份,页面层和 C# 侧各算各的必然对不上。统一由 C# 算出逻辑坐标,桥接层只做一次线性映射,出问题时只需要检查这一个函数。

3. 桥接层实现:JS 输入代理与 Unity 消息通道

3.1 DOM 输入元素的创建与组合事件监听

页面层的核心是一个动态创建的 input,挂在 body 下,样式上做成透明且不抢焦点视觉。下面这段是桥接层里最关键的监听逻辑,组合输入和上屏输入分开处理。

// 创建代理输入元素,透明覆盖在 Unity 输入框位置 function createProxyInput() { const el = document.createElement('input'); el.type = 'text'; el.style.position = 'absolute'; el.style.opacity = '0'; // 视觉交给 Unity 渲染 el.style.zIndex = '9999'; el.style.border = 'none'; el.style.outline = 'none'; el.style.background = 'transparent'; document.body.appendChild(el); // 组合输入阶段:输入法还没上屏,文本暂存 el.addEventListener('compositionstart', () => { isComposing = true; }); el.addEventListener('compositionupdate', (e) => { // 把组合中的文本实时回传,用于 Unity 侧预览 sendToUnity('OnCompositionUpdate', e.data); }); el.addEventListener('compositionend', (e) => { isComposing = false; // 上屏文本才是最终结果 sendToUnity('OnCompositionEnd', e.data); }); // 非组合输入(英文、数字)直接走 input 事件 el.addEventListener('input', (e) => { if (!isComposing) { sendToUnity('OnInputText', e.target.value); } }); return el; }

这段代码的设计意图是把「组合中」和「已上屏」两种状态分开。compositionupdate 里拿到的 e.data 是候选词还没确认时的临时文本,用来做输入预览;compositionend 里的 e.data 才是玩家最终选定的内容。如果只监听 input 事件,中文输入法在组合阶段的行为在不同浏览器里不一致,很容易出现重复字符或者丢字。isComposing 这个标志位就是用来挡住组合期间的 input 事件,避免同一段文本被送两次。

sendToUnity 的实现依赖 Unity 提供的全局对象,写法如下:

// 统一的消息出口,gameInstance 由 Unity 模板注入 function sendToUnity(methodName, payload) { if (typeof gameInstance === 'undefined' || !gameInstance) return; gameInstance.SendMessage('WebGLInputBridge', methodName, payload || ''); }

参数说明:methodName 对应 C# 侧挂载在 WebGLInputBridge 这个 GameObject 上的公开方法名,payload 是字符串。SendMessage 只支持单个字符串或数值参数,所以复杂结构要在两侧约定好序列化格式,这里文本场景直接用字符串即可。

3.2 聚焦、失焦与输入框位置同步

输入框位置同步是候选框跟随的前提。C# 侧在每帧或者输入框状态变化时,把输入框在屏幕上的矩形传给桥接层,桥接层设置 DOM input 的 left、top、width、height。下面是对外暴露的同步接口。

// 由 Unity 调用:更新代理输入框的位置与尺寸 function syncInputRect(x, y, w, h) { if (!proxyInput) return; proxyInput.style.left = x + 'px'; proxyInput.style.top = y + 'px'; proxyInput.style.width = w + 'px'; proxyInput.style.height = h + 'px'; } // 由 Unity 调用:请求聚焦,触发浏览器输入法 function focusInput() { if (!proxyInput) return; proxyInput.focus(); } // 由 Unity 调用:主动失焦,收起输入法 function blurInput() { if (!proxyInput) return; proxyInput.blur(); }

坐标 x、y 是相对浏览器视口左上角的 CSS 像素值,w、h 是输入框的显示尺寸。这里有个血泪经验:不要直接把 Unity 的 UI 局部坐标传过来,必须先经过 canvas 的 boundingRect 换算。因为 Unity 画布在页面里可能不是从 (0,0) 开始,还有可能被外层容器加了 padding 或缩放,直接传局部坐标会让候选框整体偏移一个固定距离,看起来就像「候选框总是偏左上」。

3.3 全屏切换时的重绑逻辑

全屏切换会改变视口尺寸,必须重新同步一次位置,并且要处理浏览器可能因为全屏而重建输入焦点的情况。

// 监听全屏变化,重算并同步输入框位置 document.addEventListener('fullscreenchange', () => { const isFull = !!document.fullscreenElement; // 通知 Unity 当前全屏状态,由 C# 侧决定是否重算布局 sendToUnity('OnFullscreenChanged', isFull ? '1' : '0'); // 延迟一帧再同步,等画布尺寸稳定 requestAnimationFrame(() => { if (typeof syncFromUnity === 'function') { syncFromUnity(); // 回调 Unity 侧重新下发矩形 } }); });

这里用 requestAnimationFrame 包一层是因为全屏切换后画布的 CSS 尺寸更新有延迟,立刻读 boundingRect 可能拿到旧值。延迟一帧再让 Unity 重新下发矩形,能避开大部分「全屏后候选框位置错乱」的问题。OnFullscreenChanged 回传后,C# 侧可以决定是否重新计算 UI 布局,比如全屏后输入框本身的位置也可能变化。

4. Unity 侧实现:文本接收、渲染与输入框状态管理

4.1 桥接脚本的挂载与消息接收

C# 侧需要一个固定名字的 GameObject 来接收 SendMessage,方案里叫 WebGLInputBridge,挂一个同名脚本。脚本里暴露三个公开方法,分别对应组合更新、上屏、全屏变化。

using UnityEngine; using UnityEngine.UI; public class WebGLInputBridge : MonoBehaviour { public InputField targetField; // 绑定的输入框 public RectTransform inputRect; // 用于计算屏幕位置 // 组合输入中的预览文本 public void OnCompositionUpdate(string text) { if (targetField != null) targetField.text = text; // 实时预览,不触发提交 } // 输入法上屏的最终文本 public void OnCompositionEnd(string text) { if (targetField != null) { targetField.text = text; targetField.caretPosition = text.Length; } } // 全屏状态变化 public void OnFullscreenChanged(string flag) { bool isFull = flag == "1"; // 全屏后布局可能变化,触发一次位置重算 SyncInputPosition(); } }

targetField 是场景里实际显示文本的 InputField,inputRect 用来取输入框的屏幕矩形。OnCompositionUpdate 里直接改 text 是为了让玩家在选词过程中看到预览,但要注意这时候不能触发 onValueChanged 的业务逻辑,否则每敲一个候选词都会触发一次提交。通常的做法是在 InputField 的 onValueChanged 里判断一个「是否正在组合」的标志,组合期间只更新显示不跑业务。

4.2 屏幕坐标计算与下发

位置同步的触发点在 C# 侧,因为只有 Unity 知道输入框在画布里的真实位置。下面这个方法把 UI 局部坐标换算成视口坐标,再调 JS。

using UnityEngine; using System.Runtime.InteropServices; public class WebGLInputBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void syncInputRect(float x, float y, float w, float h); [DllImport("__Internal")] private static extern void focusInput(); public Canvas targetCanvas; public RectTransform inputRect; public void SyncInputPosition() { if (inputRect == null || targetCanvas == null) return; // 取输入框四角在世界空间的坐标 Vector3[] corners = new Vector3[4]; inputRect.GetWorldCorners(corners); // 转成屏幕坐标(左下为原点) Vector2 screenMin = RectTransformUtility.WorldToScreenPoint( targetCanvas.worldCamera, corners[0]); Vector2 screenMax = RectTransformUtility.WorldToScreenPoint( targetCanvas.worldCamera, corners[2]); // 转成浏览器视口坐标(左上为原点,Y 轴翻转) float x = screenMin.x; float y = Screen.height - screenMax.y; float w = screenMax.x - screenMin.x; float h = screenMax.y - screenMin.y; syncInputRect(x, y, w, h); } }

这段的关键在 Y 轴翻转。Unity 的屏幕坐标原点在左下,浏览器视口原点在左上,所以 y 要用 Screen.height 减去上边界的屏幕 y。w 和 h 直接取对角差值。GetWorldCorners 返回的顺序是左下、左上、右上、右下,corners[0] 和 corners[2] 正好是对角,取这两个点就够了。如果输入框有旋转或者非矩形裁剪,这套换算会失真,所以方案里约定输入框保持轴对齐,复杂形状用外层容器包一层矩形来算。

4.3 输入框生命周期与焦点管理

输入框不是一直聚焦的,玩家点一下才进入输入状态,点外面就失焦。C# 侧用一个状态机管理三种状态:空闲、聚焦、组合中。点击输入框时调 focusInput 让 DOM 元素拿到焦点,同时把状态切到聚焦;收到 compositionstart 对应的信号切到组合中;compositionend 后回到聚焦;点击空白区域调 blurInput 回到空闲。

public enum InputState { Idle, Focused, Composing } private InputState state = InputState.Idle; public void OnInputFieldClicked() { if (state == InputState.Idle) { focusInput(); state = InputState.Focused; } } public void OnClickOutside() { if (state != InputState.Idle) { blurInput(); state = InputState.Idle; } }

状态机的好处是避免重复聚焦和重复失焦。浏览器对 focus 的调用有节流,短时间内连续调 focus 可能导致输入法状态异常,用状态挡一层能减少这类问题。另外,失焦时要主动把组合中的临时文本清掉或者提交,具体看业务,方案里默认失焦即提交当前上屏文本,避免玩家以为输入了其实丢了。

5. 避坑与排查:WebGL 中文输入最容易翻车的五个点

5.1 候选框位置偏移一个固定距离

现象:输入法候选框总是出现在输入框左上方或右下方一段固定距离,怎么调都对不齐。原因通常是坐标换算时混用了两套原点,比如 C# 传了 UI 局部坐标,JS 侧又按视口坐标处理,或者 canvas 有外层容器的偏移没算进去。解决方式是统一收口:C# 只输出视口坐标,JS 侧不再做任何二次偏移,排查时在 syncInputRect 里打印传入值和 canvas.getBoundingClientRect() 对比,一眼就能看出差在哪。

5.2 中文上屏后字符重复或丢字

现象:打「你好」上屏变成「你你好」或者只剩「好」。原因是组合期间的 input 事件和 compositionend 都被处理了,同一段文本走了两条路径。解决方式是在 input 监听里用 isComposing 挡住组合期间的输入,只让 compositionend 负责最终上屏。如果项目里还监听了 keydown 做特殊处理,也要在组合期间跳过,否则回车选词会被当成提交。

5.3 全屏后输入法完全失效

现象:非全屏能输入,一进全屏输入法就不弹或者弹了没反应。原因多半是全屏切换导致 DOM 焦点丢失,代理 input 被浏览器移出了焦点链。解决方式是在 fullscreenchange 之后延迟一帧重新调 focusInput,并且检查代理 input 是否还在 document.body 下。有些浏览器全屏时会重建渲染层,input 元素如果挂在被全屏的容器内部可能被隐藏,方案里统一挂在 body 下就是为了避开这个问题。

5.4 移动端浏览器组合事件不触发

现象:桌面浏览器正常,手机浏览器上中文输入没有预览、上屏也异常。原因是移动端输入法的 composition 事件行为和桌面不同,部分浏览器在软键盘下只发 input 不发 composition。解决方式是在桥接层做能力检测,如果一段时间内没收到 composition 事件,退化为直接监听 input 并做去重。这个降级逻辑要单独测,不能假设桌面行为能直接搬到移动端。

5.5 SendMessage 目标对象找不到

现象:控制台报错说找不到接收方法的 GameObject,输入完全没反应。原因是桥接脚本挂载的 GameObject 名字和 JS 里写的不一致,或者场景切换时对象被销毁了。解决方式是给桥接对象加 DontDestroyOnLoad,名字用常量统一管理,JS 侧调用前先判断 gameInstance 是否存在。排查时在浏览器控制台手动调一次 gameInstance.SendMessage 看是否有响应,能快速区分是桥接没通还是逻辑没跑。

6. 验收与答辩:怎么证明这套输入方案真的能用

验收这套方案,不能只看「能打出中文」这一条,要按场景拆成可检查的清单。我一般会准备一张验收表,逐项过一遍,答辩时直接拿这张表讲,比口头描述有说服力。

验收项操作步骤通过标准
基础中文输入聚焦输入框,切换中文输入法,输入「测试」并选词上屏文本正确显示,无重复无丢字
组合预览输入拼音但不选词,观察输入框能看到组合中的临时文本,选词后正确替换
候选框跟随在不同屏幕位置各放一个输入框,分别聚焦输入候选框始终贴近当前输入框,不偏移
全屏输入进入全屏后重复基础中文输入输入法正常弹出,候选框位置正确
失焦提交输入到一半点击空白区域已上屏文本保留,组合中文本按约定处理
多浏览器在至少两种主流浏览器各跑一遍行为一致,无控制台报错

答辩时容易被问到的两个点,提前准备好答案。一个是「为什么不用 Unity 自带的输入」,回答要落到组合输入事件在 WebGL 下拿不到完整状态,而不是笼统说「有 bug」。另一个是「全屏坐标怎么保证准」,回答要能说清 boundingRect 加 devicePixelRatio 的换算链路,最好现场打开控制台打印一次坐标对比。演示顺序建议先非全屏走一遍完整输入,再切全屏重复一次,最后点空白失焦,三步把核心场景覆盖完,时间控制在两分钟内。

有个习惯我从那以后一直保留:任何涉及浏览器原生能力的桥接方案,验收前一定先在目标浏览器里手动跑一遍最小闭环,确认 SendMessage 通、坐标对、事件触发顺序符合预期,再往上叠业务逻辑。这套输入方案看着不复杂,但翻车点全在浏览器和 Unity 的交界处,先把桥接层单独验通,后面省下的排查时间远超这点准备成本。希望帮到你。

本文还有配套的精品资源,点击获取