运行时性能:克隆体、广播、扩展、编译器
三个原本跑不动的场景:
- 一万个克隆体各跑各的脚本:原来 38 fps,每一帧都超预算;现在稳定在 60 帧,上限放开到 120 能跑到 97。
- 一个用积木写的软件光线追踪器:原来 2.3 fps,现在快了 3.4 倍。
- 一个用广播逐帧驱动三百个单位(一千二百多个克隆体)、大量使用社区扩展的动作游戏引擎:原来 25 fps; 现在帧率设为不限时稳定在 60 帧(显示器刷新率),关掉垂直同步能跑到 70 多。
这一篇讲运行侧(VM、编译器、渲染器)做了什么。编辑侧(几万块积木的工作区、打开大作品) 是另一篇:编辑器性能。
前两个场景的完整报告在 scratch-vm/PERFORMANCE.md,数字出自那份报告:Chrome、Intel 核显、
绘制缓冲 720×540,前后两棵源码树在同一台机器上背靠背测出来的。
结果#
广播驱动的游戏引擎:「步入深渊 2.0 引擎」性能测试作品,2683 块积木、17 个扩展。主循环每帧依次发 10 条广播,每条广播让所有单位的对应脚本各跑一遍:一帧新建约 2700 条线程、调用扩展积木约 3.4 万次、 移动角色约 5700 次。作品没有做任何改动。
测量条件:生产构建,Chrome,i7-11800H,舞台面板绘制缓冲 726×408,帧率设为不限;改动前后两份构建在 同一台机器上交替运行,每轮 12 秒。机器上同时开着别的程序,单轮之间有约 10% 的波动。
| 指标 | 优化前 | 优化后 | 倍数 |
|---|---|---|---|
| 帧率(显示器 60 Hz) | 24.6 fps | 60.0 fps | 2.4× |
| 帧率(关掉垂直同步) | 26.2 fps | 75.7 fps | 2.9× |
| 每帧 VM + 绘制 中位数 | 37.3 ms | 11.1 ms | 3.4× |
| 每帧 VM + 绘制 p95 | 42.6–50.5 ms | 16.0–18.6 ms | — |
| 超过 16.7 ms 的帧 | 100% | 4–12% | — |
一万个克隆体,每个一条自己的线程,全部绘制
| 指标 | 优化前 | 优化后 | 倍数 |
|---|---|---|---|
| 帧率(上限 60) | 38.7 fps | 61.1 fps | 1.6× |
| 帧率(上限 120) | 38.2 fps | 97.6 fps | 2.6× |
| 帧时间 中位数 | 24.3 ms | 9.9 ms | 2.5× |
| 帧时间 p90 | 30.7 ms | 14.2 ms | 2.2× |
| 帧时间 p99 | 47.8 ms | 18.0 ms | 2.7× |
| 其中渲染 | 13.6 ms | 3.1 ms | 4.4× |
| 超预算的帧(上限 60) | 100% | 2.4% | — |
| 超预算的帧(上限 30) | 7.4% | 0% | — |
原来连三十帧都稳不住;现在三十帧毫无压力,六十帧也稳得住。
光线追踪器:1336 块积木、19 个自制积木,一个角色用画笔画每一个像素
| 指标 | 优化前 | 优化后 | 倍数 |
|---|---|---|---|
| 浏览器里的帧率 | 2.3 fps | 7.9 fps | 3.4× |
| 浏览器里的帧时间 | 245.4 ms | 80.3 ms | 3.1× |
| node 里的帧时间(res 2) | 285 ms | 85 ms | 3.4× |
| node 里的帧时间(res 1) | 1140 ms | 326 ms | 3.5× |
只测 VM,在 node 里对着桩渲染器
| 场景 | 优化前 | 优化后 | 倍数 |
|---|---|---|---|
| 1000 个克隆体,一帧 | 0.44 ms | 0.21 ms | 2.1× |
| 3000 个克隆体,一帧 | 1.63 ms | 0.95 ms | 1.7× |
| 10000 个克隆体,一帧 | 8.55 ms | 5.73 ms | 1.5× |
| 生成 10000 个克隆体 | 608 ms | 280 ms | 2.2× |
| 3000 个克隆体,一帧,解释执行 | 4.41 ms | 4.14 ms | 1.1× |
| 生成 3000 个克隆体 | 89 ms | 111 ms | 0.8× |
解释器那一行几乎没动,符合预期:这一轮的改动几乎全在编译器和渲染器上,解释器只得到了调度 那部分的好处。最后一行是退步,见下面。
改了什么#
渲染器:一万个角色一次画完#
- 批量绘制:连续的、只差自己位置、共用同一张造型贴图的一串角色,一次
drawArraysInstanced画完:一万个同造型克隆体从一万次 draw call 变成一次。 - 纹理过滤按纹理设一次,不是按角色设一次。原来每帧有几千次多余的 GL 调用。
渲染器:大造型不再发糊#
- 舞台按实际显示的像素绘制。以前平时只画 480 × 360 再由 CSS 拉大,面板一放大就是一片糊; 现在面板多大画布就多大,连续变化时最多每 150 ms 重建一次。
- 一张造型的纹理能有多大,按像素预算定(800 万像素,另受 GPU 上限),不再是固定的 2566 像素边长:宽过它的地图以前永远只按 1 倍栅格化。
- 超出预算的部分分块绘制:512 像素一块,只画画面里看得见的那些块(相机扩展平移、缩放过的 画面也按它实际看到的范围算,放大时块也跟着放大倍数变清晰);没画好的先用粗一级的块或一张 小图顶着。块和整图纹理共用一份显存预算,见下一节。碰撞用的剪影最多 400 万像素。
- 换了造型内容立刻加载。皮肤懒加载之后,造型编辑器写回的新图要等别的东西把渲染器弄脏 才会被要走,舞台一直停在旧图上。
2260 × 600 的地图造型,舞台浮窗最大化到 1402 × 1051,RTX 3060 笔记本:
| 改前 | 改后 | |
|---|---|---|
| 画布缓冲 | 480 × 360 | 1402 × 1051 |
| 边缘过渡宽度(像素,越小越清晰) | 1.21 | 0.38 |
| 同上,全屏、设备像素比 2 | 3.21 | 0.92 |
| 全屏横向滚动,每帧绘制耗时(分块开 / 关) | — | 0.8 ms / 0.4 ms |
渲染器:显存有上限,显卡出事能原地恢复#
大场景、全屏时舞台会突然停住,声音和操作照常:那是浏览器分配不到纹理(Error allocating Texture2D),整个 GPU 进程退出,页面上所有 WebGL 上下文一起丢了;接连几次之后浏览器干脆停用整页的
WebGL。chrome://gpu 最下面的日志里能看到这几行。
- 所有造型纹理共用一份显存预算。整图纹理和分块记在同一本账上,每张记着最后一次被画到的帧。 预算按画布大小定(大约十屏的 RGBA,96–384 MB),超出时先放掉两秒没画过的;到了两倍预算,除了 这一帧正在画的,都可以放。放掉的下次用到时重新栅格化,期间用最接近的一档顶着。原来整图纹理 一旦做出来就跟着造型一直留着,走过几段关卡就是几百 MB 没人看的图。
- 栅格化不再有尖峰。原来每张造型自己开一张画布、要多大开多大,最大 800 万像素,浏览器要为它 在显卡上再配一块同样大的模板缓冲 —— 分配失败的正是这一步,而且镜头一动,好几张挤在同一帧里做。 现在所有造型共用画布:400 万像素以内的整张画(画完立刻缩回,像素和原来完全一样 —— 浏览器在不同 大小的画布上给 SVG 路径做抗锯齿略有不同,只有同样大小的画布才画得出同样的像素),更大的在一张 1024 × 1024 的画布上一块一块画、一块一块传进纹理,块与块之间多画 16 像素的重叠免得接缝。丢过一次 上下文之后一律分块。每帧栅格化最多花 6 ms,来不及的下一帧补上,工作分摊到几帧里。
- 分块绘制的造型不再另留一张满分辨率的整图。原来整图纹理是分块的「替身」,却按满分辨率做, 每个分块造型都额外攥着一张最大的整图;现在替身是一张不超过 4 MB 的小图。
- 离屏幕很远的角色不画。角色的包围盒完全在画面外,就不画、也不为它栅格化;在画面外半屏以内的, 用这一帧剩下的时间把纹理提前做好,进画面时已经就绪。自定义投影、Spine、画笔和文字、九宫格和 平铺这几类照常画。
- 碰撞结果和以前完全一样。剪影原来是做纹理时顺带读回的,所以碰撞精度跟着显示倍率走。纹理现在 会被放掉、会跳档,剪影改为单独生成,但仍按「哪一档第一次被要到」的老规则,读回的像素和原来相同; 没被任何角色穿着、很久没用的造型,剪影内存可以放掉,再穿上时按同样的倍数同步重建。
- 上下文回来后原地恢复。原来是换一个新的渲染器,可扩展手里攥着的是旧的那个,还往上装了钩子 (相机扩展换了它的投影),恢复后用相机的游戏整个画偏。现在同一个渲染器把着色器、缓冲、帧缓冲、 后处理效果重建一遍,造型纹理按需重新栅格化,位图从剪影里原样传回,画笔层补回最近一次读回的内容。 回不来时舞台上会写明「画面停止更新」,作品照常在跑。
- 后处理的故障效果不再每帧漏一张纹理。
「老精灵」(1928 个造型、Kamera 相机)真全屏 2880 × 1620,玩家一路往右走 90 秒;「深空遗骸」 真全屏开局。RTX 3060 笔记本:
| 改前 | 改后 | |
|---|---|---|
| 老精灵 造型纹理,90 秒后 | 454 MB,一直在涨 | 180–225 MB,封顶 356 MB |
| 老精灵 进游戏第一秒最长的一帧 | 641 ms | 179–238 ms |
| 深空遗骸 开局造型纹理 | 316 MB | 267 MB |
| 丢一次上下文再恢复,画面变了的像素 | 59%(整个画偏) | 0 |
144 组造型 × 缩放 × 位置 × 舞台尺寸的静态画面逐像素对照:141 组完全相同;另外 3 组用到了超过 400 万像素、分块画的纹理,每组不超过 11 个像素差 1/255 到 4/255,都在抗锯齿边缘。
渲染器:每画一个角色少做的事#
- 完全透明的角色不画。虚像为 100 的角色在屏幕绘制里输出的是 (0, 0, 0, 0):着色器在最后一步乘上虚像, 混合方式不变,画了等于没画。隐形的碰撞箱很常见,原来每一个都是一次完整的绘制调用。碰撞、盖章、 取色不受影响。
- 投影矩阵只在变了的时候上传。同一次绘制里,连续几个角色用的是同一个着色器程序和同一个投影矩阵时, 只上传一次;原来每个角色都要上传。
- 绘制循环不再为每个角色新建缩放数组。
- 换造型不再线性摘除监听。一张造型被几百个克隆体共用时,它的变更通知列表就有几百项,每次换造型都要 在里面线性查找、再挪动数组;现在改为集合。
编译器(src/compiler/)#
- 数值变量槽:全工程分析出「只写过数字」的变量,挪进一个 V8 会拆箱的小对象,编译出来的 代码直接读字段。解释器、监视器、序列化都不受影响。扩展会绕过积木直接写变量,所以扩展积木在 菜单或文本框里点名的变量不进槽;扩展往槽里写了文字的,那个变量改回普通存储、编译产物重新生成, 文字原样保留。积木一改分析就重跑,有变量因此离开了槽,所有角色的编译产物(包括留给后面线程复用的) 一起作废。编译好的代码取槽时,那一份要是已经不在槽里,就改从它的普通值读写,不会出错 —— 以前这里读到的是空,脚本每帧抛错,没接住的错误把整帧打断在绘制之前,舞台画面停住、鼠标失灵, 声音和键盘照常。
- 自制积木内联:小的、不让出的、非递归的过程直接展开在调用处,常量实参跟着折叠。
- 常量参数特化:太大不好内联、但实参是常量的过程,单独编一份把常量代进去。光线追踪器里 这一下把五次字符串比较和一串分支减到了零。
- 常量折叠:算术、比较、
sin/cos/sqrt、大小写无关的字符串相等,两边都已知时编译期 就算掉。折叠走的是生成代码用的同一套辅助函数,所以语义是 Scratch 的,不是 JS 的。 - 少发 NaN 守卫,以及参数上的公共子表达式(同一个参数的
sin/cos只算一次)。 - 扩展积木直接调用:扩展积木,以及「说」「播放声音」这类走兼容层的原生积木,编译后先同步 调一次扩展自己的函数,只有它返回 Promise、让出或停掉线程时才进 generator。调用直接写在生成 代码里,每个调用点只认一个函数,V8 能把扩展管理器的包装层和积木本身内联进去;原来每次调用要 建一个 generator 和两个闭包。扩展积木密集的循环快 2 到 7 倍。扩展一行不用改,扩展库里那些没人 维护的也照样受益;自定义的条件、循环积木走原来的路。扩展库里能加载的 51 个扩展、946 个积木 逐个用改前改后的编译器对照过,结果一致;其中 797 个积木逐个计时,每次调用省约 100 纳秒,本身 很轻的积木快 3 到 5 倍,本身就重的(操作 DOM、IndexedDB、矩阵)约 1.1 倍。
- 扩展积木的
compile字段:扩展可以在积木定义上声明不会让出、返回什么类型,或者直接给出 积木本体的 JS 模板,编译器把它当原生积木内联;条件、循环积木的模板里能放编译好的积木堆。 写法见给扩展的宿主接口。编辑器自带十二份配置:更多比较、字符串处理、位运算三份 给出积木本体的模板,这三个扩展的积木单次调用从 240–415 纳秒降到 11–48 纳秒;更多数据类型、多莉 Pro、 作用域变量、注释、arkos 工具、拉伸、图层管理、Kamera 相机、终端九份声明积木不会让出(注释积木另给了 模板),用到它们的自制积木因此可以内联,也不必再是 generator。每一块都对照扩展源码核实过:不返回 Promise、不让出、不改线程状态。 - 常用扩展的替身:更多数据类型的「取 / 设对象属性」,多莉 Pro 的「ID 的属性」「我的 ID」「存在 ID 的 克隆体?」「我是克隆体吗?」「ID 的变量」「ID 到 x、y 的距离 / 方向」「到 ID 的距离 / 方向」「碰到分组的 克隆体?」,由编辑器提供替身:常见的情况由替身直接算出,其余情况交回扩展。替身逐句按扩展源码编写, 只在扩展与编写替身时的版本一致(相关方法源码的哈希相同)时启用,扩展更新后自动退回原来的调用。每个 替身都在真实扩展上用几万组随机输入对照过,返回值与对象状态(包括原型)完全一致。解释器和编译器用的 是同一个替身。
- Kamera 的钩子装得更轻:Kamera 相机往角色的 setXY、调度器等十几处装了钩子,它用来安装钩子的通用
包装每次调用都要复制参数、拼接数组、再
call.apply。编辑器在它装钩子之前把这个安装函数换成等价的写法, 钩子本体一字不动;被包着的 setXY 从每次约 350 纳秒降到约 80 纳秒。同样只在版本一致时替换。 - 不会让出的脚本编译成普通函数。脚本与自制积木用同一套分析:没有循环等待、没有可能返回 Promise 的
积木,就编成普通函数而不是 generator。广播触发的短脚本大多属于这一类,启动更便宜(一个 generator
对象要带上整个函数的寄存器),V8 也更容易优化。生成的源码里只要出现
yield字样,就仍然编成 generator。 - 扩展积木的参数对象按固定的键顺序生成。作品文件里同一种积木,每一块的输入顺序都可能不同,而每种 顺序在 V8 里是一种不同的对象形状:同一个积木函数见到十几种形状,里面每读一次参数都会退化成最慢的 查找。现在按参数名排序;有副作用的输入仍按积木自己的顺序求值。
- 「说」「思考」「移到」「面向」「播放声音」这几块走兼容层的原生积木,只要没有被扩展替换掉,就按不会 让出处理。
- 大小写无关的字符串比较、「某项在列表中的位置」不再为比较分配小写副本。
扩展积木(node,warp 循环,每次迭代纳秒)
| 循环体 | 解释器 | 改前 | 改后 | 倍数 |
|---|---|---|---|---|
| 一个扩展 reporter + 一个扩展 command | 1025 | 282 | 39 | 7.2× |
| 六个原生运算 + 一个扩展 reporter | 3742 | 176 | 85 | 2.1× |
| 调一个只含扩展 reporter 的自制积木 | 1428 | 154 | 61 | 2.5× |
| 第一行的非 warp 版本 | 1282 | 791 | 296 | 2.7× |
调度(src/engine/)#
startHats按 key 直接找线程,不再扫一遍所有正在跑的线程。- 线程表增量维护,不再每帧从头重建;没有线程被停时整趟跳过。
hasScriptsForOpcode走缓存:没有脚本用到的边沿 hat 不产生任何开销。- 克隆时直接拷平表:图形特效和边沿 hat 的值原来是每个克隆体一次 JSON 往返。
- 广播按名字找接收的脚本。每个积木容器按广播名缓存接收它的脚本,一条广播只经过收它的脚本,顺序与 原来相同。原来每条广播都要把每个角色的每个广播帽子逐个比一遍名字。
- 新线程接手上一条线程的编译产物。同一个角色上同一个脚本的上一条线程结束后,新线程直接用它留下的 编译产物,只把其中的线程引用改指新线程。原来每启动一条线程,都要把脚本和它能调到的全部自制积木的 工厂函数重跑一遍(查变量、查积木函数、建闭包)。编译结果、变量表、所在场景有变化,或者有变量、角色、 扩展积木被增删时,产物作废重建;跨场景角色不复用。
- 「脚本 → 正在运行的线程」挂在每个脚本的记录上,不再是一张以「角色 id & 积木 id」为键的大表: 原来每启动一条线程要查一次、写一次,结束时再删一次,表还在不停扩容重建。
- 线程结束时放开编译产物对它的引用,新线程的栈按实际大小分配。一帧几千条只活一帧的线程不再被晋升到 老生代。上面那个作品运行 3 秒:改动前新生代回收 33 次、平均 5.5 ms(最长 9.5 ms),另有一次 88 ms 的 全量回收,每帧晋升到老生代约 3.2 MB;改动后 18 次、平均 1.7 ms(最长 2.6 ms),没有全量回收,每帧 晋升约 0.2 MB。
- 每条线程只读一次时钟:上一条线程结束的时刻就是下一条开始的时刻。接手编译产物的线程不再进出场景 上下文。
- 碰撞索引里没有登记任何角色时,移动、转向、换造型不再去查它。
编辑器界面#
- 角色列表的造型缩略图按资源缓存。作品运行时只要有本体角色在动,VM 差不多每帧都会发一次角色列表更新; 原来每次都把每个本体角色的当前造型重新编码成 data URI,再逐字比较有没有变。
碰撞检测:不再逐像素硬扫#
「碰到某角色?」原来是在两者包围盒的交集里逐像素扫,每个像素对每个候选做一次完整的 投影变换再查轮廓图。满屏重叠一次未命中要 24 毫秒,比一帧的预算还长。现在分四步拦:
- 不改形状的特效不再走几何变换。原来只要开了任何特效(包括虚像、颜色、亮度这些 几何上没动过的)每个像素都要多绕一圈。开了虚像的角色现在和没开特效一样快。
- 轮廓压成一位一像素,另存一份预先膨胀过的,四邻域判定变成一次取位。500×500 的造型 从 1 MB 降到 31 KB,装得进一级缓存。同时把透视除法整个去掉(那个除数恒为 1)。
- 层级占用位图:先按块问「这一块里有没有东西」,两边都确定为空才跳过。只跳过 可证明为空的地方,答案和逐像素扫一模一样。镂空、环形、细长的造型受益最大。
- 结果记忆化,并缓存候选的包围盒。
重复执行里反复问同一个问题不再反复重算。
鱼眼、漩涡、像素化、马赛克这四种会扭曲几何的特效仍然走原来的逐点路径 —— 在它们下面 「世界上的一块对应纹理上的一块」这个前提不成立。
「碰到颜色?」也用上了同一份位图,并且不再一超过阈值就无条件先画一整张 GPU:先在 CPU 上 探一行,探不到再交给 GPU。
| 场景 | 改前 | 改后 |
|---|---|---|
| 满屏未命中 | 24.4 ms | 93 µs |
| 镂空造型(1.6% 不透明) | 6.2 ms | 46 µs |
| 内层循环(粗筛剪不掉时) | 28 ns/像素 | 7–11 ns/像素 |
| 800 个克隆体各问一次「碰到角色?」 | 11.8 fps | 15.4 fps |
| 20 个克隆体的脚本吞吐 | 9.4 万次/秒 | 58.6 万次/秒 |
| 「碰到颜色?」命中 | 38.8 µs | 0.8–1.9 µs |
仍然慢的:800 个克隆体同时问「碰到颜色?」还是只有 1 帧上下。那条路卡在 GPU 上 —— 每个候选一次绘制再加一次同步回读,和像素扫描快不快无关。
一处退步#
三千个克隆体的生成从 89 ms 变成 111 ms。变量提升成槽要给它定义访问器,而克隆体的变量是一个 一个提升的。这是一次性成本,换来之后每帧省 0.68 ms,二十帧就回本;到一万个克隆体时生成反而 快 2.2 倍,因为在那个规模上,原来那套二次方的 hat 扫描才是主导。
开关#
出问题时可以逐项关掉,用来定位是哪一半的责任。这些不在设置界面里,要从控制台改:
// 渲染器(scratch-render)
RenderWebGL.INSTANCING_ENABLED = false; // 退回「一个角色一次 draw call」
RenderWebGL.INSTANCING_MIN_BATCH = 4; // 短于这个长度的一串不做批量
// 大造型分块(SVGSkin 从 __engine.renderer.exports.SVGSkin 拿)
SVGSkin.TILING_ENABLED = false; // 不分块,退回整图纹理(按预算封顶,超出就糊)
SVGSkin.MAX_MIP_PIXELS = 8 * 1024 * 1024; // 整图纹理的像素预算
SVGSkin.WHOLE_RASTER_PIXELS = 4 * 1024 * 1024; // 这以内整张栅格化,更大的分块
// 显存预算(TextureBudget 从 __engine.renderer.exports.TextureBudget 拿)
__engine.renderer.getMemoryStats(); // 纹理、分块、剪影各占多少,淘汰了多少次
TextureBudget.SCREENS = 10; // 预算按几屏 RGBA 算(下次改舞台尺寸时生效)
TextureBudget.RASTER_BUDGET_MS = 6; // 每帧最多花多久栅格化
RenderWebGL.CULLING_ENABLED = false; // 离屏幕很远的角色也照样画
// 编译器
JSGenerator.inlining = false; // 不内联自制积木
JSGenerator.specialization = false; // 不生成常量特化的副本
JSGenerator.INLINE_MAX_SIZE = 150; // 能内联的最大过程,按 IR 节点数
JSGenerator.INLINE_BUDGET = 2500; // 每条脚本内联的节点总预算
JSGenerator.INLINE_MAX_DEPTH = 4;
JSGenerator.SPECIALIZE_MAX_VARIANTS = 8; // 每个过程留几份特化副本
// 调度与入口脚本(从 __engine.vm.exports.i_will_not_ask_for_help_when_these_break() 拿)
Thread.reuseCompiledInstances = false; // 每条新线程都重跑一遍工厂函数
ScriptTreeGenerator.plainEntryScripts = false; // 入口脚本一律编成 generator(之后编译的脚本才生效)画面不对时第一个该关的是 INSTANCING_ENABLED:它把绘制路径原样退回去,一步就能把渲染器
这一半隔离出来。
编辑器自带的扩展配置(包括替身和 Kamera 的钩子安装函数)在 src/vm/compileProfiles,从 index.ts 的列表里
去掉某一份,那个扩展就回到普通的调用方式。
自己测量#
克隆体压力测试要先解掉两个上限,编辑器里没有开关,从控制台设:
__engine.vm.setRuntimeOptions({maxClones: Infinity}); // 默认 300
__engine.vm.setFramerate(120); // 默认 30报告里用的克隆体作品是这样的:一个角色,绿旗脚本在一个「运行时不刷新屏幕」的自制积木里克隆
自己 N 次;「当作为克隆体启动时」里一个永远循环,按自己的速度变量改 x、y,转一下,到舞台边缘
翻转速度,再累加一点算术。克隆一定要放在不刷新屏幕的积木里:普通的 重复执行 每轮让出
一次,一万个克隆体就要一万帧,那样量到的是「生成」不是「运行」。
编辑器里还带了帧插值和按需解码资源。
相关#
- 编辑器性能 —— 几万块积木的工作区、打开大作品
- 完整报告:
scratch-vm/PERFORMANCE.md(含正确性验证、试过但没用的方案、测量方法)