最近在折腾一件有点执拗的事:能不能不覆盖安装版 UE 的原始文件,只靠插件和配套 SDK,把 UE5.8 工程编译成浏览器里真正运行的游戏?

不是 Pixel Streaming,也不是把场景导出来交给另一个 JavaScript 引擎重演。目标是让 UE 自己的对象、地图、材质、动画和输入继续工作,只把底下的执行环境换成 WebAssembly,把图形接口接到 WebGPU。

一开始看起来像“给打包菜单加一个平台”。做下去才发现,菜单是最薄的一层,底下是编译器、32 位数据布局、Cook、Shader、RHI 和浏览器生命周期一起搬家。

UE5.8 到浏览器的技术路线:C++ 编译为 Wasm,UE Shader 转为 WGSL,资源经 Cook 和 Pak 进入 UE Runtime 与 WebGPU RHI

上图是按实现整理的架构示意,不是游戏运行截图。本文依据 UEWebGPU 技术档案截至 2026-09-06 的记录整理;下文的测试数字来自对应研发记录,这次写文章没有重新编译或运行测试。

先说结果:跑通了模板,但还不是通用 Web 平台

当前插件针对固定的 UE5.8.0 / CL55116800。记录中,三个经过依赖审查和 Web 配置准备的官方模板,已经完成真正的 Wasm 编译链接、fresh Cook、Pak、Stage,并在 Edge 中加载原地图、执行基本玩法。

  • FirstPerson 保留原 Blueprint 移动和跳跃,实际 W、Space 输入能改变原 Pawn 的位置与 falling 状态。
  • ThirdPerson 保留原角色、动画和移动跳跃路径,不是用 JS 拖动一个模型。
  • TopDown 保留原点击导航、动画和点击标记所需依赖。

这里的限定词不能省略。模板关闭了经批准的无关默认插件,Web 渲染配置也与 Windows 默认配置不同;不能推导成“任意现有 UE 工程直接一键导出”。最新修复还没有纳入一次完整的新 SDK 封存,Shipping、独立 Chrome 验收和新机器上的离线构建也没有全部完成。

不过,它已经越过了单纯画一个三角形的阶段。接下来值得拆开的,正是这些中间层怎么连起来。

第一层:先做 Wasm SDK,再谈日常打包

Windows 的 .lib.dll 不能换个链接器就变成 Wasm。当前做法是把 SDK 制作和项目打包分成两件事:

SDK Forge
  UE runtime source + platform overlays + target libraries
    -> private UBT / fixed Emscripten
    -> Wasm .a + generated headers + ABI / link manifests

Project packaging
  project + portable source plugins
    -> UHT -> project/plugin objects
    -> link precompiled Engine .a
    -> game.js + game.wasm

Forge 首次构建的是游戏运行所需的模块依赖集合,不是整个 Editor、全部平台和全部插件。日常打包再消费这些归档,新工程与源码插件仍然要跑真实 UHT,生成并编译自己的反射代码。

基础库分成 Core、CoreUObject、RHI、RenderCore、EngineRuntime 五组。当前 Development 清单记录了 163 个模块、410 个合并编译对象,归档总大小约 1.09 GB;对象数受 Unity 编译影响,不能当作源文件数。

一个很实用的拆分是:把正在迭代的 WebGPURHI 留在基础 Engine 归档之外。 修复纹理绑定、输入或提交逻辑时,可以只重编插件并重新链接,不必每次把整组运行库再造一遍。

但可复用 SDK 也不只是几份 .a。它还需要匹配的公开头与生成头、UBT Rules、第三方头文件、宏、库顺序和平台配置。实际遇到过 Eigen 无扩展名头被遗漏的情况:按 *.h 打包一个“头文件 SDK”,看起来很完整,到了消费工程照样缺文件。

这里更像是在维护一个完整编译契约。UE CL、工具链、线程选项、宏、生成头和目标 ABI 必须一起对得上。

第二层:不改原引擎文件,不等于不需要底层适配

当前选的是 wasm32 + pthread + SIMD128。首先撞到的是 UE5.8 发行版的 64 位假设:指针断言、指针高位打包标志、任务对象布局、容器大小转换,都不是加一个 PLATFORM_WEBGPU 就能解决的。

处理方式是插件内的 source / VFS overlay。先核对原文件哈希和预期片段,再生成适配副本,让目标编译使用新的源码视图。安装目录里的原始文件不被覆盖;对不上版本就拒绝,而不是硬套补丁。

