☰
Unity WebGL中文输入解决方案:透明DOM覆盖层与IME输入法跟随
2026/10/23 3:11:34 网站建设 项目流程

简介:这是一份针对Unity WebGL中文输入需求的示例工程,面向希望在网页端实现原生中文输入与输入法跟随的Unity开发者,尤其适合在文本交互、聊天、搜索等场景中需要中文录入的WebGL项目。工程演示了通过JavaScript与Unity交互层监听键盘事件、激活浏览器输入法,以及在全屏模式下动态校正输入法窗口位置的完整方案。压缩包共19个文件,约17.4MB,其中3个unityweb为构建后的WebAssembly资源,2个js负责与浏览器输入法联动,另有html入口、css样式、png图标及说明文档,目录结构清晰,可直接部署调试。目前已有939人学习下载。通过学习该示例,可掌握跨语言通信机制、输入法跟随的坐标计算方式,以及全屏切换时的边界处理思路,对优化中文本地化体验有直接参考价值,适合作为集成参考或二次开发底座。

1. 中文输入框在 Unity WebGL 里点不出中文,这个 Demo 凭什么能解决

Unity WebGL 项目接入国内浏览器后,第一次翻车往往是玩家改昵称或者发聊天消息:UGUI/TMP 的 InputField 能弹键盘,中文输入法的候选词却始终不出现。这不是引擎 bug,而是 WebGL 构建下浏览器默认把 IME 事件交给了 DOM 层,Unity 的画布接不住这套事件链路。这份 Demo 的核心价值,是绕开 Unity 内部输入框,在页面上叠一层透明 DOM Input,让系统输入法候选框自然弹出,同时做了输入法跟随和全屏适配。适合正在做页游、H5 活动页和 WebGL 工具型产品输入组件的人,拿来即跑,看完源码能直接改成自己的业务输入方案。

2. 整体架构拆解:C#、JSLib 与 DOM Input 三者如何协作

2.1 WebGL 构建里普通 InputField 为什么成为输入法黑匣子

Unity WebGL 的产物在浏览器里是一个 Canvas 加上一堆 WebAssembly 逻辑,Unity 内部的 UI 并没有直接拿到操作系统输入法组合事件的能力。浏览器处理中文输入时,IME 候选框依赖 input、textarea 或者 contenteditable 元素去接收composition事件,而 Unity 的 InputField 并不属于这三种 DOM 元素,所以拼音被拆成一串普通按键隐私,最终显示出来的就是英文字母,候选框则完全无影踪。这就像你对着一个黑匣子喊话,黑匣子听不见,你也不知道候选框该往哪儿站。

这个 Demo 采用的“透明 DOM Input 覆盖层”方案,核心思路不是去让 Unity 支持 IME,而是把浏览器本来就支持的中文输入能力重新接回 Unity。整个过程分三步:C# 拿到当前输入框在屏幕上的矩形区域,通过 JSLib 创建一个透明<input>,浏览器接管组合输入,输入事件再回传给 C# 更新 Unity 里的文本。这样中文输入法不用做任何适配,输入法跟随也变成了对 DOM 元素位置的管理。

之所以不推荐在 WebGL 里直接调用系统输入法 API,是因为 Windows、macOS、移动端各家输入法行为差异太大,你没法保证每个浏览器都暴露同样的组合事件结构。反而是 DOM Input 这种最朴素的浏览器原生能力,兼容面最广,踩坑最少。当然代价也很直接:焦点管理、层级遮挡、坐标换算、全屏后的重排,这些原本由引擎包办的事情,现在全部要你自己兜住。这份 Demo 的价值就是把这套兜底逻辑完整写了出来。

2.2 Demo 的目录结构与消息流

拉下工程后先看目录结构,文件不多,但每层的职责分得很清楚:

Assets/ ├── Plugins │ └── WebGL │ ├── ChineseInput.jslib │ └── FullScreen.jslib ├── Scenes │ └── Demo.unity └── Scripts ├── Input │ ├── ImeInputBridge.cs │ └── ImeInputHelper.cs └── UI └── FullScreenController.cs

ChineseInput.jslib和FullScreen.jslib是 WebGL 构建时才会被编译进去的 JavaScript 桥接层。Unity 的 C# 通过[DllImport("__Internal")]声明外部方法,构建时 Emscripten 会把方法调用映射到 jslib 里的同名函数。这个模式在 WebGL 插件开发里是标配,属于 U3D 和浏览器之间唯一的直接通信通道。

