
简介面向Unity WebGL开发者的中文输入与输入法跟随Demo资源直接解决Web端Unity应用输入中文不便、输入法弹窗位置错乱、全屏状态下面板无法跟随等常见问题。资源包共19个文件压缩后大小约17.4MB其中包含HTML入口文件、JavaScript交互逻辑、CSS样式表、Unity WebGL构建数据.unityweb格式及多张UI预览图方便了解页面加载到构建运行的整体过程。当前已有934人学习下载适合希望快速补齐WebGL中文输入能力的中高级Unity开发者参考。通过研究该Demo可以掌握Unity C#脚本与浏览器JavaScript之间的通信方式理解输入法跟随功能如何利用浏览器事件动态调整输入法窗口位置。同时项目中对全屏模式切换、输入法样式定制、浏览器兼容性及异常处理均有涉及能帮助开发者在实际项目中减少踩坑提升WebGL应用的中文输入体验。 不算少见你在网上搜“Unity WebGL 中文输入”能找到一大堆人卡在同一道坎上浏览器里输入框一聚焦英文敲得飞快一切到中文输入法拼音倒是出来了候选框却不知道飘到哪里去或者干脆输不进去。等项目接上全屏需求的瞬间问题更上头——输入法弹窗飞到了屏幕角落Unity 这边的输入框和系统输入法完全对不上号。标题里的“Dome”应该是“Demo”的手误但这三个痛点是真的Unity WebGL 中文输入、输入法跟随、全屏支持属于做网页端 Unity 项目绕不开的硬骨头。这篇文章就把我从踩坑到落地的一套方案完整拆开讲内容包括底层原理、jslib 注入、坐标换算、全屏 API 联动以及我在真实项目里记录下的高频问题和排查思路。无论你是刚转 WebGL 的新手还是已经在 H5 游戏里被输入法折磨过几轮的开发者这篇都值得你花十分钟读完再收藏。1. 先把问题说清楚Unity WebGL 中文输入到底难在哪1.1 输入的底层机制Unity 的“假输入框”和浏览器的“真输入法”很多人第一次在 Unity WebGL 里做输入时都会愣一下Unity 编辑器里跑得好好的 InputField怎么到了浏览器里就只能敲英文了要理解这个问题得先搞清楚 Unity WebGL 里的输入框本质是什么。Unity 编辑器或者桌面平台上InputField 是引擎内部直接处理的 UI 控件系统输入法会把候选窗口直接挂到原生窗口上所以中文输入天然可用。但 WebGL 构建出来的 Unity 应用跑在浏览器里引擎的 UI 画在 WebGL Canvas 上它其实是一个被浏览器当成“一块画布”的东西。InputField 显示出来的光标、文字全是引擎自己画上去的不是一个真正的 HTMLinput元素浏览器输入法根本不认识它。这里的关键在于输入法的工作流程。中文输入法的核心是 composition组合输入你按下拼音字母输入法进入预编辑状态候选框出现选完字才触发真正的 input 事件提交文本。这个过程中composition 事件是由浏览器在“能承受 IME 的输入框元素”上分发的。Unity 的 Canvas 不能接收 IME 的 composition 事件所以你在 Unity 的 WebGL 输入框里打拼音浏览器不知道把候选框放哪Unity 更拿不到组合过程中的文本最后的表现就是中文根本进不去或者进去一团乱码。1.2 三个需求其实是一条链路输入、跟随、全屏不能分开做很多教程会把这问题拆成三篇讲先是中文输入怎么处理再讲输入法跟随怎么调最后聊全屏支持。实际做项目你就会发现这三件事根本拆不开——它们共享同一套坐标体系和事件链路。举个很现实的例子你给 Unity 的 Canvas 上盖了一层透明的 HTML input 来接输入法的 composition 事件位置也对准了 Unity 输入框。这时候用户点了全屏按钮Unity 的 Canvas 尺寸瞬间变化你那层 HTML input 如果还用原来的 left/top下一秒就会偏到十万八千里。输入法候选框或者你的透明输入层一旦位置偏移用户每敲一个字都要怀疑自己是不是打错了位置。所以在方案设计的第一天我就要求自己把这三件事放到一起考虑输入层跟随 Unity 输入框的坐标而坐标换算的基准是当前 canvas 在浏览器视口中的 CSS 位置和尺寸全屏事件发生后这个基准必须重新计算。所有逻辑都围绕“坐标映射”这一根线展开后面每个环节都会反复遇到它。2. 方案选型为什么我放弃插件用原生覆盖层2.1 现成插件的坑聊中文输入网上第一个跳出来的就是 Unity 官方维护的 webgl-input-plugins或者各种第三方输入的 JS 插件。我一开始也图省事直接用结果在真实项目里被坑了好几次。官方插件的问题不是不能用而是覆盖不到所有场景。它对中文输入的默认处理策略是“拦截 InputField 的焦点事件再把文本注入回 Unity”这个思路没错但当你想要定制输入法跟随候选框位置、或者把它嵌套进 iframe 播放页、或者对接抖音/微信小程序的 webview 时插件的封装反而成了阻碍。很多这类插件为了兼容性位置计算用的是 fixed 定位一旦全屏或者外层页面滚动定位立刻失效。第三方插件更不可控有些已经几年不维护和最新的 Unity 版本构建出来的 JS 模块交互时会直接报_unityInstance is not defined或者Module.ccall is not a function之类的错排查成本比自己做一套还高。我后来想通了中文输入这套东西本质上就是在和浏览器 API 打交道不如直接写一层轻量 JS 嵌入 Unity WebGL 模板把控制权握在自己手里。2.2 覆盖层方案的总体思路我最终采用的思路可以概括为一句话在 Unity 的 canvas 上方盖一个透明、绝对定位的 HTMLinput接管输入法事件拿到中文文本后再回填给 Unity 的 InputField。这个透明输入层只在 Unity 输入框处于激活状态时出现平时pointer-events设为none绝不干扰游戏操作。一旦 InputField 通过点击获得焦点JS 侧就把这个透明 input 显示出来并让它聚焦用户的所有键盘输入、输入法组合、候选框弹出全部发生在这个真实的 HTML 元素上。当汉字被选中、composition 结束JS 捕获最终文本通过 Unity 对外暴露的接口注入回引擎侧随后隐藏透明层让 Unity 继续作为 UI 的主人。这套方案的优势很明显输入法候选框的跟随位置天然正确因为浏览器会为真实 input 元素自动调整候选框中英文切换完全交给系统输入法处理不用在 JS 里做语言判断遇到全屏、滚动、iframe 缩放这些场景时我可以自己控制透明层的定位策略不会被插件逻辑卡死。当然代价也很直接——所有坐标换算都要自己写这就是下面几节要讲的核心内容。3. 一步一步实现中文输入支持3.1 jslib 注入给 Unity 安一个“外部输入通道”Unity WebGL 的构建产物是一个 HTML5 应用你要在 C# 和浏览器 JS 之间通信标准做法是写一个.jslib文件放在Assets/Plugins/WebGL目录下。Unity 会自动把它合并进最终构建的 JS 模块里C# 侧通过[DllImport(__Internal)]声明外部方法即可调用。我先给一个最小可用的 jslib 骨架里面包含三个基础能力绑定透明输入元素、控制显隐、获取输入内容。var IMEBridge { // 初始化创建透明 input挂到 canvas 的父节点下 BindInputElement: function (canvasId) { var canvas document.getElementById(canvasId); if (!canvas) return; container canvas.parentNode; overlayInput document.createElement(input); overlayInput.type text; overlayInput.style.position absolute; overlayInput.style.opacity 0; // 透明但不隐藏隐藏会导致输入法驱逐 overlayInput.style.pointerEvents none; overlayInput.autocomplete off; overlayInput.setAttribute(autocorrect, off); overlayInput.setAttribute(autocapitalize, off); overlayInput.setAttribute(spellcheck, false); container.appendChild(overlayInput); overlayInput.addEventListener(compositionstart, function () { composing true; }); overlayInput.addEventListener(compositionend, function (e) { composing false; onCompositionEnd(e.target.value); }); overlayInput.addEventListener(input, function (e) { if (!composing) onCompositionEnd(e.target.value); }); }, // 让透明输入层获得焦点 FocusOverlay: function () { if (overlayInput) overlayInput.focus(); }, // 清空并隐藏 ClearOverlay: function () { if (overlayInput) overlayInput.value ; if (overlayInput) overlayInput.style.display none; } }; mergeInto(LibraryManager.library, IMEBridge);mergeInto是 Unity 默认的 jslib 合并方式这个写法在 2020 到 6000.x 系列构建里都能用。jslib 文件本质就是普通 JS但要注意LibraryManager.library这个内部对象是 Unity 构建系统约定好的不要动它只往里挂你的方法就行。3.2 C# 侧的事件绑定与回填jslib 写完C# 侧要做的就是三件事加载时绑定、输入框聚焦时唤起、JS 回传文本时写入。using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.UI; public class WebGLInputBridge : MonoBehaviour { [DllImport(__Internal)] private static extern void BindInputElement(string canvasId); [DllImport(__Internal)] private static extern void FocusOverlay(); [DllImport(__Internal)] private static extern void ClearOverlay(); [DllImport(__Internal)] private static extern string GetOverlayValue(); private InputField currentInput; private void Start() { #if UNITY_WEBGL !UNITY_EDITOR BindInputElement(GetCanvasId()); #endif } public void OnInputFieldFocused(InputField field) { currentInput field; #if UNITY_WEBGL !UNITY_EDITOR FocusOverlay(); #endif } public void OnInputFieldExit(InputField field) { currentInput null; #if UNITY_WEBGL !UNITY_EDITOR ClearOverlay(); #endif } // JS 侧通过 SendMessage 回调这个方法 public void ReceiveIMEComposition(string text) { if (currentInput null) return; currentInput.text text; currentInput.caretPosition text.Length; } }这里我特意把OnInputFieldExit也做好了点击 Unity 输入框之外的地方要立刻隐藏透明层并把焦点还给 Canvas否则用户想用键盘 WASD 操作游戏按下去全敲进了透明 input 里游戏直接卡死这是新手最容易被偷袭的坑。3.3 叠加层与 Unity 输入框的联动逻辑代码写完联动逻辑是决定好不好用的关键。我的建议是不要只要看到 Unity 的 InputField 获得焦点就无脑唤起透明层而是加一个开关只有标记为“允许中文输入”的输入框才唤起覆盖层比如只对昵称、聊天、搜索框开启对玩家输入的密码、ID 等非中文场景让 Unity 原生处理就行这样能少很多兼容性问题。还有一个细节值得单独说透明层的display属性不要用none来隐藏。display: none会让元素彻底脱离渲染树如果用户切换到其它窗口再切回来浏览器可能会忘记之前的状态再聚焦时输入法没法被正确唤起。更稳妥的做法是用visibility: hidden或opacity: 0同时让pointer-events: none这样元素虽然在视觉上不可见但它始终在文档流里聚焦和输入法唤起行为都正常。这个细节是我在某次做到一半时突然发现“怎么输入法偶尔弹不出来了”追了半天才定位到的。4. 输入法跟随坐标换算和 composition 处理4.1 屏幕坐标到 CSS 坐标的换算透明输入层能不能精准覆盖住 Unity 里的那个 InputField本质是坐标换算问题。Unity 的 UI 坐标系以左下角为原点Canvas 采用 Screen Space - Overlay 模式时RectTransform.position给的是世界坐标数值上等于屏幕像素坐标。而浏览器 DOM 的坐标以左上角为原点所以 y 轴必须翻转。先说最简单的情况Unity 的 Game 视图就是浏览器里 canvas 的完整显示区没有额外 DOM 遮挡scale 为 1设备像素比 devicePixelRatio 只有 1。此时换算公式是cssX unityX cssY containerHeight - unityY - inputHeight其中containerHeight是 canvas 父容器的 CSS 高度inputHeight是透明输入层自身的高度减掉它是因为 input 的定位用的是左上角而我们拿到的是 Unity 输入框矩形底边的位置。真实项目里 canvas 会被 CSS 缩放所以我在 jslib 里维护一组缓存变量每次换算前先实时读取canvas.getBoundingClientRect()把 Unity 的屏幕坐标除以 canvas 宽度、再乘以当前 CSS 显示宽度避免硬编码。给出完整换算代码大概长这样function getCanvasRect() { var rect canvas.getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, scaleX: rect.width / canvas.width, scaleY: rect.height / canvas.height }; } function setOverlayPosition(unityX, unityY, unityWidth, unityHeight) { var r getCanvasRect(); var cssX r.left unityX * r.scaleX; var cssTop r.top (canvas.height - unityY - unityHeight) * r.scaleY; overlayInput.style.left cssX px; overlayInput.style.top cssTop px; overlayInput.style.width unityWidth * r.scaleX px; overlayInput.style.height unityHeight * r.scaleY px; }注意这里用的是canvas.width而不是canvas.clientWidth。canvas.width是 Unity 构建时设置的绘图缓冲区宽高和引擎内的逻辑分辨率保持一致canvas.clientWidth是 CSS 显示宽高两者在页面被缩放时不一样。我见过有人拿后者做分母结果透明层永远对不准 Unity 输入框一查发现就是混用了两个宽度这是最容易犯的低级错误。4.2 光标级跟随别只跟输入框左上角输入法跟随做到“输入框位置对”只是及格要做到“候选框跟着光标走”才算优秀。如果透明层只是压在输入框上你在输入框中间打字输入法的候选框仍然会默认弹在透明层左上角视觉上就会错位。要做到光标级跟随Unity 侧需要给出当前光标caret所在位置的字符坐标。Unity 的TextGenerator可以拿到字符矩形我封装了一个 C# 方法在每次 InputField 内容变化或光标移动时把光标对应的屏幕坐标传给 JSprivate Vector2 GetCaretScreenPosition() { if (currentInput null) return Vector2.zero; var textGen currentInput.textComponent.cachedTextGeneratorForLayout; int caret currentInput.caretPosition; if (textGen.characterCount 0 caret textGen.characterCount) { var charRect textGen.charactersVisible[caret].topLeft; float x currentInput.textComponent.transform.position.x charRect.x; float y currentInput.textComponent.transform.position.y charRect.y; return new Vector2(x, y); } return currentInput.textComponent.transform.position; }这是进阶写法如果你的功能优先级不高也可以用简化版把透明层定到输入框左下角把 input 字号调成和 Unity 输入框相近这样候选框即使偏一点也还能忍。但一旦上了全屏2 个像素的偏移都会被缩放放大成明显错位所以我还是建议把光标级跟随做完整。JS 侧在composition事件的 diaglog 触发时实时调用setOverlayPosition即可。4.3 密码框、多行框、TMP 的特殊处理输入框形态复杂时这套方案要把特殊情况逐磨透。密码框需要把overlayInput.type改成password否则用户输密码浏览器自动带上“明文预览”和“保存密码”的鸡肋提示安全上的体验也很减分。多行输入框则应该把透明层改成textarea同时高度调成撑满输入框否则长段内容换行后全挤在一行。TextMeshPro 的处理也要单独留意。TMP 和 UI.Text 的文本生成器 API 不同用cachedTextGeneratorForLayout时需要确认你引用了正确的类型而且 TMP 对富文本标签color这类内容也会渲染到字符列表中取值之前最好调用TMP_TextUtilities.GetCursorIndexFromPosition这类方法做一次坐标反查。不管哪种形态透明输入层的字体样式尽量和 Unity 输入框保持一致特别是font-size和line-height不然输入法候选框的参考位置会不同就会有“文字输入进去之后显示偏上一个像素”的诡异感觉这种问题通常很难排查因为肉眼几乎看不出但截图对比就能发现。5. 全屏支持不是点两下 API 那么简单5.1 全屏 API 的正确姿势浏览器全屏的 API 本身并不复杂canvas.requestFullscreen()让 canvas 进入全屏document.exitFullscreen()退出document.fullscreenchange监听状态变化。但放到 Unity WebGL 场景里有三个坑必须提前处理。第一个坑是手势限制。浏览器出于安全策略requestFullscreen()必须由用户的点击、触摸等手势事件直接触发如果你在 Unity C# 侧通过UnityAction回调、或者在异步延时之后再调用 JS 的全屏方法浏览器会把请求视为“非手势触发”直接拒绝。解决办法是Unity 侧只负责标记“用户想全屏”真正的全屏调用要在 JS 侧的手势事件回调里发起或者至少让 JS 方法在同一个调用栈内完成requestFullscreen()。第二个坑是全屏元素的选择。如果你调用的是document.documentElement.requestFullscreen()整个页面包括 Unity 的 loading bar 都会进全屏副作用是页面布局可能塌掉。更推荐直接让 Unity 的 canvas 元素全屏这样它自动铺满屏幕Unity 的 Game 视图就正好占满全屏。不过注意如果 canvas 外面还有其他 UI 元素比如聊天框、角落的 log它们就看不到了遇到这种项目可以把外层的容器元素拿去全屏。第三个坑是 iframe。这点在抖音、微信小程序 webview 或第三方页游平台里尤其常见iframe 内的 canvas 想全屏父页面必须在 iframe 标签上加上allowfullscreen否则你去调requestFullscreen()控制台会安静地报一个Fullscreen permission denied用户体验就是“点了全屏键没反应”。这种问题不在自己的代码里但排查起来特别费时。5.2 全屏后输入法跟随的联动调整全屏后 canvas 的尺寸从浏览器页面的几百像素突然变成整个屏幕透明输入层的坐标基准也彻底变了。在 jslib 里监听fullscreenchange事件把之前缓存的 canvas 尺寸强制清空下次打开输入框时重新走一遍getCanvasRect()就能自动适配新尺寸。这个操作听着简单但一定要记得触发一次重新定位否则上次留下的透明层位置在全屏后的第一帧还是旧的用户如果在这个瞬间点了一下输入框候选框会从屏幕角落闪一下再跳到正确位置体验很违和。我在实际项目里还在全屏切换后干了另一件事强制把透明层隐藏一帧然后在下一次 Unity 聚焦输入框时再唤醒。理由是 WebGL 在全屏切换的瞬间浏览器会把 canvas 内部 buffer 重建这个过程中如果透明层还在 DOM 上聚焦个别浏览器会候选框闪烁甚至卡住隐藏再唤醒可以在视觉上规避这个问题。这个策略不一定对所有人的项目都有必要但保持输入层状态干净是没错的。5.3 DPR 与性能别让全屏变成幻灯片全屏后 canvas 的尺寸变成屏幕分辨率高性能显卡的机器上还好集成显卡或手机上画一个大分辨率的 WebGL 场景就是灾难帧率会直线掉。这里有两个手段配合使用。一个是设备像素比devicePixelRatio的处理。Unity WebGL 默认会按 CSS 像素和目标 DPR 来设置 canvas buffer如果你的项目对画质要求不是特别高我建议在全屏状态下手动把 DPR 上限设为 1.5 甚至 1对应到 Unity 里就是在 C# 侧调用Screen.SetResolution(width, height, false)降分辨率必要时把androidApplication相关的质量档位同步调低。另一个是锁定帧率Unity WebGL 没有直接暴力的Application.targetFrameRate完全锁死整个 pipeline但你可以在 C# 侧每隔一段时间检查canvas.width的变化在全屏状态下做一次性能采样超过阈值就把GetComponentCamera().allowHDR关掉或者降一下后处理特效。这和输入法本身没有直接关系但全屏体验是完整功能的一部分如果全屏后卡到输入法弹窗都要 3 秒才响应那用户的第一反应一定是你的功能做坏了。6. 常见问题排查实录6.1 高频问题与解决方案下面表格里是我在这套方案落地过程中真实遇到过的坑按照出现频率从高到低排列。症状根因解决办法中文输入后 Unity 输入框乱码JS 回传时文本编码不一致C# 侧按 UTF-8 读取jslib 里直接用字符串不要在 JS 里手动转编码接收端统一用Application.ExternalCall或SendMessage传字符串输入法候选框闪到屏幕左下角透明 input 没有正确聚焦或坐标没有更新检查FocusOverlay是否被调用绑定聚焦事件时用input.focus()并主动preventDefault掉 Unity 的 Click全屏后透明层位置偏了fullscreenchange没触发重定位在事件里强制置空缓存坐标下一次定位时重新取getBoundingClientRect()iOS Safari 里输入法组合结束不触发compositionend部分 iOS 版本单指拼音进入候选状态会比较特殊在 blur 事件里兜底把当前的overlayInput.value全部提交给 Unity微信内置 webview 里虚拟键盘把透明层顶到屏幕外虚拟键盘弹出后 visualViewport 高度变化监听window.visualViewport的 resize 事件重算透明层位置并适当上移iframe 嵌套项目全屏无响应iframe 缺少allowfullscreen属性让父页面在 iframe 标签上加allowfullscreen并确认requestFullscreen是接到 canvas 上6.2 几个容易被忽略的细节再补充几个不容易在测试阶段发现、但上线后一定会被用户骂的细节。透明输入层的opacity: 0在个别浏览器上会连 caret 光标一起隐藏导致用户不知道焦点在哪。我的做法是不把 opacity 设为 0而是设成一个极小值比如0.001视觉上已经不可见但浏览器的 IME 光标仍然存在输入法候选框也不会因为元素“不可见”而产生异常行为。这个技巧看起来有点取巧实测下来非常稳。如果你的项目要同时嵌入多个 Unity 实例那么 jslib 里的全局变量就是灾难。每个实例的 canvas、overlayInput 都要按实例 ID 或者 canvasId 存进一个 map 里事件回调也要带上实例标识。我在做一个计算器 H5 和一个小游戏同页展示时就被狠狠坑过一次输入法文本从 A 实例串到了 B 实例的输入框排查了很久才意识到全局变量被第二个绑定方法覆盖了。最后提醒一下不要想着把这套 DOM 覆盖层方案直接套到微信小游戏上。微信小游戏环境没有真正的 DOM 和 input 元素只能用输入法适配插件或者自绘键盘方案完全是另一个体系。曾有同事把 WebGL 版的 JS 插件原封不动搬过去构建后运行直接报错因为document.createElement在小游戏运行时环境里是不存在的。做不同平台方案要重新评估。说实话Unity WebGL 的中文输入、输入法跟随和全屏支持单独拆开看每一项都不算难但把它们组合到真实项目里要考虑的边界情况真的很多。我个人在做完这一整套方案后最大的体会是不要怕自己造轮子尤其是这种跨引擎和浏览器边界的场景理解了底层机制你才能在任何平台、任何浏览器、任何诡异的 WebView 环境下临危不乱。上面这些代码和排查记录是我在多个项目里反复验证过的直接拿去用就行遇到新问题再回来对照坐标换算和事件处理的思路基本都能找到突破口。本文还有配套的精品资源点击获取