说实话在把 v0.5.0 的发布说明发出去之前我纠结了很久。如果只写修复了若干个 bug、新增某某模块那完全体现不了这一版的真实分量可要是一上来就聊架构、聊生态、聊理念又怕把实际用户吓跑。几轮讨论下来最后只用一句话定了调EVEngine v0.5.0 正式发布它不再只是一个游戏运行时而是开始变成一个可组合创作平台。这句话听起来像产品口号但它是实话。过去两年EVEngine 的主要角色是游戏运行时给你一个循环、一个场景、一套实体组件系统让你把游戏跑起来。v0.5.0 之后它更像一个积木盒子核心只负责定义组合规则渲染、输入、资源、脚本、UI、音频全部变成可插拔模块第三方也可以通过统一插件协议往里面加东西。这篇文章我想把这次重构的来龙去脉、技术选型、迁移过程和踩坑记录完整写一遍给准备升级或者正在考虑要不要入坑的朋友做一个参考。1. 为什么必须离开游戏运行时的舒适区1.1 旧架构的三个硬约束先说清楚搞这次重构不是版本号没东西写了强行刷存在感。旧架构真到了不改不行的地步。v0.4.x 时代整个内核是一个典型的游戏运行时结构一个主循环负责 update 和 render一个 Scene 对象管理所有实例一套 Entity-Component 系统组织游戏对象。这套模型在做游戏这个假设下是非常顺手的但它几乎把所有用户都绑死在了三个硬约束里。第一个约束是生命周期绑定单一循环。运行时启动之后你就只能接受它每帧 tick 一次的模式。做游戏没问题但你想做数据可视化大屏、交互展览、工具原型的时候这个循环就变得很碍事。你可能根本不需要每秒 60 次的 update你只需要数据到达的时候刷新一下界面。可旧架构不支持你关掉循环关了整个引擎就不工作了。第二个约束是场景即世界的假设。旧版本里所有资源、实体、脚本都挂在一个场景下面离开场景就没有世界。想做多窗口、多画布、或者一个进程里同时跑两套完全独立的交互逻辑几乎不可能。场景这个概念本身是游戏领域的抽象不是通用创作领域的抽象。第三个约束是编辑器与运行时高度耦合。编辑器是作为运行时的一部分存在的运行时里塞了一堆编辑器才需要的状态管理代码发布到浏览器和桌面端的时候这些代码根本用不上白白增加包体。这三个约束放在一起导致一个结果只要你的需求稍微偏离传统游戏你就得钻进引擎源码里改。短时间能改长期看每多一个用户这么干维护成本就翻一倍。1.2 来自使用者的真实反馈推动方向修正v0.4 发布之后一些使用者给了很多有价值的反馈其中几条直接推动了这个版本的架构调整。有个开发者用 EVEngine 做内部数据展示平台他反馈说我其实只需要把 canvas 画出来、能响应点击事件、能加载 JSON 数据结果我被迫学习关卡、场景、实体这些概念。游戏循环开着不用心理上很难受代码结构也被迫按游戏逻辑组织。还有一位做硬件交互原型的朋友想把自己写的渲染逻辑挂在物理更新之后而不是脚本更新之后。旧版内核没有开放这个中间层他只能改源码重新构建。他原话说的是如果内核能把生命周期分得再细一点我根本不需要维护一个 fork。另一位刚毕业的学生做交互毕业设计想用 EVEngine 但完全不想用内置物理系统想要一种方式能把内置的某个模块替换掉。在旧架构里这很难因为物理模块被埋在引擎内部不是独立包。这几类反馈指向同一个方向引擎不应该假设你正在做游戏它应该假设你要构建一种可交互的体验。体验的载体可能是游戏可能是工具可能是演示文稿也可能是某个硬件产品的控制台。引擎的任务是提供组合这些体验的能力而不是替你把体验的类型定死。1.3 定位转变从运行引擎到创作基座于是 v0.5.0 的产品定位发生了根本转变。我们不再把 EVEngine 描述为游戏运行时而是定义为可组合创作平台。这个定位转变带来三条设计原则后面所有重构都是围绕它们展开的。第一不强制生命周期。引擎提供一套生命周期状态机但模块可以只关心自己需要的那一段。比如渲染模块只需要注册渲染钩子资源模块只需要在初始化阶段加载清单不需要参与每帧 update。第二模块即插即用。渲染、输入、资源、脚本、音频、物理、UI、网络被拆成独立模块每个模块有独立的版本号和生命周期。用哪个、不用哪个由用户在 createApp 时声明决定。第三接口输入输出化。模块之间不再通过私有方法互相调用而是通过统一的事件和消息接口交换状态。这样任何一个模块被替换掉其他模块不需要改代码。简单来说平台只做两件事提供组合的语法管理组合后整体的运行。至于你组合出来的是一个游戏、一个编辑器、还是一个数据可视化工具那是使用者和模块的事情不是引擎的事情。2. 内核重构核心模块化边界、组合式 API 与生命周期模型2.1 模块边界怎么划模块边界是这次重构最花时间的设计决策。我们最终确定按照能力域划分而不是按照技术层次划分。什么意思如果按技术层次会把引擎分成底层层、中间层、应用层这是传统分层架构的思路。但分层架构很容易出现底层模块为了支撑所有上层而膨胀的情况和拆模块的目标背道而驰。按能力域划分则是把渲染能力、输入能力、资源管理能力这些他们各算一个域每个域自己管理自己的技术栈。最终 v0.5.0 的官方模块划分如下表模块名能力域主要职责默认加载evengine/core内核组合规则、生命周期、事件总线、插件协议是必需evengine/renderer-2d渲染Canvas 2D 渲染、图层合成、绘制指令批处理否evengine/renderer-3d渲染WebGL 渲染、模型加载、材质与光照否evengine/input输入键盘、鼠标、触摸、游戏手柄统一事件否evengine/asset资源资源清单、加载、缓存、打包资源解析否evengine/script脚本行为脚本运行时、状态管理与生命周期绑定否evengine/ui界面基础 UI 组件、布局、样式解析否evengine/audio音频音频播放、音量控制、音频资源绑定否evengine/net网络WebSocket、HTTP 请求、消息同步否evengine/editor编辑工具可视化编辑宿主、组件面板、调试器否每个模块都有自己的状态机installed已安装、initialized已初始化、started已启动、stopped已停止、disposed已销毁。模块状态转换由内核协调但每个模块内部怎么响应这些状态完全由模块自己决定。这样设计的好处是一个模块的崩溃或替换不会拖垮整个系统。比如音频模块初始化失败并不会影响渲染和输入模块继续工作只是全局状态里多了一个audio: failed的标记。2.2 组合式 API入口只有一个 createAppv0.4 时代有一个全局的 Game 单例所有地方都 import 同一个对象。老开发者可能已经习惯了但全局单例有一个致命问题是多实例隔离。你在同一页面想跑两个互不干扰的交互面板就得想办法造两个命名空间很别扭。v0.5.0 里入口收敛成了一个函数createApp。你通过传入 modules 数组声明需要哪些能力通过 plugins 数组挂载第三方插件。这是标准的组合式 API 风格相比继承式 API 有两点明显优势显式依赖代码里能一眼看到这个应用到底用了什么容易替换想换渲染后端把 renderer-2d 替换成 renderer-webgl 就行其他代码不用动。一个最小应用的写法如下import { createApp } from evengine/core import { input } from evengine/input import { renderer2d } from evengine/renderer-2d import { cursorFollow } from ./plugins/cursor-follow const app createApp({ modules: [renderer2d, input], plugins: [cursorFollow({ speed: 2 })], canvas: document.querySelector(#main), }) app.start()这个写法看起来和很多前端框架的初始化很像但在游戏引擎领域其实是个不小的转变。以前你要先理解 Game、Scene、Layer、Entity 这一串概念才开始写代码现在你只需要知道我要渲染我装渲染模块我要响应输入我装输入模块我要额外行为我用插件。同时 createApp 返回值是一个 App 实例不是全局单例。你在同一个页面可以 create 多个 app各自拥有独立的事件总线和模块实例。2.3 模块之间只说事件事件总线的取舍模块拆开了通信就成了第一难题。v0.5.0 选择让模块之间通过统一的事件总线通信而不是直接调用对方的方法。事件按照域:动作的格式命名比如input:keydown、asset:loaded、render:prerender、render:postrender、script:started。任何模块和插件都可以监听和发布事件。举一个实际的事件链例子用户按下空格键输入模块发布input:keydown:Space脚本模块监听到之后驱动角色跳跃逻辑角色状态更新后发布logic:updated渲染模块监听logic:updated再执行绘制。整个过程中输入模块根本不知道脚本模块存在脚本模块也完全不需要了解渲染模块怎么处理画面。app.events.on(input:keydown:Space, () { player.jump() app.events.emit(logic:updated, { entity: player.id, action: jump }) }) app.events.on(logic:updated, (data) { renderer.drawEntity(data.entity) })这里有一个值得注意的技术取舍v0.5.0 的事件分发改成了异步队列而不是旧版同步立即分发。这样做可以避免事件监听器在分发过程中修改事件对象导致不可预期的连锁反应代价是同一帧内的事件顺序不再有严格的时序保证。后面迁移部分我会详细讲这个坑。2.4 生命周期从一个大循环到一组状态机旧版本的生命周期非常简单启动、每帧更新、停止。新版把它拆成了模块状态机加全局状态机的组合。全局状态机仍然是启动、运行、停止但每个模块和插件都有一段自己的生命周期。模块可以在 installed 阶段做资源预检在 initialized 阶段加载配置在 started 阶段才真正开始产生副作用。插件协议里的 install 钩子则挂载在初始化之前。对比起来v0.4 和 v0.5 的运行模型差异非常明显维度v0.4v0.5入口全局 Game 单例createApp() 实例模块全部内置不可拆卸modules 数组按需组合生命周期单一 update/render 循环每个模块独立状态机能力扩展自定义组件/修改源码use() 插件协议 模块替换多实例不支持需要 hack原生支持这个表格基本概括了这次重构的外在表现。总结成一句话就是以前你得到一个预装好的厨房所有电器都焊死在台面上现在你只拿到一个标准的灶台接口想装什么电器自己往上面卡不想用的部分完全可以不带回家。3. 亲手迁移一个 2D Demo全程实操与五个真实踩坑点3.1 迁移前先盘点别急着动手架构拆了API 必然动迁移是每个用旧版的人都躲不开的活。我拿一个内部 2D 横版 Demo 做了全程迁移先把经验写出来。第一步不是写代码是盘点。把这个 Demo 用到的能力全部列出来。我们这个 Demo 用的是Canvas 2D 渲染、键盘输入、脚本驱动的角色行为、少量 JSON 配置资源、背景音频。旧代码里主要依赖 Game、Scene、Entity、Component、AssetLoader 这几个顶层 API。盘点完之后对照 v0.5.0 的模块清单确定目标环境要装哪些模块core、renderer-2d、input、script、asset、audio。第三步是备份。旧版全局 API 在 v0.4 已经运行了一年多代码里到处都是不可能一夜改完。所以我在迁移开始之前先写了一个兼容层包把旧 API 映射到新内核上。这个包最后帮了大忙后面会提到。3.2 迁移主流程六步改造整个迁移分成六步每一步都对应一个明确的改动。第一步改造 package 依赖。把原本的单一引擎包替换成模块化包安装 core 和需要的能力模块。npm install evengine/core0.5 evengine/renderer-2d0.5 evengine/input0.5 evengine/script0.5 evengine/asset0.5 evengine/audio0.5第二步改入口。原来的入口是const game new Game()之后调用game.start()。现在改成 createApp 加模块声明。第三步替换场景和实体 API。原来的game.createScene()、scene.createEntity()、entity.addComponent()全部废弃。新模型里场景不再是顶层概念改成组合描述文件 模块。第四步统一资源路径。这是个大坑我单独写在踩坑部分。第五步验证生命周期。启动后先检查有没有收到engine:started事件再检查渲染模块是否正常进入 started 状态。第六步做回归测试。跑旧代码的测试集逐个修复兼容层里的映射错误。3.3 五个真实踩坑点每个都有具体现象和解决方式坑一事件时序变化导致同一帧内逻辑顺序错乱。现象是迁移后角色起跳的动画偶尔会晚一帧原因在于新版事件改成异步队列在input:keydown事件里触发的状态变化不会再同步被渲染模块感知。解决方式是改用明确的钩子把事件处理里的逻辑拆成输入收集和状态更新两段分别挂到render:pre和render:post上不再依赖注册顺序。坑二渲染器漏装导致的黑屏关键是它不报错。新版渲染器是可选模块少了它应用照常启动canvas 元素也会被创建但没有任何绘制循环在跑。你的代码调用app.renderer.draw()也不会报错因为渲染器实例是 null 的只有运行时异常才会暴露出来。排查办法是启动后在控制台打印模块状态列表看到 renderer 是 installed 而不是 started就知道漏装了。坑三模块依赖顺序容易踩。asset 是 audio 的依赖如果你在 modules 数组里写成[audio, asset]初始化阶段 audio 模块找不到资源管理器直接启动失败。新版没有在声明时强制排序所以这里很容易踩。解决方式是使用官方提供的buildPreset函数它会按模块声明的依赖关系自动排序。import { buildPreset } from evengine/core const modules buildPreset([ { use: renderer2d }, { use: audio, dependsOn: [asset] }, { use: input }, { use: asset }, ])坑四资源相对路径全部失效。旧版加载资源的路径相对于场景文件所在的目录新版资源包统一从项目根目录解析。迁移之后原来./textures/frame1.png在旧版指向当前场景同目录下的 textures新版直接变成项目根目录下找不到这个相对路径。我们都是用批量脚本把资源引用全部改成/assets/前缀然后重新跑一轮资源检测。坑五getComponent 改名带来大量编译错误。旧版entity.getComponent(RigidBody)到处都是新版组件查询改成了app.stage.query()加条件对象的方式语法完全不同。这种改动不能手动改我写了一个 codemod 脚本正则匹配所有 getComponent 调用先替换成兼容层的兼容方法再逐模块替换成新写法。对比下来脚本处理了大约 80% 的改动剩下 20% 需要手写类型推断。3.4 迁移后的数据对比迁移完成后我们在同一台测试机上做了对比环境是 Chrome 桌面版采样同一个 Demo 的 100 次运行结果取中位数。指标v0.4v0.5变化包体体积未压缩6.8 MB2.3 MB-66%冷启动时间430 ms165 ms-62%常驻内存占用52 MB36 MB-31%首次渲染耗时210 ms90 ms-57%这个数据只代表我们这个小规模 2D 项目的实测情况不能推广到所有游戏负载但趋势是明确的按需组合之后引擎不会再为不需要的能力买单。4. 插件协议与生态面可组合平台的关键拼图4.1 为什么必须有一个统一的插件协议模块解决了引擎自带能力的可拆卸问题但如果平台只支持官方模块它仍然是半封闭的。可组合创作平台的真正含义是第三方可以像官方模块一样扩展能力。要做到这一点必须有一个统一的插件协议。协议要满足三个要求最小约束插件作者不需要理解内核全部实现稳定边界内核升级不会轻易破坏插件能力叠加任何人都可以在已有插件之上再包一层。4.2 协议的最小定义name version installv0.5.0 的插件协议非常轻量一个插件本质上是一个对象包含名字、版本号和 install 方法。install 方法接收 app 实例可以在里面注册事件监听、挂载 API、注入模块。我来看一个实际插件示例。这个插件叫自动快照每 30 秒序列化一次当前场景状态并保存到外部存储用于崩溃恢复和编辑器的撤销栈。export function snapshotPlugin({ interval 30, onSave }) { return { name: snapshot, version: 1.0.0, install(app) { const timer { id: null } app.events.on(engine:started, () { timer.id app.timers.setInterval(() { const snapshot app.serializer.serialize() onSave?.(snapshot) }, interval * 1000) }) app.events.on(engine:stopped, () { app.timers.clearInterval(timer.id) }) // 对外暴露这个插件提供的方法方便其他模块或插件调用 app.snapshot { take: () app.serializer.serialize(), } }, } }这个插件可以在项目里直接被app.use(snapshotPlugin({ interval: 10 }))启用然后其他模块就能通过app.snapshot.take()调用快照能力。可以看到协议没有要求插件作者了解渲染、资源管理这些内部实现它只需要知道事件总线和 app 实例的公开接口。4.3 发布、版本管理与兼容策略插件协议有了版本管理也得跟上。我们给插件生态定的规则是三点。第一官方模块和插件全部使用语义化版本。主版本的变化意味着协议破坏次版本意味着能力增强修订版本只做修复。第二每个插件在 manifest 里声明自己兼容的 core 版本范围。内核加载插件时会做检查版本不匹配直接拒绝安装并给出清晰报错而不是运行到一半爆炸。第三核心包保持小步快跑v0.5 系列的 core 尽量保持协议稳定把新能力放到模块和插件层。这样第三方插件作者不需要跟着内核频繁改代码。另外我们特意发布了一个兼容层包 evengine/legacy-runtime-adapter把 v0.4 的全局 Game API 映射到新架构上。老项目可以先把引擎升级到 v0.5但代码暂时不用大改慢慢迁移。这个兼容层包并不完美性能比原生 v0.5 API 差一些但它的作用是让升级路径变得平滑而不是逼用户在版本落后和一夜重写之间二选一。4.4 把现有工具变成平台插件这次重构里我们把自己的编辑器工具也重新改造成了插件组。编辑器以前是独立进程和运行时靠私有接口通信。到了 v0.5.0编辑器被拆成了三个独立插件宿主编译、组件面板、调试器。宿主插件负责把当前项目运行到一个预览窗口里组件面板插件提供组件树展示和属性编辑调试器插件订阅事件总线把模块状态、网络请求、脚本日志实时显示出来。这些插件都走同一个插件协议用户完全可以把官方编辑器插件替换成自己写的极简版编辑器或者干脆不带编辑器插件只保留运行时。类似的扩展还有很多有人做了把场景导出成视频序列帧的插件有人做了把资源打包进单个 JSON 的插件还有人做了一个把组合描述文件转换成网页嵌入代码的插件。这就是可组合创作平台和游戏运行时最本质的区别发行的是平台的组合能力而不是某个固定产品。5. 重构逼我想清楚的三个边界问题5.1 编辑器与运行时的边界以前我总觉得编辑器是运行时之上的管理界面这导致编辑器代码和运行时代码纠缠不清。重构之后编辑器和运行时被彻底分开编辑器只是宿主环境里的插件集合运行时本身完全不感知编辑器的存在。这个边界带来的直接好处是发布产物变干净了。以前发布一个游戏需要叮嘱用户编辑器相关代码不会进入发布包但代码里总是容易残留一些隐式依赖。现在编辑器插件没有安装时那些 API 就不存在编辑器相关的代码根本不会被执行也不需要做 tree shaking 特判。另一个好处是你可以为不同场景选择不同的编辑体验。有人喜欢可视化拖拽有人喜欢直接用代码写组合描述文件还有人想在企业内部系统里嵌一个极简属性面板。这三个需求可以分别用不同的编辑器插件组合实现运行时的核心不需要跟着变。5.2 资源管线应该独立于游戏循环第二个想清楚的问题是资源管线的位置。旧版里资源加载跟着场景走场景启动就加载资源场景关闭就卸载资源。这对游戏场景很自然但对创作平台是错的。v0.5.0 把资源管线抽成了独立的 asset 模块其中定义了一个资源包概念。资源包是一份编译后的静态清单运行时只需要按需加载不需要关心原来资源是怎么导入、怎么烘焙的。编辑器负责把原始素材导入到资源包运行时只消费资源包。这个分离在处理热更新时特别有用。以前热更新要热更新整个场景现在只需要更新资源包文件然后触发asset:reload事件渲染模块会自动重新读取新数据。另外一个好处是游戏循环如果崩溃了资源管理模块还能独立工作排查问题的时候不至于全盘黑屏。5.3 数据驱动和代码驱动其实可以共存第三个边界问题是数据驱动与代码驱动的方式。很多引擎把可视化编辑和写代码对立起来好像选了可视化就是放弃灵活性。v0.5.0 想通了一件事组合描述文件可以同时承载数据和代码引用。现在的组合描述文件是一个 JSON 结构里面可以声明模块配置、资源引用、插件参数也可以声明某个组件挂载的脚本代码路径。可视化编辑器修改的是这个 JSON手写代码的开发者修改的也是这个 JSON两边不冲突。{ meta: { appName: demo-stage }, modules: [renderer-2d, input, script], entities: { player: { transform: { x: 0, y: 0 }, scripts: [ { path: /scripts/player-control.ts, config: { speed: 3 } } ] } }, plugins: [{ name: snapshot, config: { interval: 10 } }] }这个 JSON 本身就是一个可组合的产物。人可以手写编辑器可以生成外部工具也可以通过脚本生成。这正是可组合创作平台的核心体验创作过程不是一个黑盒编辑器也不是一堆裸代码而是一份可以被程序和人共同读写的组合描述。6. 冷静复盘v0.5.0 值不值得升级6.1 最值回票价的三个改动对我个人而言v0.5.0 里最值回票价的改动有三个。第一是按需组合。我们的分发包体积减少了接近一半这在一个加载型的小游戏项目中体验非常明显首屏启动速度的提升不是一个量级的问题。第二是插件协议。以前我改引擎功能都得走内核仓库的合并审查现在可以像写 npm 包一样在项目里写插件改动局部隔离回溯方便。内部已经有好几个项目模块被直接抽成了插件复用率明显提高。第三是兼容层。我一开始以为兼容层是一个临时的过渡方案实际用下来发现它让老项目不必经历痛苦的断崖式迁移。6.2 目前还不太满意的地方也有三点让我不够满意。第一事件总线的异步化虽然解决了顺序 bug但在实际调试时反而更难追踪到底是谁先处理了这一个事件。希望后面版本能内置一个事件链路追踪器插件把事件触发和监听调用栈可视化出来。第二官方模块的数量还不够。旧版本里内置但不是模块化的那部分能力目前有些还在兼容层里跑没有真正被拆解成独立模块比如粒子系统和动画状态机。第三文档和示例标签过于偏向游戏场景。数据可视化、导览、工具原型这些创作场景明显是 v0.5.0 的新增定位但官方示例大部分还是角色闯关式的游戏 Demo这会误导新用户以为这个引擎只能做游戏。6.3 给还在用旧版本的人的建议如果你现在还在用 v0.4 或者更早的版本我的建议分两种情况。第一种项目不涉及强游戏逻辑比如数据展示、交互工具、编辑器类应用。这种情况我强烈建议升级因为按需组合和插件模型带来的收益非常大你不需要的游戏能力可以完全不装包体和启动时间都会有明显改善。第二种项目已经稳定并且工期紧张。这种情况我不建议立刻全量迁移。比较好的路径是先用兼容层把引擎升级到 v0.5锁定行为然后利用碎片时间把高频调用频次最高的几个 API 先迁移到新写法剩下的留在兼容层上不做大动。迁移之前一定要先把 breaking changes 清单完整读一遍。我踩的坑大多是因为想当然地以为版本升级只是 API 换皮实际上这次是运行模型和依赖关系都变了没搞清楚就动手不如先花半天做一个迁移影响分析。最后分享一个实际操作中的小技巧在迁移过渡期内保留一个专门跑兼容层测试的 CI 任务很值。我们当时把旧的 Demo 测试集绑定到这个任务上每次 v0.5 的 core 更新都跑一遍及时发现有回归避免把旧项目的升级变成一个无限期悬而未决的事情。如果后面你有计划迁移也可以先从这个角度入手把确认兼容层稳定作为第一个里程碑并不需要一口气改完所有代码。