数据流可以用一条链路说清楚:

  1. 用户点击 Unity UI 里的输入框,Unity 的 EventSystem 检测到选中对象。
  2. ImeInputBridge拿到该输入框的 RectTransform,换算成屏幕坐标。
  3. C# 调 JSLib 创建透明 DOM Input,并调用focus(),此时系统输入法候选框被激活。
  4. DOM Input 触发input事件,jslib 里通过unityInstance.SendMessage把当前值回传 C#。
  5. C# 把文本写回 TMP_InputField,输入法跟随靠每帧更新矩形位置来保证。
  6. 全屏切换时,FullScreen.jslib 调用浏览器的 Fullscreen API,再通过fullscreenchange事件通知 C# 重新计算坐标。

这里最关键的一个坐标换算是方向问题。Unity 的Screen坐标原点在左下角,DOM 的 CSS 坐标原点在左上角,所以取到输入框四个世界角点后,必须对 Y 轴做一次翻转。很多 WebGL 输入插件第一次跑起来候选框落在屏幕下半部分,八成都是忘了这一步。

JSLib 方法C# 引用作用关键参数
ImeInput_Create(float x, float y, float w, float h)创建透明输入框并聚焦左上角坐标、宽高
ImeInput_SetRect(float x, float y, float w, float h)每帧移动输入框同 Create
ImeInput_Destroy()销毁并清理焦点无
FullScreen_Request()进入浏览器全屏无

这个目录结构对二次开发也很友好,输入和全屏是解耦的,你只想要中文输入就把 FullScreen 部分拆掉,只想要全屏控制也不会被输入逻辑干扰。

3. 核心模块实现:输入法跟随是怎么做的

3.1 JSLib 层创建透明 DOM Input

先看ChineseInput.jslib,这一段是所有输入法能力的根。注意 jslib 不是普通 JavaScript 文件,它要用mergeInto(LibraryManager.library, {})的格式给 Unity 暴露外部函数:

mergeInto(LibraryManager.library, { ImeInput_Create: function (x, y, w, h) { if (window.__imeInput) { ImeInput_Destroy(); } var input = document.createElement('input'); input.type = 'text'; input.style.position = 'fixed'; input.style.left = x + 'px'; input.style.top = y + 'px'; input.style.width = Math.max(w, 2) + 'px'; input.style.height = Math.max(h, 2) + 'px'; input.style.opacity = '0'; input.style.background = 'transparent'; input.style.border = 'none'; input.style.outline = 'none'; input.style.zIndex = '2147483647'; input.setAttribute('autocomplete', 'off'); input.setAttribute('autocorrect', 'off'); document.body.appendChild(input); input.addEventListener('input', function () { if (window.unityInstance) { window.unityInstance.SendMessage( 'ImeInputBridge', 'SetText', input.value ); } }); input.focus(); window.__imeInput = input; }, ImeInput_SetRect: function (x, y, w, h) { if (!window.__imeInput) return; window.__imeInput.style.left = x + 'px'; window.__imeInput.style.top = y + 'px'; window.__imeInput.style.width = Math.max(w, 2) + 'px'; window.__imeInput.style.height = Math.max(h, 2) + 'px'; }, ImeInput_Destroy: function () { var input = window.__imeInput; if (input && input.parentNode) { input.parentNode.removeChild(input); } window.__imeInput = null; } });

代码看起来简单,细节其实都藏在样式里。position: fixed是为了让输入框始终相对于浏览器视口定位,不受 Canvas 尺寸和滚动条影响。opacity: 0是隐藏视觉但保留元素结构,这里不要用display: none,因为部分浏览器对不可见输入框不弹 IME 面板。宽高用Math.max(..., 2)兜底,避免传入 0 的时候输入框被当成不存在的元素。

input事件监听是整个回传链路的入口。每次输入内容变化,JS 就把最新值通过SendMessage发给场景里名为ImeInputBridge的物体。这里要特别留意 GameObject 名称和场景中的名字必须完全一致,不然会报SendMessage has no receiver。Unity 模板里暴露出来的实例变量通常是unityInstance,如果你改过构建模板,记得同步改这里。

ImeInput_SetRect是输入法跟随的关键接口。它不直接操作 Unity 内部的光标位置,而是每帧把 DOM 输入框移动到 Unity 输入框当前所在的位置。浏览器看到 DOM Input 移动了,IME 候选框就会跟着走到新的位置。这个方式对中文拼音、手写、语音输入都有效,因为真正决定候选框位置的是 DOM 元素本身。

3.2 C# 侧的位置换算与文本同步

C# 侧负责把 Unity 的 UI 坐标转换成浏览器需要的 CSS 坐标。Demo 里用 TMP_InputField 做演示,核心代码在ImeInputBridge.cs:

