
简介面向Unity开发者的WebGL实时通信实战资源聚焦将游戏或应用打包为WebGL格式并接入MQTT协议解决网页端3D内容无需插件运行以及与远程设备实时交换数据的问题适用于物联网可视化、在线演示等场景。内容覆盖Unity Build Settings中WebGL平台切换与导出流程、MQTT客户端库如Paho或UnityMQTT的导入与参数配置、主题订阅与消息发布等关键代码实现并附带标准Unity工程目录结构便于对照学习与二次开发。资源包共2000个文件以C#脚本、meta配置、PNG图片、DLL动态库为主另有文本说明、Shader及场景资源压缩包约295MB涵盖Assets、ProjectSettings、Packages等模块结构清晰完整。目前已有721人学习适合希望快速掌握WebGL打包及MQTT集成的初中级Unity开发者。1. Unity打包WebGL并使用MQTT先回答一个最常见的问题你刚用Unity搭好一条产线的数字孪生场景接下来要交付一个能在浏览器里演示、设备数据实时刷新的看板。能做到这件事的技术组合就是Unity打包WebGL并使用MQTTUnity负责3D渲染、交互和视觉表现MQTT负责把设备状态推送进页面。这套路线在工业可视化、物联网监控和数字孪生项目里非常常见适合那些已经在Unity里做了大量界面和逻辑、不想用纯前端重写一遍的团队。核心是理解WebGL环境和浏览器对网络协议的限制再决定MQTT这条链路怎么接。所以下文先把构建环境这层基础打好否则后面所有MQTT联调代码都没法跑。2. 搭建WebGL构建环境安装模块、Player Settings与命令行打包2.1 WebGL Build Support模块从安装到确认生效打开Unity Hub找到当前编辑器版本右侧的齿轮图标选择Add modules在弹出的模块列表里勾选WebGL Build Support点安装。这里有个容易忽略的细节模块是绑定某个具体编辑器版本的如果你同时装了2021和2022两个版本必须分别给它们装WebGL模块。装完后打开Unity编辑器进入File - Build Settings左侧平台列表里WebGL图标不再是灰色说明模块生效。如果图标还是灰色点一下WebGL图标再点Switch PlatformUnity会触发一次平台切换这一步会重启编辑器属于正常现象。不少人在unity安装阶段就卡在“安装失败:验证失败”这个提示上。这个报错多半是下载缓存损坏。我的处理方法是先退出Unity Hub把编辑器安装目录下WebGL模块对应的缓存文件删掉再重新安装。如果重装还失败直接去Unity官网的下载归档页面按编辑器版本号精确下载WebGL Build Support安装包手动运行。这个方法我用了多次基本能解决90%的模块安装问题。安装包与编辑器版本差一个Patch都可能装不上所以版本号要精确到形如2021.3.32f1的样子。2.2 Player Settings里这三处配置IL2CPP、压缩与WebSocket打包前先在Player Settings里过一遍三处配置它们直接影响MQTT能否在浏览器里跑通。第一处是Scripting Backend我会确认它设置在IL2CPP。这是WebGL平台的默认后端会把C#编译成C再转成WebAssembly。不要改成Mono否则代码行为在浏览器里和编辑器差异会被放大尤其是网络库和JSON库。第二处是Compression Format我一般选Brotli。Brotli压缩率比Gzip还小一截对Unity的.wasm和.framework.js效果明显。但这有个前提部署WebGL的Nginx或云服务器必须开启Brotli解码否则浏览器拿到压缩文件但解不开直接白屏。如果你初期用的是本地Python的http.server它默认不支持Brotli响应这种情况要把压缩设为Disabled否则本地调试会一直白屏。第三处容易被忽略WebGL默认支持WebSocket不需要额外勾选但Unity C#里的TcpClient和UdpClient在WebGL发布后是走不通的浏览器只给你WebSocket这一条通道。所以MQTT库必须能配置成ws或wss连接。后续联调时如果发现连不上第一个想到的问题应该是浏览器里有没有出现跨域拦截而不是怀疑Unity端代码写错了。2.3 用命令行打包让构建参数不再靠手点单机开发时Build Settings里的Build按钮够用。但一旦你开始调服务器端配置或给现场交付新包每次都点菜单、选目录、等进度条很容易漏。我习惯写一个Editor脚本把输出目录作为静态字段然后命令行调用。using UnityEditor; using UnityEngine; public static class WebGLBuilder { public static string outputDir build/webgl; [MenuItem(Build/WebGL)] public static void PerformBuild() { var options new BuildPlayerOptions { scenes new[] { Assets/Scenes/Main.unity }, locationPathName outputDir, target BuildTarget.WebGL, options BuildOptions.None }; var report BuildPipeline.BuildPlayer(options); if (report.summary.result ! UnityEditor.Build.Reporting.BuildResult.Succeeded) throw new System.Exception(WebGL build failed: report.summary); } }这段脚本必须先放进Assets/Editor目录命令行才能用-executeMethod找到它。命令行打包命令是这样/opt/Unity/Editor/Unity -batchmode -quit -projectPath /data/workspace/webgl-demo \ -buildTarget WebGL \ -executeMethod WebGLBuilder.PerformBuild \ -logFile /data/workspace/build.log这里-executeMethod的参数是类名加方法名Unity会在编译完Editor程序集后调用它。把输出目录写在脚本里能保证每次构建产物落到同一位置自动化工具只需要看目录里的时间戳就能判断构建是否成功。如果构建日志里出现Failed to build player去logFile里搜具体错误码。WebGL构建时间普遍比Windows长第一次构建可能超过十分钟不要误判成进程假死给构建机器留足内存建议至少16G。2.4 本地调试禁止直接双击index.html打包完成后很多人习惯双击index.html看效果结果页面一片白控制台报file://协议下的跨域错误。WebGL的产物必须通过HTTP或HTTPS访问浏览器对file协议限制了很多API。我本地调试会到构建输出目录下执行cd /data/workspace/build/webgl python3 -m http.server 8080然后访问http://localhost:8080。这个简单的HTTP服务器没有Brotli支持所以前面说的Compression Format要先设成Disabled。页面能正常打开后再接入MQTT。要注意这里的端口和MQTT的WebSocket端口最好分开比如8080访问页面、8083连接MQTT浏览器Network面板里看到两个端口的请求排查问题时一眼就能分清哪个是页面、哪个是消息通道。WebSocket握手时浏览器会发送Origin头MQTT服务器会检查这个Origin是否在允许列表里。因此本地起服务器后如果遇到403先把服务器的Allowed Origins配成http://localhost:8080打通本地环境再说公网证书的事。3. 在C#端实现MQTT客户端选库、连接、订阅与发布3.1 选库为什么是MQTTnet而不是自研SocketUnity WebGL下写MQTT客户端不是随便选库。最常用的开源库是MQTTnet但要注意版本。我一般在Unity项目里用MQTTnet 4.2系列因为它提供了对WebSocket传输的完整封装。不选更早的3.x原因是API风格差别大网上很多旧帖子容易把你带偏。更推荐自己写一个基于WebSocket的MQTT解析因为MQTT的报文编码、订阅匹配和QoS确认状态机看着简单真跑起来全是边界情况投入产出不划算。MQTTnet在Unity里的接入方式我一般用Package Manager的Add package from git URL或者直接把预编译DLL放进Assets/Plugins。这里要确认DLL在WebGL平台下没有依赖System.Net.Sockets的部分否则IL2CPP编译期会报错。如果你的项目里已经有DLL但编译不过优先换一个更新版本而不是自己去改源码裁剪。3.2 最小可用代码连接、订阅、发布下面这段放在场景里的一个空物体上就能跑通连接、订阅和上行发布。using MQTTnet; using MQTTnet.Client; using System.Threading.Tasks; using UnityEngine; public class MqttClientService : MonoBehaviour { private IMqttClient _client; async void Start() { var options new MqttClientOptionsBuilder() .WithWebSocketServer(o o.WithUri(ws://192.168.10.20:8083)) .WithClientId(webgl-demo- Random.Range(10000, 99999)) .WithCleanSession() .WithKeepAlivePeriod(5000) .Build(); _client new MqttFactory().CreateMqttClient(); _client.ApplicationMessageReceivedAsync OnMessageArrived; try { await _client.ConnectAsync(options); Debug.Log(MQTT connected); await _client.SubscribeAsync(factory/line1/#, MqttQualityOfServiceLevel.AtMostOnce); } catch (System.Exception ex) { Debug.LogError(MQTT connect failed: ex.Message); } } private Task OnMessageArrived(MqttApplicationMessageReceivedEventArgs args) { string payload System.Text.Encoding.UTF8.GetString(args.ApplicationMessage.PayloadSegment); Debug.Log($topic{args.ApplicationMessage.Topic} payload{payload}); return Task.CompletedTask; } public async void PublishControl(string topic, string jsonPayload) { if (_client ! null _client.IsConnected) { await _client.PublishStringAsync(topic, jsonPayload); } } }代码逻辑分三层Start里建连接、订阅一个主题OnMessageArrived是消息入口现在只打日志PublishControl是外部按钮回调可以调用的公开方法。这个结构足够一个看板项目起步。参数值得单独说几点。WithWebSocketServer是WebGL唯一可走的方式o.WithUri里的地址必须以ws://开头如果页面部署在HTTPS下这里必须换成wss://。ClientId必须唯一如果多个浏览器标签页用同一个IDMQTT服务端会把前一个连接踢掉我后面第4章会专门讲这个坑。KeepAlivePeriod设成5000毫秒表示按这个间隔发PINGREQ心跳。WebGL页面切后台时浏览器会冻结JavaScript定时器KeepAlive太短会在切回前台时立刻掉线太长又会让服务端误判客户端失联我一般取5到10秒。3.3 消息不能直接更新UI用主线程队列处理OnMessageArrived回调在MQTTnet内部线程触发你在这个回调里直接调用Unity API做实例化、改UI会出现随机性错误。常见做法是把它当作一个生产队列。private readonly System.Collections.Concurrent.ConcurrentQueuestring _pendingMessages new(); private Task OnMessageArrived(MqttApplicationMessageReceivedEventArgs args) { string payload System.Text.Encoding.UTF8.GetString(args.ApplicationMessage.PayloadSegment); _pendingMessages.Enqueue(payload); return Task.CompletedTask; } void Update() { int processed 0; while (_pendingMessages.TryDequeue(out string payload) processed 20) { // 在这里做JSON解析和UI更新 processed; } }逻辑要点每帧最多处理20条避免一帧短时间大量消息累计导致卡顿。如果消息本身是批量数据可以合并成一次UI刷新而不是逐条驱动场景动画。这种队列模式的好处是Whatever哪个线程触发回调最终都落到主线程Update里执行。如果某一帧队列堆积超过100条说明前端消费速度跟不上数据产生速度这时不要继续堆内存应该丢掉老数据只保留最新状态或者等待下一次批量上报。3.4 重连逻辑与Unity暂停状态WebGL页面切后台后WebSocket可能在恢复前台时才收到断开通知。常见做法是在Update里定时检查客户端状态断开就重连。private float _lastCheck; void Update() { if (Time.realtimeSinceStartup - _lastCheck 3f) { _lastCheck Time.realtimeSinceStartup; if (_client ! null !_client.IsConnected) { _client.ConnectAsync(_options, CancellationToken.None); } } }重连不能太频繁否则服务端日志会被刷屏。我在项目里会把Player Settings里的Run In Background勾选上避免页面切后台时Unity暂停主循环这样重连逻辑不会停摆。这里要小心Time.timeScale的干扰如果游戏里做了暂停菜单把timeScale设成0协程里用WaitForSeconds做心跳会一起被暂停但Update不受timeScale影响所以重连检查和消息消费都放在Update里是安全的。如果你用协程做业务逻辑注意区分WaitForSeconds和WaitForSecondsRealtime。还要注意CleanSession参数。上面代码里设成true服务端不会保留会话状态重连后订阅关系会丢失所以在重连成功的回调里要重新执行SubscribeAsync。如果设成false服务端会缓存离线消息但要求ClientId固定这和第4章里说的避免踢线矛盾。我一般选true用业务层去丢状态而不是依赖MQTT会话持久化。4. WebGL与MQTT联调避坑5条最常翻车的报错4.1 WebSocket握手返回403先查服务器Origin白名单现象浏览器Network面板里看到WebSocket请求状态码403Unity端一直报连接超时或握手失败。原因MQTT服务器开启了Origin校验浏览器握手时带的Origin头不在允许列表里。本地开发时页面跑在http://localhost:8080服务器没把localhost放行就会拒绝。解决如果你用EMQX登录Dashboard找到WebSocket配置把Allowed Origins设成你的页面域名本地调试直接允许http://localhost:8080。用Mosquitto则需要在配置文件里加上对应的cors头参数。改完重启服务器再试。这个坑在联调第一天几乎必踩提前配好能省一小时。4.2 页面白屏但构建日志正常压缩格式与本地HTTP服务器不匹配现象Unity构建没报错但打开页面纯白控制台有一堆解析失败。原因Player Settings的Compression Format选了Brotli而本地Python http.server或某些旧Nginx没有配置Brotli解压浏览器拿到的仍是压缩文件自然加载不出Unity的wasm。解决本地调试阶段把Compression Format改成Disabled重新打包。等部署到正式环境确认Nginx开了brotli模块并且Content-Encoding响应头正确再改回Brotli。打包后也可以先看一下响应头没有Content-Encoding: br就说明没生效。4.3 编辑器一切正常、打包后收不到消息多半是TLS协议不匹配现象Unity编辑器里用TCP连接MQTT完全正常打进WebGL后用ws://连本地服务器也能通但一部署到公网页面收不到任何数据。原因浏览器有混合内容限制。页面如果通过HTTPS打开脚本里用ws://发WebSocket请求会被浏览器静默拦截控制台可能只留一条警告不仔细看就漏掉。MQTT服务器如果是裸ws且页面是HTTPS链路直接断。解决页面是HTTPS时MQTT必须走wss://服务器要配置TLS证书。这对应很多项目里“unity打包部署需要ssl”的疑问不是Unity本身要SSL而是浏览器的安全策略要求所有网络通道都加密。配置好后把Unity端地址从ws://换成wss://端口通常是8084EMQX默认WSS端口重新打包验证。4.4 两个标签页互相踢下线ClientId重复导致现象同一个浏览器开两个标签页第二个页面打开后第一个页面的数据全部停掉过一会儿第二个页面也开始掉线。原因两个页面用了相同的MQTT ClientId。MQTT协议规定同一ClientId只能有一个在线连接后连接的会把先连接的踢下线。解决ClientId加随机后缀。我习惯用时间戳加三位随机数拼在固定前缀后面例如webgl-demo-1712345678-482。客户端断电重连也一样每次连接生成新ID避免服务端残留会话占用连接。4.5 运行几个小时内存飙升高频MQTT消息加粒子实例泄漏现象页面刚打开时内存正常连续跑三四个小时后占用持续增长粒子特效变卡最后页面崩溃。原因MQTT消息驱动场景里的粒子特效或实例化对象反复创建但销毁逻辑只做了SetActive(false)没有真正释放Texture和Material。WebGL的IL2CPP运行时有自己的一套内存管理跟编辑器行为差异明显实例引用被缓存住GC回收也不积极。解决用对象池管理设备状态变化触发的特效关闭时回收进池而不是重新Instantiate。在OnDisable和OnDestroy里显式释放Texture、RenderTexture资源必要时调用Resources.UnloadUnusedAssets。同时控制消息消费频率不要把每一条设备上报都驱动一次粒子播放可以做阈值过滤状态变化超过一定幅度再触发视觉反馈。这个坑不是MQTT库本身的问题是WebGL环境下资源管理比编辑器严格得多。5. 服务器端搭建与Topic设计把设备数据和Unity端对得上5.1 选型EMQX还是Mosquitto先看WebSocket支持本地开发选MQTT服务器我推荐直接从EMQX开始理由是它对WebSocket的支持开箱即用。Mosquitto默认只监听1883 TCP端口如果要支持WebSocket得额外编译或启用插件对新手不友好。我用一个表格做个对比对比项EMQXMosquittoWebSocket支持默认开启8083端口需要配置插件可视化Dashboard有端口18083无Origin跨域配置页面操作简单文本配置容易写错资源占用偏重但开发机无感非常轻量适合场景多数WebGL项目纯内网设备接入如果只是在一台Linux机器上做内网测试两种都行。但考虑到后面要调TLS证书、检查订阅关系有Dashboard能省很多事。5.2 Docker部署EMQX一条命令把WebSocket端口全打开docker run -d --name emqx \ -p 1883:1883 \ -p 8083:8083 \ -p 8084:8084 \ -p 18083:18083 \ emqx/emqx:5.6.1端口含义1883是TCP MQTT给Unity编辑器联调用8083是WebSocket给WebGL页面用8084是WSS加密通道公网HTTPS页面用18083是Dashboard管理界面。启动后访问http://localhost:18083默认账号admin密码public进入Dashboard后第一件事是在配置里加入Allowed Origins。如果现场有防火墙只放行页面域名需要的端口别把1883暴露到公网否则scan工具很容易盯上。5.3 Topic结构设计设备状态、控制指令与告警分离数字孪生项目里Topic设计决定你后面写维护性代码的幸福感。我一般按产线和设备层级分三层factory/{lineId}/{deviceId}/state 设备状态上行QoS 0 factory/{lineId}/{deviceId}/ctrl 控制指令下行QoS 1 factory/{lineId}/alarm 产线告警QoS 1 Retain这个设计的逻辑是状态数据量大、丢失一帧影响不大用QoS 0控制指令要求至少到达一次用QoS 1告警要保留最新值Retain标志让它持久化在服务器里新页面打开后立即拉到最近一次告警状态。如果你把设备状态也设成Retain大批设备会占用服务器内存不建议。Unity端订阅时可以用factory/line1/#这样的通配符一条订阅拿到整条产线的所有设备和告警。如果线上有多条产线在代码里按Topic格式解析出lineId和deviceId再映射到场景里对应的设备对象。5.4 数据格式约定JSON字段少一层解析快一倍Unity WebGL端解析JSON我建议用JsonUtility而不是Newtonsoft.Json。JsonUtility的问题是缺字段时会留默认值不会像Newtonsoft那样给异常这反而适合物联网状态同步。代价是它不支持字典所以数据模型要用数组。{ dev: pump-01, ts: 1712345678, val: 36.5, st: 1 }字段尽量短val和st这种缩写能显著降带宽尤其在公网环境。嵌套层数不要超过两层否则JsonUtility反序列化时要多写很多中间类。如果设备上报字段太多在网关或服务器端做一次扁平化转换再推给Unity不要在Unity端做复杂的动态解析。5.5 Unity端给485设备发指令靠网关做协议转换“mqtt如何给485设备发指令”这个问题本质是MQTT不直接接触串口而是通过协议网关中转。Unity发布的是一条JSON命令到一个控制Topic网关上跑的程序订阅这个Topic把JSON翻译成写寄存器指令再通过串口或网口发给485总线上的设备。[System.Serializable] public class ModbusCmd { public string id; public int reg; public int val; } public void SendModbusCommand(string deviceId, int registerAddr, int value) { var cmd new ModbusCmd { id deviceId, reg registerAddr, val value }; string payload JsonUtility.ToJson(cmd); _client.PublishStringAsync(factory/line1/gateway/ctrl, payload); }这里Unity端只关心协议网关能听懂的消息格式。网关可以是工业DTU、边缘计算盒子或者一台装了Modbus网关软件的工控机。项目现场如果已有PLC往MQTT服务器上推数据Unity端订阅对应Topic就行改动最小。6. 打包后的验证流程与性能优化在浏览器里看数据6.1 验证三件事连接、订阅、消息到达页面打开后先看EMQX Dashboard的Client列表里有没有出现你刚启动的连接连接数增加说明握手成功。再点进连接详情看Subscriptions列表确认订阅主题符合预期。最后在Dashboard的监控页往state主题推送一条测试消息观察Unity场景里对应设备状态是否变化。这三步走完链路基本是通的。如果设备状态没变再看浏览器控制台的日志输出Unity的Debug.Log在WebGL下会映射到console.log这就是排查黑匣子的窗口。6.2 用Chrome Performance定位GC问题打开Chrome开发者工具的Performance面板在页面运行时录制一行。重点看Script部分的长任务和GC次数。如果发现频繁GC检查是否每帧都在new字符串或byte数组。MQTTnet收消息时PayloadSegment是数组片段转字符串会分配内存消息频率越高GC越明显。优化做法是改成定长缓冲区接收或用StringBuilder做拼接。6.3 包体与跑批优化Brotli与批量刷新正式发布时Compression Format开Brotli配合Nginx的brotli模块包体能降到原始体积的三分之一左右。如果页面同时运行多台设备的动画消息不要逐条驱动模型变换我习惯按50ms批次聚合一次状态刷新。连续跑一周看内存曲线是否平滑这比我口头承诺“没问题”有用得多。如果只让我给一条经验我会说WebGL页面把MQTT消息当作输入流不要当作UI事件源。所有消费逻辑都放到主线程队列所有资源都用对象池管理剩下的事情就是等数据和看曲线了。希望帮到你。本文还有配套的精品资源点击获取