这些地方必须真的改布局、重编受影响的 Wasm 模块。链接器的 --wrap 适合替换应用工厂、键码映射等明确的函数边界,不能修复已经错误的对象内存布局。

宿主侧也有类似分工:菜单、目标平台、ShaderFormat 尽量走公开扩展;静态 Shader 平台名称注册、部分 Cook 压缩行为没有合适公开入口时,才使用固定 CL、明确范围的窄适配。它们不是跨 UE 版本稳定的插件 API,升级需要重新审查。

另一个容易混淆的点是:不需要重编整个 Win64 Editor,不代表不需要任何原生编译。 Cooker 要加载新项目和源码插件的类、序列化器,仍可能需要匹配安装版 BuildId 的原生 Editor 模块。宿主 DLL 与浏览器 Wasm 是两个不同目标,各自有自己的依赖集合。

浏览器线程不是原生线程的透明替身

页面主线程负责 DOM、用户手势和可见 Canvas;UE 的 EngineTick 与 RHI 调用进入应用 pthread worker。接收 OffscreenCanvas 的那个 worker 要创建自己的 Adapter、Device,并维护对应 Emdawn 对象表。

Page: DOM / user gesture / keyboard / mouse
  -> OffscreenCanvas + input proxy
  -> UE application worker
       GPUAdapter / GPUDevice / Emdawn object table
       ApplicationCore -> EnhancedInput -> original Pawn
       EngineTick -> WebGPURHI

页面上的 Module 与 worker 的 Module 不是同一个对象环境。主线程创建设备,再让 worker 通过另一张对象表引用它,会得到错误句柄。应用循环也需要让出控制,等待 WebGPU 的异步完成回调,不能套一个永不返回的原生 while。

当前还明确关闭了独立 render thread 和 RHI thread。因此“启用了 pthread”不能写成“桌面版多线程渲染模型已完整移植”。

第三层:真正困难的是 UE Shader 到 WGSL 的整条链

单独把一段 HLSL 转成 WGSL,只能证明转换工具能工作。要画 UE 的原场景,还要让材质 permutation、参数映射、Uniform 布局和 ShaderArchive 全部贯通。

模板现在使用的是正常的 UE ShaderFormat / Cook 路径:

USF / USH / material permutations
  -> UE preprocessing + WebGPU compatibility transforms
  -> Vulkan ShaderFormat frontend / DXC
  -> SPIR-V + UE reflection
  -> SPIR-V normalization -> pinned Tint -> WGSL
  -> WebGPU shader header + UE parameter/resource tables
  -> cooked ShaderArchive -> Pak
  -> WebGPURHI layout / pipeline / draw or dispatch

这里复用的是宿主侧的 Vulkan 编译前端,不是在浏览器里运行 VulkanRHI,也不是退回 WebGL。ShaderFormat 版本还进入 Cook 缓存身份;反射格式变了,不能继续拿旧归档当新结果。

RHI 需要精确知道资源是什么:纹理是 float、不可过滤 float、整数还是 depth,Sampler 是过滤还是比较采样,StorageBuffer 到底只读还是可写。只传一段 WGSL,不足以重建 UE 的绑定语义。

因此 cooked header 会记录 binding、资源类型、Uniform 布局、入口和 InOutMask,同时保留原来的 UE 参数表。后者曾直接解决一个真实问题:Pixel Shader 没有向某个 MRT 输出时,不能仍然为那个颜色附件打开写入。

没有 Pixel Shader 的 stencil draw、Cube UAV 的六面范围、normalized16 数据纹理的 sample type,也都需要按真实调用补齐。浏览器验证器报错往往不是“太严格”,而是迫使这一层把原来含糊的假设说清楚。

我的理解是,这一层的原则应该是:支持的语义真的实现,暂不支持的语义明确失败。 不能用默认纹理、假读回或者返回成功的空操作,把错误一路推到“能看见一帧”。

第四层:能出图之后,先解决为什么过一会儿就掉设备

最初模板能显示,不代表能稳定运行。一次关键排障的日志顺序是:

ID3D12Device::CreateHeap ... OutOfMemory
Device is lost
Instance dropped in popErrorScope