using System.Runtime.InteropServices; using TMPro; using UnityEngine; public class ImeInputBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void ImeInput_Create(float x, float y, float w, float h); [DllImport("__Internal")] private static extern void ImeInput_SetRect(float x, float y, float w, float h); [DllImport("__Internal")] private static extern void ImeInput_Destroy(); private TMP_InputField currentField; private RectTransform currentRect; public void Open(TMP_InputField field) { currentField = field; currentRect = field.GetComponent<RectTransform>(); Rect r = GetScreenRect(currentRect); ImeInput_Create(r.x, r.y, r.width, r.height); } public void Close() { currentField = null; currentRect = null; ImeInput_Destroy(); } private void Update() { if (currentRect == null) return; Rect r = GetScreenRect(currentRect); ImeInput_SetRect(r.x, r.y, r.width, r.height); } private Rect GetScreenRect(RectTransform rectTransform) { Vector3[] corners = new Vector3[4]; rectTransform.GetWorldCorners(corners); Vector3 bottomLeft = RectTransformUtility.WorldToScreenPoint(null, corners[0]); Vector3 topRight = RectTransformUtility.WorldToScreenPoint(null, corners[2]); return new Rect( bottomLeft.x, Screen.height - topRight.y, topRight.x - bottomLeft.x, topRight.y - bottomLeft.y ); } public void SetText(string value) { if (currentField == null) return; currentField.text = value; currentField.caretPosition = value.Length; } }

GetScreenRect里的WorldToScreenPoint第二参数是 Camera。Screen Space Overlay 模式下传null是对的;如果你把 UI 挂到了 Camera 模式下,必须传入对应的 UI Camera,否则坐标会偏差。Y 轴翻转的处理写在了Screen.height - topRight.y这行,这是 DOM 坐标和 Unity 屏幕坐标最需要记住的分水岭。

Update()里逐帧调ImeInput_SetRect,初看觉得浪费,但实际很有必要。输入框可能因为 UI 位移动画、ScrollRect 拖动、全屏切换而改变位置,如果只在点击时定位一次,输入法候选框就会漂移。逐帧更新量级很小,浏览器塞一个样式属性完全扛得住。

文本同步方面,SetText每次把整个输入框内容赋给 TMP,然后移动光标到末尾。Demo 场景下够用,生产业务里如果聊天内容很长,建议改成增量更新,否则每敲一个拼音就把整段文本重建一次,可能会出现光标跳动。

3.3 全屏接口与坐标回算

全屏部分在FullScreen.jslib。Unity WebGL 的Screen.fullScreen在浏览器里并不总是可靠,最稳的方式是直接调浏览器的 Fullscreen API:

mergeInto(LibraryManager.library, { FullScreen_Request: function () { var canvas = document.querySelector('#unity-canvas'); if (!canvas) canvas = document.querySelector('canvas'); var container = canvas.parentElement || document.body; var requestFn = container.requestFullscreen || container.webkitRequestFullscreen || container.msRequestFullscreen; if (requestFn) { requestFn.call(container); } } });

把全屏挂在 Canvas 的父容器上比挂在 Canvas 本身更安全,因为 Unity 的输出画面有时候会带一个固定的背景容器,父容器全屏才能保证铺满整个屏幕。

全屏切换后 Canvas 的 CSS 尺寸会变,Unity 的Screen.width和Screen.height也会更新,但浏览器 DOM 坐标和 Canvas 尺寸之间不一定同步。这里需要在 JS 里监听fullscreenchange,再回发 Unity 强制重算一次:

document.addEventListener('fullscreenchange', function () { if (window.unityInstance) { window.unityInstance.SendMessage( 'ImeInputBridge', 'OnFullscreenChanged', '' ); } });

对应的 C# 方法只需要在当前输入框上触发一次位置更新:

public void OnFullscreenChanged() { if (currentRect != null) { Rect r = GetScreenRect(currentRect); ImeInput_SetRect(r.x, r.y, r.width, r.height); } }

全屏状态的变量名、浏览器兼容前缀在不同浏览器里有差异,建议做一层getFullscreenElement判断,而不是只依赖fullscreenchange事件本身。

4. 跑通 Demo:构建、部署与在浏览器里验证中文输入

4.1 用 Unity Editor 打开并配置构建

拿到工程后先用 Unity Hub 打开,注意 Editor 版本不需要完全一致,Demo 用 2021.3 LTS 或 2022.3 LTS 都能直接打开。首次打开会让 TMP 弹导入提示,选择导入即可。场景文件在Assets/Scenes/Demo.unity,里面放了一个输入框、一个显示区域、一个全屏按钮,以及挂在空物体上的ImeInputBridge和FullScreenController。

