简介:这个演示项目展示了Unity WebGL在浏览器中实现中文输入、输入法跟随与全屏显示的关键方案,面向需要为WebGL应用接入原生输入法体验的Unity开发者。资源共19个文件,以构建数据文件、JavaScript脚本、界面贴图与页面入口文件为主,压缩包约17.4MB,其中构建目录存放核心编译产物,脚本负责加载进度与输入法桥接,图片和图标用于加载进度动画、全屏按钮等界面元素,另有说明文档便于对照项目结构理解实现思路。目前已有938人学习浏览,可作为在Unity WebGL项目中嵌入中文输入法、适配光标位置与全屏切换的实用参考。从资源中既能拿到可直接运行的示例,又能学习脚本与程序交互、画布鼠标事件监听、浏览器全屏接口配合使用等具体写法,适合有基础Unity开发经验、希望改善网页端输入体验的技术人员借鉴改造,无论集成到现有项目还是单独研究都很有价值。
1. 先别急着换方案:Unity WebGL 中文输入这事能做成什么样
Unity WebGL 中文输入,最劝退的往往不是渲染性能,而是输入法跟随和全屏后的一堆玄学问题。你辛辛苦苦把游戏或工具发布到浏览器,点开 Input Field 打中文,候选框却钉死在屏幕左上角,或者全屏后 DOM 输入框直接跑偏到十万八千里。很多团队试过改 UI 插件、换渲染方式、甚至砍掉中文输入功能,结果全部翻车。其实这个问题有成熟解法,核心思路是让浏览器的 DOM 输入框替 Unity 打工,再用脚本把光标位置实时同步过去。本文会从原理讲到可复现的代码,再列出全屏模式和输入法跟随的必改参数,以及五位过来人才知道的坑。适合正在做网页版游戏、在线编辑器或协同工具,并且被中文输入卡住的从业者。
2. WebGL 的输入法困局:为什么原生 Input Field 在浏览器里翻车
2.1 浏览器输入法候选框是独立 UI,Unity 完全拿不到它的位置
Unity 在 WebGL 平台下,UI 最终被渲染进一块 canvas。浏览器里的中文输入法(IME)是操作系统级别的组件,它弹出的候选框、拼音串、状态条都属于原生 UI,和 canvas 里的三角形、按钮完全不在一层。Unity 的 TextMeshPro Input Field 在编辑器里有自己的光标闪烁和输入逻辑,但浏览器 IME 候选框的位置完全由浏览器根据当前焦点元素或鼠标光标位置决定。问题在于 Unity 的 canvas 上没有一个真正的 DOM 焦点元素,浏览器找不到合理的 IME 锚点,于是默认把候选框放在屏幕角落或者页面正中。这就是为什么你打中文时候选框乱跑。
另一个隐藏问题是事件流。Unity 自带的 EventSystem 会拦截 keyDown、keyUp 和字符输入,但在中文输入法状态下,你按下拼音字母时,浏览器先把它交给 IME 进程,输入法确认后产生 compositionend 事件,然后才把最终字符送给页面。Unity 如果直接监听键盘事件,只能拿到原始按键码,拿不到输入法组合后的中文字符。很多团队在 WebGL 里做中文输入,发现打拼音没有反应,或者出来的是一堆英文字母,就是这个原因。
2.2 三条路线对比:iframe 桥接、DOM 输入框、纯 Unity 侧改造
先说结论:最可靠的是 DOM 输入框桥接,也就是在 Unity 的 canvas 上覆盖一个真正的 HTML input 元素,把用户输入交给浏览器处理,再把文本同步回 Unity。这三条路线的取舍是这样的:
| 方案 | 原理 | 输入法跟随 | 全屏适配 | 工作量 |
|---|---|---|---|---|
| iframe 桥接 | 在页面里嵌一个 iframe,里面放输入框,用 postMessage 通信 | 一般,iframe 定位同样要跟随 | 麻烦,iframe 全屏行为和 canvas 不一致 | 中 |
| DOM 输入框覆盖 | 动态创建 input 元素,绝对定位到 Unity 控件上方,监听 input/composition 事件 | 好,可精确控制位置 | 需要监听 fullscreenchange 重新计算 | 中低 |
| 纯 Unity 侧改造 | 用自定义 Shader 或 UI 系统模拟 IME | 极差,无法获取系统 IME 位置 | 相对好 | 高且不推荐 |
纯 Unity 侧改造并不是做不到,但你要在 WebGL 里把系统 IME 的候选框绘制到自己 canvas 里,相当于重写一个输入法前端,维护成本极高。iframe 桥接看似隔离了事件,但 iframe 在移动端虚拟键盘弹出时经常失焦,而且全屏 API 对 iframe 有额外限制。所以常见做法是用 DOM 输入框覆盖,这是国内做 WebGL 在线文档、网页版设计工具时普遍采用的方案。
2.3 为什么一定要做输入法跟随而不是固定位置
偷懒做法是把 DOM 输入框固定到页面某个角落,比如右下角,Unity 里的 InputField 只做展示,用户打字全在角落完成。这在小屏站点上凑合能用,但凡是正经产品,用户都会觉得极其割裂:光标在屏幕中央,候选框却跑到右下角,每敲一个字都要转移视线。输入法跟随的意义在于,候选框必须紧贴 Unity InputField 的光标位置,用户才能像在原生应用里一样连续输入。要做到跟随,核心是两条:第一,Unity 要能拿到 InputField 在屏幕上的精确坐标;第二,浏览器要能把这个坐标换算成 CSS 像素,因为 canvas 可能做了缩放,而 DOM input 定位用的是 CSS 像素。这两条做到位,输入法跟随就完成了百分之八十。
3. 最小可跑通的配套:用 JS 插件把中文输入和光标位置绑在一起
3.1 在 Unity 工程里创建 C# 输入代理脚本
我们直接在 Unity 工程里新建一个 MonoBehaviour,负责监听 UI 上的 InputField,并把坐标同步给浏览器。这里用 Unity 的 RectTransformUtility 把 UI 世界坐标转成屏幕坐标,再通过自定义 jslib 插件传给 JS。先看 C# 侧代码:
using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; using System.Runtime.InteropServices; public class ImeBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void ImeBridge_CreateInput(); [DllImport("__Internal")] private static extern void ImeBridge_MoveInput(float x, float y, float w, float h); [DllImport("__Internal")] private static extern void ImeBridge_DestroyInput(); public InputField targetInput; // 需要支持中文输入的 InputField private RectTransform targetRect; void Start() { #if UNITY_WEBGL && !UNITY_EDITOR ImeBridge_CreateInput(); targetRect = targetInput.GetComponent<RectTransform>(); #endif } void Update() { #if UNITY_WEBGL && !UNITY_EDITOR if (targetInput.isFocused) { Vector2 screenPos = RectTransformUtility.WorldToScreenPoint( Camera.main, targetRect.position); // 世界坐标转屏幕坐标 ImeBridge_MoveInput(screenPos.x, screenPos.y, targetRect.rect.width, targetRect.rect.height); } #endif } void OnDestroy() { #if UNITY_WEBGL && !UNITY_EDITOR ImeBridge_DestroyInput(); #endif } }这段代码的逻辑是:在 Start 里通知浏览器创建隐藏的 DOM input,Update 里检测 InputField 是否聚焦,如果聚焦就持续上报它的位置和尺寸。注意 WorldToScreenPoint 得到的是 Unity 屏幕坐标,原点在左下角,y 轴向上,浏览器坐标原点在左上角,y 轴向下,这个翻转在 JS 侧要做。targetRect.rect.width 和 height 是 UGUI 的本地尺寸,但 UI 可能被 Canvas 缩放,所以这里只做参考,更精确的宽度应该用屏幕坐标换算。如果你用的是 TextMeshPro InputField,脚本原理一样,只是组件类型换成 TMP_InputField。
3.2 index.html 里的 IME 桥接代码和坐标翻转
现在写 jslib 插件。新建一个 ImeBridge.jslib 文件放在 Assets/Plugins 下,内容如下:
mergeInto(LibraryManager.library, { ImeBridge_CreateInput: function () { if (document.getElementById('unity-ime-input')) { return; } var input = document.createElement('input'); input.id = 'unity-ime-input'; input.style.position = 'absolute'; input.style.zIndex = '99999'; input.style.background = 'transparent'; input.style.border = 'none'; input.style.outline = 'none'; input.style.color = 'transparent'; input.style.fontSize = '16px'; input.style.opacity = '0'; input.autocomplete = 'off'; input.autocorrect = 'off'; document.body.appendChild(input); }, ImeBridge_MoveInput: function (x, y, w, h) { var input = document.getElementById('unity-ime-input'); if (!input) return; var canvas = document.getElementById('#canvas'); // 根据你的 index.html 实际 ID 修改 if (!canvas) return; var rect = canvas.getBoundingClientRect(); var scaleX = rect.width / canvas.width; // canvas.width 是 Unity 绘制分辨率 var scaleY = rect.height / canvas.height; var cssX = rect.left + x * scaleX; var cssY = rect.top + (canvas.height - y) * scaleY - h * scaleY; // 翻转 y 轴 input.style.left = cssX + 'px'; input.style.top = cssY + 'px'; input.style.width = (w * scaleX) + 'px'; input.style.height = (h * scaleY) + 'px'; }, ImeBridge_DestroyInput: function () { var input = document.getElementById('unity-ime-input'); if (input) input.parentNode.removeChild(input); } });这里最关键的参数是 cssX 和 cssY 的计算。canvas.width 和 canvas.height 是 Unity 在 WebGL 上的绘制缓冲区大小,通常与 Player Settings 里的分辨率一致;getBoundingClientRect 返回的是 canvas 元素在页面里实际占用的 CSS 尺寸,二者不一定相同,因为 CSS 可能拉伸了 canvas。所以要先算出 scaleX 和 scaleY,再把 Unity 的屏幕坐标乘以比例,加上 canvas 左上角的偏移量,才是 DOM input 应该放置的 CSS 位置。y 轴翻转那一步是这类问题最常见的血泪点:Unity 的 (0,0) 在左下,浏览器 CSS 的 (0,0) 在左上,直接套用必然错位。
3.3 事件回传:composition 序列和 input 事件的完整处理
桥接代码只建 input 还不够,必须把用户输入回传给 Unity。回传一般走 Unity 的 SendMessage 方法,JS 侧监听 input 事件和 composition 事件。注意要在 compositionstart 时标记状态,避免拼音中间过程被当成最终字符提交。JS 侧对应代码如下:
// 在 ImeBridge_CreateInput 成功后追加事件监听 var unityInstance = null; // 指向 UnityLoader 创建的实例,需在外部赋值 var isComposing = false; input.addEventListener('compositionstart', function () { isComposing = true; }); input.addEventListener('compositionend', function () { isComposing = false; var val = input.value; // 清除 input 以便接受下一个候选 input.value = ''; if (unityInstance) { unityInstance.SendMessage('ImeReceiver', 'OnImeText', val); } }); input.addEventListener('input', function () { if (!isComposing) { var val = input.value; input.value = ''; if (unityInstance) { unityInstance.SendMessage('ImeReceiver', 'OnImeText', val); } } });unityInstance 应该在你启动 Unity 实例时赋值,比如unityInstance = createUnityInstance(canvas, config)。OnImeText 是 Unity 里某个挂载脚本上的方法,名称可以自己定,但必须和 SendMessage 里的对象名、方法名严格一致。compositionend 事件后,input.value 里已经是确认好的中文,这时要马上清空 input,否则下一次输入会重复提交上一次的内容。这个清空动作是很多人漏掉的,结果每打一个字,Unity 收到的字符串就会累加一个旧值。
在 Unity 侧,你需要一个接收脚本挂在 GameObject "ImeReceiver" 上,代码如下:
using UnityEngine; using UnityEngine.UI; public class ImeReceiver : MonoBehaviour { public InputField targetInput; public void OnImeText(string text) { if (targetInput != null) { targetInput.text += text; // 追加到现有内容 } } }如果 InputField 本身设置了字符上限,这里要再截断一次。另外,如果产品里有多个人机交互入口,比如聊天框、搜索框、重命名框,需要一个全局管理器来记录当前聚焦的 InputField,避免 OnImeText 只往某一个固定控件里塞。这个全局管理可以简单维护一个静态字段,在 UI 的 OnFocus 事件里赋值即可。
4. 全屏模式下的三件套:尺寸同步、坐标补偿与焦点管理
4.1 监听 fullscreenchange 并重新计算 DOM 输入框的覆盖位置
打开全屏后,canvas 的 CSS 尺寸通常会变成整个屏幕,Unity 绘制分辨率可能没变,也就是 scaleX 和 scaleY 会重新变化。如果你在进入全屏前算好坐标,全屏后没有更新,DOM input 就会偏到页面原来的位置,这在高 DPI 屏幕下尤其明显。解决办法是在 index.html 里监听 fullscreenchange 事件,并重新触发一次坐标同步。可以先让 Unity 侧的 Update 继续跑,但 Update 里可能只聚焦时上报,如果用户全屏后重新聚焦 InputField,JS 侧会拿到新坐标。但为了稳妥,推荐在全屏切换时主动请求 Unity 刷新一次坐标:
document.addEventListener('fullscreenchange', function () { if (document.fullscreenElement) { // 通知 Unity 延迟一帧重新计算坐标 setTimeout(() => { unityInstance.SendMessage('ImeReceiver', 'ForceRefreshImePos', ''); }, 100); } });Unity 侧对应加一个公共方法:
public void ForceRefreshImePos(string _) { Vector2 screenPos = RectTransformUtility.WorldToScreenPoint(Camera.main, targetRect.position); ImeBridge_MoveInput(screenPos.x, screenPos.y, targetRect.rect.width, targetRect.rect.height); }这里的 setTimeout 100ms 是为了等浏览器完成全屏动画和 canvas 尺寸重排。有些低端浏览器全屏切换可能超过 100ms,保守起见可以改成直接监听 canvas 的 resize 事件,或者用 requestAnimationFrame 连续检查几次 canvas 尺寸,直到稳定再上报坐标。全屏状态下 canvas 的 CSS 尺寸改变时,getBoundingClientRect 会更新,所以只要坐标上报时读取的是最新 rect,位置就是正确的。
4.2 全屏后 canvas 的 CSS 尺寸变化与 Unity 分辨率不变的坑
Unity WebGL 的 Player Settings 里有一个分辨率设置,构建后 canvas.width 和 canvas.height 被固定成那个值。但页面 CSS 可以任意拉伸 canvas,比如设置 width: 100%; height: 100%。全屏后 canvas 的 CSS 尺寸几乎必然变化,但 canvas.width 不变,那么 scaleX 和 scaleY 就会变成一个新的值。很多人直接用 canvas.width 当作 CSS 宽度来算位置,全屏前没问题,全屏后 DOM input 的位置会整体左移或上移。正确做法是永远通过 canvas.getBoundingClientRect() 拿实际 CSS 尺寸,而不是依赖 canvas.width。
还有一个容易忽略的坑:全屏后如果 canvas 的 CSS 宽高比和 Unity 分辨率宽高比不一致,canvas 内会被拉伸,画面变形。但这不影响输入法跟随,因为 Unity 屏幕坐标本身是绘制分辨率坐标系,浏览器展示时无论怎么拉伸,换算公式都会把坐标映射到拉伸后的 CSS 位置。如果页面内有多层布局,比如 canvas 外面包了 header 或侧边栏,rect.left 和 rect.top 已经包含这些偏移量,不需要额外去加。
4.3 输入框焦点丢失与自动唤起输入法的处理
全屏状态下,鼠标点击 Unity 的 InputField 时,焦点其实还在浏览器页面上,但 Unity 的 EventSystem 会设置自己的逻辑焦点。如果你在 DOM input 上绑定了 blur 事件,要小心不要在全屏切换时误触发。比如用户点击 canvas 全屏按钮,DOM input 会失焦,然后 Unity 侧 isFocused 变成 false,Update 不再上报坐标。这时如果用户再点击输入框,Unity 重新获得焦点,但浏览器 DOM input 可能没有获得真正的键盘焦点,导致输入法无法弹出。常规解法是:在 Unity 的 InputField 选中回调里,主动把焦点转移到 DOM input 上。你可以在 C# 侧监听 InputField.onFocus 事件,然后调用 jslib 里的 focus 方法:
ImeBridge_FocusInput: function () { var input = document.getElementById('unity-ime-input'); if (input) { input.focus(); input.select(); } },但要注意,input 元素是透明且 opacity 为 0 的,直接 focus 会被浏览器判定为"用户没有实际点击可输入区域",在 Safari 上可能不会弹出虚拟键盘。一个常见技巧是在焦点时临时把 input 的 opacity 设为 1,等一帧再恢复为 0。移动端 WebGL 这个 trick 尤其重要,否则软键盘死活不出来。如果你的产品主要跑在 PC 浏览器,那直接 focus 就够用。
5. 踩坑与常见问题:输入法跟随的 5 个血泪案例
5.1 候选框在屏幕左上角,不跟随光标
现象:浏览器弹出中文候选框永远出现在页面左上角,就算 Unity 的 InputField 在屏幕正中央也一样。
原因:DOM input 虽然存在,但它的位置没有被设置到 InputField 上方。可能你的 jslib 没有收到坐标,或者坐标计算时报错。最常见的是 Unity 侧 Update 里 isFocused 判断失败,导致 ImeBridge_MoveInput 从未被调用。
解决:先在 JS 侧加 console.log 输出 x、y、scaleX、scaleY,观察每次点击有没有数据上来。没有数据就检查 C# 侧 targetInput 是否赋值、isFocused 是否成立。有数据但位置偏,检查 y 轴翻转和 rect 偏移。我遇到过某个项目,Canvas 的 Render Mode 是 ScreenSpaceCamera,RectTransformUtility.WorldToScreenPoint 传的 Camera.main 是空引用,坐标全是 0。改成传 UI 相机就正常了。
5.2 中文输入法下按键被吞掉或重复
现象:用中文输入法打字,拼音打了一半,Unity 里突然出现一堆字母;或者明明只选了一个候选字,Unity 收到两个一模一样的字符。
原因:JS 侧同时监听了 keyup 和 compositionend,在拼音组合过程中按下字母键触发了 keyup 事件,又被 Unity EventSystem 当作普通英文字符处理。重复字符是因为 input 事件和 compositionend 事件都提交了文本,且 input.value 没有及时清空。
解决:最终字符只通过 compositionend 提交,普通 input 事件只在非输入法状态时处理。另外在 Unity 侧,要保证 InputField 不是由 Unity 自己处理键盘输入,而是完全由 DOM input 代理。可以在 Update 里临时把 targetInput.interactable 置为 false,但这样点击聚焦就会失效。更好的做法是让 Unity 的 InputField 只负责显示,不拦截键盘事件,具体是在 Canvas 的 GraphicRaycaster 上做一层过滤,或者用自定义 EventSystem 模块。如果嫌麻烦,先把 JS 侧改为仅 compositionend 提交,大多数重复问题都能解决。
5.3 全屏后 DOM 输入框位置越偏越大
现象:退出全屏再进入全屏,反复几次后,输入框的位置偏移越来越大,每次多偏几十像素。
原因:全屏切换过程中,页面的滚动条消失又出现,document.body 的整体布局偏移了。DOM input 的定位用的是 position: absolute,它的参考系是包含块,一般是 body 或最近的定位祖先。如果页面有滚动,而 canvas 的 rect 是相对视口的,但绝对定位元素的 left/top 是相对文档的,滚动偏移没有扣除,就会累积误差。
解决:把 input 的 position 改成 fixed,这样它始终相对于浏览器视口,不受滚动影响。同时把 left/top 直接用 getBoundingClientRect 的坐标,因为 getBoundingClientRect 本身就是视口坐标。很多项目的踩坑记录都是因为 absolute 和 fixed 的混乱造成的。另外,全屏模式下滚动条几乎不存在,但浏览器地址栏可能自动隐藏,fixed 更稳定。
5.4 DOM 输入框被 Unity 渲染层遮挡
现象:input 元素明明设置了 zIndex 99999,但输入时看不到光标,甚至点击被 Unity 的 canvas 吃掉。
原因:Unity 的 canvas 默认有自己的堆叠上下文,可能设置了 transform 或滤镜,导致 zIndex 规则不生效。你设置的 zIndex 很高,但 canvas 内部的 WebGL 内容在 GPU 层,如果 canvas 本身的位置固定且不透明,DOM input 即使在上方,由于 canvas 开启了 pointer-events 默认值,鼠标点击会被 canvas 拦截,input 收不到焦点。
解决:给 Unity 的 canvas 容器加样式pointer-events: none,并把 input 放在 canvas 外面。更稳妥的是把 input 放在一个独立的空 div 里,设置pointer-events: auto。同时 Unity 侧要在 InputField 点击时,把点击事件手动传给 JS,让 JS 对 DOM input 执行 focus()。这里不存在"事件穿透"的说法,本质是调整层级和命中区域。
5.5 焦点漂移:点击一次输入框却出两个字符
现象:用户点击输入框,第一次点击通常只聚焦,但有时候点击一次后输入框里立刻多出一个"a"或"1"这样的字符。
原因:Unity 的 EventSystem 在点击时生成了一个键盘事件,DOM input 又收到了一次同样的点击事件,导致双重输入。尤其在 mac 浏览器上,点击 input 元素时浏览器会自动同步一个键盘焦点事件,而 Unity 侧又用 OnPointerClick 回调去更新坐标,两边的逻辑叠加产生字符。
解决:Unity 侧在处理点击时,设置一个标志位,之后一帧内的键盘事件全部忽略;JS 侧监听点击事件时,如果 target 是那个透明 input,就不阻止事件,但也别做额外处理。我见过一个项目是在 input 上绑定了 mousedown.preventDefult(),结果焦点永远丢不掉,最后靠event.stopPropagation()解决了。不同浏览器的行为有差异,建议在 Chrome 和 Safari 上分别测试点击一次、快速点击两次、点击后拖动等场景。
6. 进阶验证:模拟移动端虚拟键盘与多输入法兼容的验收清单
做这套方案不能只盯着 PC 上的微软拼音。移动端 Safari 和 Chrome 的输入法行为差异巨大,虚拟键盘弹出会压缩视口,canvas 尺寸发生变化,如果不重新计算坐标,输入框会跑到键盘后面。我自己的习惯是保留一个调试按钮,可以手动切换"模拟移动端键盘",也就是把浏览器视口缩放到一半高度,然后检查 DOM input 的位置是否还在可点击区域内。验收时按下面清单逐条跑:
- PC Chrome + 微软拼音:输入拼音,候选框跟随光标,候选选中后 Unity 显示正确中文
- PC Firefox + 搜狗输入法:重复快速输入看是否有吞字或重复字符
- macOS Safari + 原生拼音:点击输入框是否能唤起中文输入法,焦点是否稳定
- iOS Safari + 系统中文键盘:软键盘弹出后,DOM input 是否被顶到可视区域内,Unity 坐标是否跟随
- Android Chrome + 第三方输入法:compositionevent 序列是否完整,部分输入法不发送 compositionend 需要注意
验证方法可以在 Unity 侧做一个诊断面板,实时显示当前 DOM input 的 left/top 和 canvas 的 rect。如果发现移动端软键盘弹出后 canvas 高度变化,可以在 resize 事件里延迟 200ms 重新调用 MoveInput,因为键盘弹出动画期间坐标会抖动。还有一个很重要的技巧:在 iOS 上要给 input 设置inputmode属性,拼音输入时用inputmode="text",数字输入时切换成inputmode="numeric",这样浏览器会弹出对应的键盘布局。这个参数在 Android 上也有效。
最后说一个我自己踩过的教训:这套方案最怕的是 Unity 侧每帧上报坐标,而 JS 侧每次都去修改 DOM 元素样式,导致浏览器不断重排,输入时有明显卡顿。优化方法是只在 InputField 的光标位置变化时才更新 DOM 元素,或者在输入法组合期间暂停更新。我后来改成用 0.1 秒的定时器轮询,只有当坐标和上一次差值超过 2 像素才操作 DOM,性能和稳定性都好了很多。另外,全屏时坐标重复计算的代码一定要放在 fullscreenchange 事件之后的一个宏任务里,否则拿到的还是旧尺寸。希望这组方案能帮你把 Unity WebGL 中文输入这件事彻底落地,少走点弯路。
本文还有配套的精品资源,点击获取