最后一句很容易把注意力引向 JavaScript 对象回收,但前面的 GPU 内存分配失败才是这次事件更直接的线索。当时 RTX 4070 SUPER 的总显存使用已接近 11.7 / 12 GiB,同时打开多张重型 Wasm 页面又叠加了压力。

修复包括正确退役资源,并把 GPU 在途帧数限制为两帧。下面只是解释控制流的伪代码,不是可直接替换的源码:

if (DeviceLost)
    StopSubmitting();
else if (FramesInFlight >= 2)
    YieldUntilWorkDone();
else
    TickAndSubmitEngineFrame();

// Asynchronous completion, not a busy wait.
OnSubmittedWorkDone([] {
    RetireCompletedResources();
    --FramesInFlight;
});

单纯 release 一层 wrapper,不一定能及时退还驱动分配;反过来,也不能提前 destroy GPU 仍在使用的对象。资源生命周期和异步队列必须一起处理。修复后有 TopDown 约 18 分钟、3600 多帧未报告 GPU 失败的记录,这是对应场景与时长的证据,还不是无限耐久保证。

性能优化最先找到的不是 Shader,而是重复工作

每次 draw 都复制 WGSL、分析资源访问、构造 specialization、计算 hash 和 PSO key,CPU 自然很忙。这些不可变信息后来进入 FWebGPUShaderData 缓存;实际纹理句柄和每帧参数仍然重新匹配,避免缓存出旧画面。

Uniform 也有类似问题:UE 已经上传过原生 UniformBuffer,适配层又每次分配、上传一份。复用后减少了开销,但提交顺序必须保留:

writeBuffer(A) -> submit(draw using A)
writeBuffer(B) -> submit(draw using B)

如果为了合批变成先写 A、再写 B、最后一起提交,两次 draw 可能都读到 B。下一步要做批量提交,得先设计每帧 arena 或资源版本,而不是只把 submit 往后挪。

另一个很朴素的遗漏是工具链优化参数。给项目 / RHI 编译及最终链接补上优化级别后,FirstPerson 的历史 Wasm 从约 399 MB 缩到约 188 MB。基础 Engine .a 仍被复用,这并不代表所有引擎源码都重新做过 O2 编译。

第五层:光照和资源,必须从 Cook 一直修到运行时

Web 配置不支持 Lumen,早期直接关闭动态 GI 后,原本依赖间接光的模板会显得很暗。处理方向不是把阴影颜色调浅,而是接回受支持的 UE SM5 SSGI 路径。

[SystemSettings]
r.DynamicGlobalIlluminationMethod=2
r.SSGI.Quality=2

只改这两个值还不够。平台能力必须声明 bSupportsSSDIndirect=true,重新 Cook 所需 permutation;RHI 还要真正实现历史纹理 copy。缺少 FSSDCompressMetadataCSRHICopyTexture 时,加一个运行时 CVar 不会自动补全整条链。

SkyLight 排查也有个教训:实时捕获的数据在 GPU 上,CPU 侧 SH 为零不等于天空光没工作。对多个真实 irradiance buffer 做 staging copy、MapAsync 和 fence 读回后,既发现了默认零缓冲,也找到了非零的活动天空缓冲。只检查第一个,结论就会错。

现在能说的是恢复了真实 SSGI、补齐了相应 copy,并观察到有效的 GPU 天空光数据。SSGI 不是 Lumen,非零 SH 也不等于光照完全一致。 Web profile 还调整了曝光等设置,并关闭了 Nanite、VSM、硬件光追、bindless 和部分高级路径,不能拿不同配置的画面直接宣称像素等价。

资源方面则有另一条容易忽略的链:Pak 压缩和资产内部压缩不是同一回事。外层 Pak 使用 Zlib,不会让里面的 Oodle 数据自动变成可读。

ControlRig 层级就曾在 Cook 后留下 Wasm 不支持的 Oodle 字节。适配限定在 WebGPU Cook 的原 Save 调用范围,让它进入引擎已有的 raw fallback,写出原始层级,而不是删除 Rig、替换动画或伪造解压成功。ShaderArchive 的 raw 审计、导航数据的 Zlib 配置,也分别处理自己的压缩层。

最后才是“打包按钮”:把各层收束成可重复的流水线

现在已审查模板使用的顺序是:

Validate project / version / settings
  -> Build and link Wasm
  -> Fresh WebGPU Cook
  -> Shader and resource audit
  -> native UnrealPak: Zlib / optional AES
  -> generate page and stage files
  -> publish while retaining the previous package
  -> launch and verify separately