构建之前先看 Player Settings 里的 WebGL 设置。调试阶段建议把 Compression Format 设为 Disabled,因为 Brotli 压缩虽然体积小,但本地静态服务器不一定配了正确的 Content-Encoding,稍不留意就会出现白屏。发布上线时可以再切回 Brotli,两者不影响 C# 和 JS 代码逻辑。

构建目标选择 WebGL,点击 Build And Run 会直接弹浏览器,但我更推荐 Build 到目录后再用本地 HTTP 服务器跑,这样方便开 DevTools 看 Network 和 Console。构建成功后会生成index.html、Build文件夹和StreamingAssets文件夹,这三个部分是 WebGL 产物的完整集合。

4.2 本地 HTTP 服务器运行验证

构建产物不能直接双击index.html用file://协议打开,浏览器的安全策略会拦截资源加载,白屏不是项目问题。我在本地一般用 Python 起一个静态服务:

cd Build python -m http.server 8080

没有 Python 的环境可以用npx serve .,效果一样。访问http://localhost:8080后,先看 Console 有没有报错。正常状态下应该没有任何红色报错,Unity 的加载进度条出现并进入 Demo 界面。

进入页面先不要急着点输入框,先按一下 F12 打开开发者工具,切到 Console 和 Elements 两个面板。点击输入框后,Elements 面板里应当能看到一个透明的<input>元素被追加到了<body>里,它有很高的 zIndex,这就是 DOM 输入覆盖层已经生效的凭据。

4.3 验证输入法跟随和全屏切换

Demo 的验证可以按下面这个清单过一遍,虽然不是自动化测试,但覆盖了最容易翻车的几个点:

检查项操作预期结果
IME 面板唤出聚焦输入框,调出中文输入法候选框出现在输入框附近
输入法跟随拖动 UI 面板或切换下一个输入框候选框跟随新位置
中文回写输入“你好”Unity 输入框中显示“你好”
焦点释放点击输入框以外区域DOM 输入框被销毁,IME 面板收起
全屏切换点击全屏按钮页面进入全屏,输入框位置不变
全屏后输入全屏状态下调出输入法候选框仍贴近输入框

如果输入法候选框没有出现,回到 JSLib 代码检查input.focus()是否在用户手势事件栈里被调用。Unity 的点击事件经过 Canvas 再走 SendMessage 到 C#,再调 JSLib,这个链路看似经过多层,但本质上还在浏览器的事件循环里,所以 focus 通常不会被拦截。一旦你改成异步回调、定时器延迟后再创建,浏览器就会丢掉手势上下文,焦点会被静默忽略。

全屏按钮的测试最好放在输入框激活状态下进行,也就是先点击输入框,再点全屏按钮。这样可以直接验证fullscreenchange后 DOM Input 的位置是否被正确重算,一次就把坐标问题暴露清楚。

5. 避坑指南:IME、焦点和全屏的常见问题

5.1 候选框不跟随,固定在屏幕角落

现象:中文能输入,但输入法候选框一直在屏幕左上角或底部,不跟着输入框走。

原因:DOM Input 的宽高是 0,或者没有使用position: fixed,浏览器无法把 IME 候选框绑定到一个有效元素上。另一个常见原因是ImeInput_SetRect没被调用,输入框位置变化后 DOM Input 还在原地。

解决:JSLib 里创建输入框时用Math.max(w, 2)保证最小尺寸,样式必须显式写position: fixed。C# 侧确认Update()里持续调ImeInput_SetRect,不要只在点击时定位一次。如果输入框有位移动画,需要等动画结束后再重算一次坐标,否则候选框会残留在中途位置。

5.2 透明输入框把鼠标点击挡住了

现象:打开输入法后,输入框下方或旁边的按钮点击无响应,像是被什么东西隔了一层。

原因:DOM Input 虽然透明,但它真实地占据着页面上的一个矩形区域,并且在这个区域内拦截了所有鼠标事件。

解决:按需设置pointer-events。创建输入框时保持默认值,让浏览器能接收点击和键盘事件;失焦销毁时直接从 DOM 移除。如果业务上有“输入框边缘紧贴按钮”的布局,可以只把输入框尺寸收敛到文本区域内部,而不是铺满整个背景。还有一个更简单的兜底方案:在页面根元素上监听pointerdown,如果点击目标不是当前输入框,立即调用ImeInput_Destroy。

5.3 全屏后输入框位置错位半个屏幕

现象:正常窗口下中文输入一切正常,点全屏按钮后,DOM Input 和 Unity 输入框不在同一个位置。

