Gandi 3.0文档
官网 打开编辑器
文档/性能(技术向)

运行时性能:克隆体、广播、扩展、编译器

三个原本跑不动的场景:

  • 一万个克隆体各跑各的脚本:原来 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(含正确性验证、试过但没用的方案、测量方法)