fresh Cook 指新的 Cook 输出,可以复用 DDC;编译显示 0 actions 也可能只是代码没有变化,不代表后面的 Cook、Pak 和 Stage 没执行。相反,外层 shell 返回 0,也不能覆盖内部 Cooker 的失败退出码。

配置读取复用 UE 原生 ConfigCache.ReadHierarchy 与 Crypto 解析,不另写一套近似 INI 规则。请求了尚未支持的 IoStore、分块安装、签名或未资格化构建配置时,在编译前拒绝,比静默忽略设置更可信。

最后一份 FirstPerson 回归包在中文与空格目录下完成。记录中的 Wasm 为 188,433,326 字节,Pak 为 83,820,718 字节;启用全资源和索引 AES,原生 UnrealPak -Verify 检查了 1185 个文件,之后在 Edge 里加载原地图并验证输入。

加密走 UE 自己的 Pak 路径,私有 Crypto 缓存不进入发布目录。不过浏览器最终必须能解密,内嵌的客户端密钥不能当作保密或防破解保证。这是打包功能验证,不是 DRM 承诺。

浏览器启动也属于交付的一部分

包里的 LaunchWeb.exe 从自身目录启动 loopback HTTP 服务,再打开页面,而不是让用户直接用 file:// 打开 HTML。服务为 threaded Wasm 提供 MIME 和隔离头:

Content-Type: application/wasm
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

当前加载器会把整个 Pak 下载到内存文件系统,再让 UE 挂载。服务器虽然支持 Range,客户端却还不是按需流式读取,也没有完成 IndexedDB 持久缓存。83.8 MB 的 Pak 加上 Wasm 内存、下载缓冲与解压开销,启动预算仍然不轻。

原生编辑器菜单已经通过 ToolMenus 接入“平台 → 打包项目”,编译与菜单注册自动化通过,通知、日志和取消也有实现。不过顶部统一打包按钮的路由和真实鼠标全流程还没完成验收。与菜单相同的 CLI 打出有效包,是一份证据;完整 UI 操作通过,是另一份,不能合写。

怎样评价这次进展

报告里最后的性能记录来自 RTX 4070 SUPER、Edge、固定内部 1280×720、单个活动测试页面且没有后台构建的条件:FirstPerson 约 35–38 FPS,ThirdPerson 约 28–34 FPS,TopDown 约 32–35 FPS。它们是 Engine tick 统计样本,没有受控原生 A/B 或完整 p95 / p99,不能包装成稳定 60 FPS,也不能和早先三页面并发的数字计算总加速倍数。

比帧率更重要的是验证路径变长了:从编译一个 Wasm、清一次 Canvas,到正常 UE GlobalShaderMap / Cook / RHI 的 256 / 256 GPU 数值读回,再到三张原模板地图和真实输入。每一步证明不同的事情,“链接成功”没有被用来替代“游戏运行成功”。

接下来最需要补的也很具体:最新 SDK 输入统一与重新封存;全新普通工程和源码插件的完整消费流程;Shipping 与跨浏览器矩阵;音频、网络和手柄的实际 UE 调用;资源流式加载;长时间运行与设备丢失恢复。

还有一项文档债:最终浏览器截图当时只在会话里查看,没有导出到本地。持久化报告保留了观察,但下一轮应该按包 hash 存下截图、完整 console、输入步骤和帧时间数据。可追溯的结果,比一张没有构建身份的漂亮截图更有用。

折腾到这里,我觉得这件事最有意思的地方不是“UE 能不能出现在网页里”,而是:当原来的平台假设逐层失效,能不能用真正的目标实现把它们重新接起来,同时把没实现的部分留在明面上。

现在可以说,特定配置下的完整模板链已经工作;离一个即插即用的通用平台,还有一段值得继续拆的工程距离。

资料口径

本文根据项目 Docs/UEWebGPU 中的架构、SDK、渲染、打包和验证档案整理,重点参考 shader-pipeline.mdperformance-and-device-loss.mdgates-and-template-results.mdpak-encryption-and-layout.mdremaining-work.md。状态截止 2026-09-06,封面为整理时绘制的流程示意。

没有把私有 SDK、Engine 源文件、构建响应文件、Crypto 缓存或本机路径作为文章附件。本文总结的是这次实验的实现和证据范围,不是 UE 官方平台支持声明。