原因:全屏切换的瞬间,浏览器视口尺寸变了,但 Unity 的Screen.width和Screen.height还没更新完,C# 在旧尺寸下做了坐标翻转。如果你用的是WorldToScreenPoint(null),在 Canvas 使用 Camera 模式时,这个 null 值会让坐标换算基准丢失。

解决:监听fullscreenchange后不要立即重算,用yield return null等一帧,让 Unity 完成内部尺寸同步。Camera 参数要传 UI 实际使用的相机。再检查全屏容器的 CSS,确保它设置了width: 100%; height: 100%,否则 Canvas 内容会被拉伸,世界角点本身就不正确。

5.4 中文输入时 Enter 会误触发送

现象:输入拼音时按 Enter 上屏候选词,结果业务逻辑把这个 Enter 当成发送,直接 dispatch 了一串拼音。

原因:中文输入法在组合过程中产生的 Enter 键属于 IME 确认键,但 keydown 事件依然会冒泡到页面层。只监听input事件时看不出来,一旦你加了键盘提交逻辑就会翻车。

解决:在 JSLib 里维护一个组合状态标记,compositionstart时置为 true,compositionend时置为 false:

var isComposing = false; input.addEventListener('compositionstart', function () { isComposing = true; }); input.addEventListener('compositionend', function () { isComposing = false; }); input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && isComposing) { e.preventDefault(); return; } // 这里再走业务提交逻辑 });

这个标记的维护必须在 DOM Input 创建时同步注册,销毁时跟着一起释放。很多输入插件只处理了input事件,漏了 composition 事件,导致中文输入法下的 Enter 行为像一个玄学问题,其实根源就这一行标记的事。

5.5 编辑器里运行直接报错找不到 __Internal

现象:在 Unity Editor 里点 Play,Console 报DllNotFoundException: __Internal,或者直接提示外部方法无法解析。

原因:[DllImport("__Internal")]只在 WebGL 构建时生效,Editor 运行环境根本没有这个 DLL。这个 P/Invoke 定义写出来就是为了让 Emscripten 在构建阶段寻找 jslib 同名函数。

解决:所有 JSLib 桥接方法套上#if UNITY_WEBGL && !UNITY_EDITOR,并给 Editor 模式提供空实现:

#if UNITY_WEBGL && !UNITY_EDITOR [DllImport("__Internal")] private static extern void ImeInput_Create(float x, float y, float w, float h); #else private static void ImeInput_Create(float x, float y, float w, float h) { Debug.Log($"[Editor] IME input at {x},{y}"); } #endif

Editor 模式下的空实现不会触发真正的浏览器输入法,但能让整个业务流程先跑通。调试 UI 交互时不需要每次都构建到浏览器,省下大量等待时间。这套条件编译结构也是后续扩展 JS 插件的标准模板,每加一个外部方法就照着写一遍。

6. 二次开发要点:把输入法 Demo 变成业务输入组件

6.1 从单例改成输入框注册表

Demo 里的ImeInputBridge一次只服务一个输入框,这在单场景演示里没问题,真实业务页面上可能同时存在昵称框、搜索框、聊天框。我会先把单例逻辑改成注册表,用 EventSystem 的当前选中物体来决定服务哪个输入框:每个输入框在OnFocus时注册自己,失焦后由桥接层统一销毁 DOM Input。这样用户从一个输入框直接点到另一个输入框时,不需要先失焦再聚焦,DOM Input 可以原地切换位置。

6.2 补上 Composition 语义和文本校验

Demo 的SetText是纯赋值,生产环境需要叠加两个能力:一是最大长度限制,JS 回传的字符串要先在 C# 侧按业务规则截断,否则只依赖 TMP 的 character limit 会出现回退不一致;二是 Enter 提交语义,接住上一章说的isComposing标记,把 text 回调包装成SubmitText事件,让业务侧只关心最终字符串,不用分辨中间组合过程。

public void SetText(string value) { if (currentField == null) return; if (maxLength > 0 && value.Length > maxLength) { value = value.Substring(0, maxLength); } currentField.text = value; currentField.caretPosition = value.Length; }

6.3 移动端兜底和失焦清理

移动端浏览器对opacity: 0的输入框有更严格的可见性要求,我会保留一个<textarea>的切换开关,某些 iOS 版本下 textarea 的 IME 唤起更稳定。失焦清理必须做两层:Unity 侧的OnApplicationFocus(false)和 JS 侧的blur事件同时监听,防止用户切走浏览器标签页后 DOM Input 仍悬浮在页面上。从那以后我每次封装 WebGL 输入组件,都强制把“中文组合输入 + 全屏切换 + 跨页面失焦”这三条路径走一遍才算验收过。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询