Skip to main content

· 渲染与性能

渲染策略:脏标记 + 脏矩形局部重绘(默认),条件回退全量

引擎采用「脏标记 + 惰性渲染」,并在 v1(2026-09-09)起把默认绘制路径从「整屏全量重绘」升级为「脏矩形局部重绘」:

  1. 脏标记 ice.dirty:任何状态变化(setState、增删组件)置 true;渲染器仅在为真时工作。
  2. 帧入口决策refreshQueue() 后,若 renderMode==='dirty-rect' 且场景满足局部条件 → doRenderDirtyRect();否则回退 doRenderFull()(旧全量逻辑原样保留,为参考与兜底)。
  3. 局部重绘:脏区按组件旧世界盒 ∪ 新世界盒 + paint pad 收集,聚合成若干块互不相接的裁剪区(coalesceRegions,上限 6 块,超出时合并「面积增量最小」的两块;脏块数超过聚合预算 MAX_COALESCE_REGIONS = 32 时直接塌缩成并集盒,避免 O(k³) 聚合把一帧卡死),逐块 clearRect + clip,块内按 z 序重画与区域相交的组件。渲染器用 WeakMap 保存每组件「上次实际绘制的世界轴对齐盒」快照,用于旧区域擦除与相交判断。
    • 两个坐标系:脏区收集与相交判定在世界坐标里做(快照盒是世界盒),而 clearRect/clip 必须用渲染坐标 = 世界坐标 × 渲染视口(dpr · viewport)。两者之间只有一个换算点 mapBoxToRender()(向外取整防接缝)。
  4. 组件上下文自包含:每个组件 render 末尾把本组件写过的泄漏 ctx 属性(shadow/globalAlpha/composite/lineCap/lineJoin/miterLimit/textAlign/textBaseline/虚线)归位为 canvas 默认值——这是 full 与 partial 逐像素一致的前提。

门控与回退条件(几何纯函数见 src/renderer/dirty-rect-util.ts,阈值常量在 CanvasRenderer)。 2026-09-10 把「risky 组件」的判定由 v1 的整场景级放宽到 相交级,这是让默认渲染路径在真实场景里真正生效的关键一步:

条件动作原因
结构变更(markQueueDirty全量并重建快照队列/层级整体变化,旧盒不可复用
快照未 prime(结构变更后首帧)全量旧区域未知,无法擦除
无可见脏组件且无隐藏擦除(手动置脏/图片 onload 帧)全量保语义
脏可见组件占比 > 20%全量相交裁剪收益小
脏区域(多块之和)面积 / 画布面积 > 35%全量接近整屏成本
可见的 dot-path(折线/星形等)、ICEText 或非不透明落墨(alpha 色/阴影/globalAlpha/合成模式)且与本次脏区相交全量clip 边界与半透明落墨/字形/折线抗锯齿相交会产生接缝。实测「文本内容变更」时字形+描边的墨迹会超出几何盒,clip 下重绘无法与全量逐像素一致(像素回归曾报 594px 差异,差异色正是文本的描边色)
上述 risky 组件刚变脏、但只是位置变化paramsDirty === false,即祖先变换导致的平移)且非文本不阻塞脏组件的 old∪new 盒(含 paint pad)本来就会被并进脏区,所以 clip 切不到它的墨迹 —— 墨迹形状不变、只是整体平移,逐像素必然一致
上述 risky 组件刚变脏、只是位置变化、且是文本命中离屏缓存则放行,否则全量文本字形墨迹可能超出几何盒,只有走离屏缓存(主画布只是 drawImage 平移复用位图、clip 只作用于整像素采样)才能保证逐像素一致
上述 risky 组件干净、且已离屏缓存不阻塞局部重绘主画布只是 drawImage 一张不透明位图,clip 只作用位图的整像素采样,不改变字形内部 AA
场景含折线(isLine)且其带宽盒较大富场景下会稳定回退全量折线包围盒修复为真实值后属「clip 会切断描边抗锯齿」的风险类别。这是正确的保守行为 —— 此前「能走局部重绘」恰恰是因为盒子退化、漏画了折线(见 13 §4.4)
新/旧包围盒含 NaN;ctx 无 clip/save/restore(老小程序 canvas)全量安全兜底

不再回退的两类(2026-09-11 落地):以前「视口非单位变换」与「dpr ≠ 1」都直接回退全量 —— 理由是「世界盒与屏幕 clearRect/clip 不一致」。但这两类恰好就是真实使用场景(编辑器必然缩放平移; 高分屏要开 dpr 才不发虚),等于招牌优化在最需要的地方完全失效。现在改为把脏区映射到渲染坐标后照常局部重绘:

场景以前现在
视口缩放/平移(scale≠1tx/ty≠0回退全量局部重绘(脏区 × 视口换算)
dpr > 1 高分屏回退全量局部重绘(脏区 × dpr)
分散脏区(多选拖拽、布局重排、多条独立动画)并成单个大盒 → 极易撞 35% 阈值 → 回退全量聚合成多块裁剪区,各擦各的

回归证据:e2e/visual/dirty-rect-pixel.spec.ts 新增 ?zoom=1?hidpi=1?zoom=1&hidpi=1?multi=1 四个场景,均断言「局部重绘执行次数 > 0」且 10 步逐像素与全量 100% 一致。

实测收益:富场景(含旋转组 / 文本 / 星形 / 连线 / 半透明控制面板)的局部重绘执行次数由 0 → 2,且 10 步逐像素比对仍 100% 一致。 编辑器里控制面板长期存在,旧的整体门控因此几乎永久失效 —— 所以这一步是「把已经花掉的优化成本真正收回来」。

这些收益以前只在「单位视口 + dpr=1」时成立(见上表);2026-09-11 补齐坐标映射后,缩放/平移与高分屏下同样生效。

仍待解决(2026-09-11 应用层实测钉出的真实瓶颈)ice-entity-designer 编辑器里局部重绘仍然 100% 回退 (拖动实体 22/22 帧 __collect() 返回 null)。逐条排查后的阻塞源是干净、不可缓存的 ICEPolyLine(关系连线) 与脏区相交 —— 连线横跨画布,任何脏区都会与它相交,而 ObjectCache 有意排除 isLine(位图过大, drawImage 反而不划算)。解开它需要一次产品级取舍,二者之一:

  1. 缓存连线位图:命中时主画布只是贴图,clip 安全;代价是每条连线一张(可能很大的)位图;
  2. 接受 clip 边缘的 AA 接缝:放弃「full 与 partial 逐像素一致」这条既有不变量(当前所有像素回归都在守它)。

在做出这个取舍前,局部重绘对这个应用没有收益(其余优化都被这一条门吃掉)—— 这也是后续排期该优先处理它的原因。 另外两条已被本次验证排除的猜测:文本离屏缓存命中率是 100%(240/240),所以不是缓存问题; 工具层(变换面板)不参与阻塞(实验:禁用工具层 risky 判定后仍 0 次局部重绘)。

组件级离屏缓存(ObjectCache)

CanvasRenderer 内置 ObjectCacheWeakMap,与 __snap 同生命周期,不写入组件 state/props)。 缓存「非编辑态、可见」的 ICEText 与「封闭点集路径」(星形/正N边形/玫瑰;排除连线类 isLine 与蚂蚁线 lineDashFlow;且面积 >= 40000(200x200)),把组件预渲染成一张不透明位图, 主画布上只 drawImage。此外缓存「半透明普通 path 图形」(rgba/阴影/globalAlpha/composite; 排除容器/图片/连线),把 alpha 落墨先画进位图,再 source-over 贴回。 大量小图形不缓存——小图形的 drawImage 光栅化会反超直接 fill/stroke

收益三点:

  1. 缓存命中帧跳过 measureText(DOM 测量)与 fillText/strokeText,或跳过 calcDots、路径重建与 fill/stroke
  2. 纯平移(内容与线性变换不变,仅 left/top 变化)复用位图,只刷新贴图位置;
  3. 已缓存组件在 __sceneAllowsPartial 中按「不透明位图贴图」处理,不再阻塞局部重绘。 clip 只作用于最终位图的整像素采样,不改变字形内部 AA,因此 full 与 partial 逐像素一致。

实现要点:

  • root.createOffscreenCanvas(width, height):浏览器 document.createElement('canvas'), 小程序 wx.createOffscreenCanvas({ type: '2d', width, height });物理尺寸按 root.devicePixelRatio 缩放。
  • ICEComponent.renderTo(targetCtx, baseMatrix):渲染期间临时重定向 this.ctx, 最终 CTM = baseMatrix · composedMatrixbaseMatrix 把世界盒平移到离屏左上角)。render() 语义不变。
  • 缓存决策(ObjectCache.render):
    • 未 dirty 且已有 cache → 直接贴图;
    • dirty → 先 refreshParams() 按需刷新派生状态(只有自身派生参数变脏时才重算点集 / 文本量测; 祖先移动导致的「只需重绘」不重算),再 composeMatrix() 后比较 contentKeylinearKey(a,b,c,d); (composeMatrix() 对 dots 的平移已改为幂等,不再要求每次 compose 前都重算 dots,见 02
    • 内容或线性变化 → 重建位图(renderTo 到离屏);
    • 仅平移变化 → 复用位图,刷新贴图位置。

像素一致性回归:e2e/visual/dirty-rect-pixel.spec.ts?opaque=1&text=1?opaque=1&star=1?opaque=1&alpha=1 场景,验证含文本 / 含星形 / 含半透明矩形的全不透明场景局部重绘真正执行 (collectOk>0)且与 full 路径 10 步逐像素一致。 引擎 JS 层基准:npm run bench 的场景 C(文本缓存命中)对比场景 D(每帧重建)可观察加速比。

渲染队列(flattenTree + 排序 + 缓存)

每帧需要把组件树"展平"成有序数组再渲染:

  • flattenTree(childNodes) 递归遍历,产出 componentQueue(普通组件)与 toolsQueue(工具组件),同时标注 _level/_pid
  • 两个队列各自按 state.zIndex 升序排序,确定绘制顺序。

性能优化(2026-09-08 引入):渲染队列带缓存。

  • 仅当组件树结构变化addChild/removeChild/addTool/removeTool 等)时,由 renderer.markQueueDirty() 触发重建(重新 flatten + sort)。
  • 稳态帧只做一次 O(n) 的 zIndex 稳定性比对:若所有 zIndex 未变,直接复用上一帧的队列数组,不重复递归展平、不重新分配数组。
  • zIndex 变化(经 setState)无需手动标记,稳态比对会自动发现并重新排序。

铁律:所有改变组件树结构的入口都必须调用 renderer.markQueueDirty(),否则稳态帧会沿用过期队列,导致增删组件不渲染或层级错乱。回归用例见 tests/renderer/CanvasRenderer.queue.test.ts

矩阵零分配

矩阵运算在动画(每帧全量 compose)场景下是最大开销之一。引擎把热路径的矩阵计算改为复用缓冲、零分配

方法复用对象
calcLinearMatrix()复用 state.linearMatrix(原地 identity + skew/rotate/scale)
calcAbsoluteLinearMatrix()复用每实例 __absScratchA/B 两个缓冲做祖先连乘
composeMatrix()复用 state.composedMatrix + 平移 scratch __transScratch
calcAbsoluteOrigin()复用 __originScratch + 原地 transformMat2d

两点关键约定:

  1. 这些缓冲是普通数组(非 mat2d.create() 返回的 Float32Array),以保持矩阵为 Array 类型,兼容 Array.isArray 与序列化。
  2. 优化不得破坏语义calcAbsoluteLinearMatrix 仍要实时重算祖先(见 03),零分配只是复用存储、不是复用缓存值。

applyStyleToCtx 同样做了零分配:用 for...in 直接遍历 props.style/state.stylectx不用 Object.keys(会分配 key 数组、加重 GC)也不做对象展开合并。

渲染循环内的细节

  • 全量路径 doRenderFull()clearRect(0,0,canvasWidth,canvasHeight) 整屏清屏;局部路径 doRenderDirtyRect()clearRect 脏区域(旧盒 ∪ 新盒 + paint pad)。两条路径的正确性以逐像素一致为验收标准。
  • 对每个组件注入 root/ctx/evtBus/ice 后调用 component.render()(稳态下这些引用已一致,跳过重复注入)。
  • 一轮结束后 ice.dirty=false,并触发 ROUND_FINISH 事件(供 linkSlotManager 等订阅)。
  • 折线的端点箭头ICEPolyLine.calcArrowPoints() 把三角形顶点插进点集[P0,A1,A2,P0,…]),路径本身只描边;填充是描边之后单独做的一次 fill(用线色,arrowStyle:'hollow' 可关闭)。 ⚠️ 那次 fill 必须先 beginPath():无全局 Path2D 的运行时(小程序低版本 / Node)走 replayPath(),它会把整条开放折线留在 ctx 当前路径上,不重开路径会把折线一并填满。

性能基线

bench/render.cjs 提供可复跑的引擎级基准(node + stub ctx,测的是引擎 JS 逻辑开销,不含光栅化):

npm run bench # 默认 1000 组件
node bench/render.cjs 5000

参考数据(N=5000,2026-09-08 优化后):

场景每帧耗时
静态重绘(稳态,仅 ice.dirty~0.8ms
动画(每帧全量 compose)~5.9ms

光栅化开销需在真实浏览器用 DevTools Performance 面板另测;可视化回归用 npm run test:visual(见 e2e/visual/README.md)。

最大图元数压测

examples/performance/max-elements.html 用纯色小矩形逐档上探图元数,测构建/首帧/稳态全量重绘。 数据(Playwright + Chromium,?full=1 满档,2026-09-10 挂载去重优化后):

图元数构建首帧稳态整帧
10万~0.36s~0.19s~0.11s
50万~2.0s~1.1s~0.48s
100万~6.1s~4.3s~1.17s

真实堆内存(不包含画布/光栅化 buffer)——2026-09-15 用 npm run bench:mem 重测

图元数堆内存增量每图元约
10万232MB2.37KB
50万1156MB2.37KB
100万2312MB(累计约 2.26GB)2.37KB

口径说明(为什么和旧版本的数字差 2.7×):本表此前写作 ~179 / ~695 / ~868MB、每图元 1.8 / 1.4 / 0.9KB, 并标为"node 实测的堆增量"。实际上那组数来自 examples/performance/max-elements.html 里读取的 performance.memory.usedJSHeapSize —— 浏览器已用堆绝对值(还带采样/量化),既不是增量、也不是 node 读数, 所以它不随图元数线性变化("每图元越算越小"就是这么来的)。 现在这张表的口径是:node 加载 dist/index.cjs、每个规模独立进程、造对象前后各强制两次 gc, 指标 =(gc 后 heapUsed 差)/N = 每实例保留字节数;npm run bench:mem 可复现,且已进 verify:full 门禁。 其中"默认配置"(props + state)那部分只占 0.98KB/图元;同场景把整份默认表显式传给每个实例约 7.7KB/图元(3.3×)—— 这就是"默认配置不复制"省下来的量级。注意 style 是例外:它按主题每实例派生一份,不在共享默认里。

首帧构成(stub ctx 拆解,不含光栅化):flattenTree + sort 约 5%,首次 composeMatrix 约 15%, 其余为渲染调用与浏览器光栅化。排序不是首帧瓶颈(队列缓存 + V8 TimSort 对递增 zIndex 近乎 O(n))。

结论:最大可交互图元数约 50 万(稳态整帧 ≤ 1s);10 万是流畅舒适区;100 万可构建渲染但不实用。 真正的硬约束是内存——100 万最小矩形堆增量约 2.3GB(每图元 2.4KB,累计约 2.26GB),先于渲染成为天花板。 该结论对应最简单图元,文字/连线/阴影/半透明等复杂组件的实际上限会明显更低。

动画机制压测

测量口径(2026-09-13 复测)

  • 示例examples/performance/animation-stress.html —— N 个图元各挂一个 left 循环动画, 同步驱动 ICE_FRAME_EVENT;结果写进 window.__animStressResult?n=10000&frames=60 可复跑)。
  • 环境:Playwright + Chromium 153,Apple M4 / macOS 26.6.2 / 24GB,画布 1600×1000、dpr=1, 引擎 2.2.0。单帧 = tween + setState + 重绘,取 p50。
  • 数字只用于量级比较:换机器 / 分辨率 / 组件类型都会变(尤其文本,见下)。

一句话:动画没有"定时器"成本 —— 全局只有一个 rAF(FrameManager)每帧派发一次 ICE_FRAME_EVENTAnimationManager 一次 O(n) 遍历做时间插值;成本全在重绘。

① 驱动成本是常数级:tween 只占 1%~5%

把渲染器 stop() 掉、只让动画跑,与完整单帧对照(矩形,见 ②):

动画组件数只算动画(tween + setState)完整单帧
1,0000.1 ms1.1 ms
5,0000.3 ms5.8 ms
10,0000.6 ms15.4 ms
20,0001.3 ms29.1 ms

② 复测旧表(同一示例)

动画组件数p50等效帧率
1000~1.1ms~909fps
5000~5.8ms~172fps
10000~15.4ms~65fps
20000~29.1ms~34fps
50000~61.2ms(2026-09-08 读数)~16fps

(2026-09-08 的旧读数 14.4ms / 26.2ms 与本次一致,说明该示例的读数可复现。)

③ 真正的断崖:脏组件计数门(FULL_FALLBACK_DIRTY_RATIO = 0.2

10,000 个静止矩形里只让 K 个动(CanvasRenderer 插桩统计走的是全量还是局部):

动画数 K脏比 K/可见单帧 p50走哪条路
100.1%1.4 ms局部重绘
5005%2.2 ms局部重绘
1,50015%4.7 ms局部重绘
2,10021%12.7 ms回退全量
5,00050%14.3 ms全量
10,000100%16.5 ms全量

只多 600 个动画组件,单帧从 4.7ms 跳到 12.7ms(2.7×)。原因是 __collect() 的门控: 「脏组件数 / 可见组件数 > 0.2 → 放弃局部重绘」——而动画的脏区面积往往很小(平移几十像素), 却按组件个数被判死刑。越过这道门后成本与动画数量几乎无关(都是整屏重绘)。

另外两条门同样要记住:脏区面积占比 > FULL_FALLBACK_AREA_RATIO = 0.35 → 全量; 脏区最多聚合 MAX_DIRTY_REGIONS = 6 块(脏块数超过 MAX_COALESCE_REGIONS = 32 时不再精挑细选, 直接塌缩成并集盒 → 交给面积阈值回退全量,见 18 §3.3)。

④ 文本动画是重灾区

2,000 个组件、全部挂 left 动画:

场景单帧 p50等效帧率
2,000 个动画矩形2.7 ms370
2,000 个动画文本91.8 ms11
2,000 个文本、强制整屏重绘(作对照)86.2 ms12
2,000 个静止文本(空闲帧)0 ms

即:动画本身的额外成本可以忽略(tween 0.2ms),贵的是"每帧把这批文本整屏重画一遍"(≈43µs/文本/帧)。 而且文本属于"risky"类别(字形墨迹会溢出几何盒),再叠加动画写值走 setState(每帧 paramsDirty), 哪怕只有 100/2000 个文本在动(5% 脏)也已经回退全量,局部重绘一次都用不上。

⑤ 根因拆解:文本动画贵在"离屏位图每帧重建"(N=1,000 文本,离屏缓存插桩)

写法单帧 p50位图重建 / 复用次数
只置 dirty(不置 paramsDirty2.6 ms1000(仅建一次) / 23000 次纯平移复用
setState(= 现在动画的写法)34.8 ms24000 / 0(每帧重建)
矩形走 setState(对照)1.3 ms0 / 0

13 倍差距,且原因明确:setState 无条件置 paramsDirtyObjectCache 视为"内容可能变了" → 丢弃位图、每帧重新 renderTo 离屏画布;而"只标 dirty 的纯平移"能命中 refreshPosition (整数设备像素位移时复用位图,只换贴图落点)。 这就是「动画写值专用通道」这条优化(见下)的量化依据。

⑥ 与 CSS / WAAPI 的本质差别

Canvas 2D 没有合成器通路:CSS/WAAPI 的 transform / opacity 可以只在合成器线程 + GPU 上完成, 不触发重绘;画布上的每一帧都要在主线程跑 JS + 光栅化。绕开只有三条:

  1. 整层动画交给 CSS:动 <canvas> 元素自身(CSS transform / opacity / 转场)→ 零 JS 绘制。 平移、缩放、淡入淡出、翻页这类"场景级动画"都该这么做;
  2. 分层与位图复用:静态背景预渲染成位图,只重绘动画层;纯平移时位图 drawImage 复用 (ObjectCache 已支持,但需要动画写值不置 paramsDirty,见 ⑤);
  3. OffscreenCanvas / Worker:把光栅化搬离主线程(成本最高,见 10)。

动画性能预算(做动画功能前先看这张表)

约束阈值依据
同时动画的矩形 / 路径类组件≤ 10,000(2026-09-14 实测 60.6fps;20,000 → 35.1fps)规模基线(下方)
同时动画的文本组件≤ 1,000~2,000(实测 1,000 → 60.1fps、2,000 → 58.8fps;5,000 → 43.7fps)规模基线(下方);原"100~200"是位图每帧重建时代的数字,⑤ 修完之后已放宽一个数量级
脏组件占比≤ 20%,否则必然整屏重绘
stagger / 入场动画典型"全员同时动"场景(脏比≈100%)→ 必然会撞 20% 门

规模基线(2026-09-14 实测,回答"再多就不行了")

口径:Apple M4 / Playwright Chromium(headless)/ 1600×1000 / dpr=1; 场景与 bench/anim 同构(网格排布的小矩形 18×14 或文本 label i全部left 循环动画 = 最坏情况);每个变体开全新页面测 1.5s,统计真实 fps、帧内 JS 耗时、CDP TaskDuration/ScriptDuration

场景真实 fps帧内 JS主线程忙其中 JS非 JSsetTimeout 最坏延迟
2,000 矩形动画61.52.5ms273ms/s155ms/s118ms/s8.6ms
5,000 矩形动画60.85.9ms500ms/s359ms/s141ms/s13.1ms
10,000 矩形动画60.611.7ms855ms/s710ms/s145ms/s35.5ms
20,000 矩形动画35.127.7ms1003ms/s973ms/s30ms/s64ms
1,000 文本动画60.14.0ms350ms/s243ms/s107ms/s9.5ms
2,000 文本动画58.86.2ms524ms/s366ms/s158ms/s13.2ms
5,000 文本动画43.715.8ms995ms/s692ms/s303ms/s40.5ms

三条结论(都会影响"下一步投什么")

  1. 瓶颈在我们自己的每组件 JS,不在光栅化。 把画布面积缩到 1/4、1/16(N 不变、JS 成本不变): fps 仍是 60.4 / 60.6,帧内 JS 仍是 11.3ms,只有"非 JS 的主线程工作"从 145ms/s 掉到 85 / 72ms/s。 所以 GPU 后端(它只换光栅化那一侧)对这个场景几乎没用;真正的杠杆是减少每组件 JS 与提交次数(合批)。
  2. "1 万"这条线是准的,但余量很小:10,000 → 60.6fps,20,000 → 35.1fps;超过 1 万之后主线程接近饱和 (1003ms/s),setTimeout 最坏延迟涨到 64ms(交互会明显发木)。
  3. 文本约束已放宽一个数量级:写值通道 + 设备像素量化之后,2,000 个文本同时动画仍有 58.8fps (旧表里 2,000 文本是 91.8ms/帧 ≈ 11fps)。到 5,000 才掉到 43.7fps。

口径提醒:headless Chromium 的光栅化路径与真机 GPU 合成不完全一致 —— 但本节的结论建立在 **"面积不敏感 + JS 占比高"**这两条与光栅化路径无关的证据上,换成 headed 也不会翻转结论。

给编排功能的提醒stagger、入场动画这类"错峰"编排天然让大部分组件同时处于动画中 (脏比接近 100%),所以做编排之前必须先解决重绘路径,否则"错峰入场"会比"一起动"更慢 (每一帧都整屏重绘)。

已落地:动画写值通道 + 设备像素量化(2026-09-13)

⑤ 的根因(setState → 每帧 paramsDirty → 位图重建)已经修掉两块:

  1. 动画写值通道setState(patch, { paramsDirty }) 支持显式跳过派生参数重算; AnimationManager 按组件的「动画安全键」白名单判断 —— 本帧写出的键全部是纯绘制/变换类 (位置、transform.*、透明度、颜色、装饰线、光标/选区…)时才跳过。白名单由子类显式声明, 未声明/未知键一律保守地照旧置脏(第三方组件不声明 = 零风险)。
  2. 设备像素量化:位图纯平移复用要求位移是整数设备像素,而动画每帧位移通常是小数。 AnimationManager.snapToDevicePixel(默认开)会把飞行中的纯平移吸附到设备像素栅格; 只对"当前可离屏缓存"的组件生效,且终点值永远精确写入(配置 100.5 就落在 100.5)。 单条动画可写 snapToDevicePixel: false 关掉,需要极致平滑的应用可整体关。

效果(npm run bench:anim,Chromium / Apple M4 / N=1000 / 40 帧,与 ⑤ 同口径):

场景改前改后
1,000 个文本各挂一个 left 循环动画35.1 ms/帧(位图重建 40000 次、复用 0)2.7 ms/帧(重建 0、复用 40000,复用率 100%)
同上、关掉量化(snapToDevicePixel = false34.7 ms/帧(回到每帧重建)
1,000 个矩形(对照)1.4 ms/帧1.4 ms/帧(不受影响)

基准门禁(4 套工具 + 判定口径,2026-09-14)

性能数字必须可复跑、可判定,不能只写在文档里靠人记。四套工具与它们的门禁:

命令口径判定
npm run bench <N> -- --checkNode + stub ctx:引擎 JS 热路径(静态重绘 / 全量 compose / refreshQueue / 文本离屏缓存)✅ 基线 bench/baselines/render.json,实测 ≤ 基线 × 2.0
npm run bench:micro -- --checkmitata 微基准(矩阵/坐标、渲染、命中、util、路径记录、序列化、SVG 导出,共 37 项)✅ 基线 bench/micro/baseline.json(ns/iter),实测 ≤ 基线 × 2.5
npm run bench:anim -- --check真浏览器:动画写值通道 + 设备像素量化✅ 阈值写死在脚本(p50 8ms / 复用率 90%)
npm run bench:layers -- --check真浏览器:分层渲染✅ 阈值写死在脚本

其中前三项分别接在 verify(render)与 verify:full(anim / layers / micro)里。 判定刻意用宽松倍数(2.0 / 2.5):它挡的是"机制被改坏"这类数量级退化 (写值通道退回 setState、位图复用失效、矩阵零分配被破坏…),不是几个百分点的抖动 —— 跨机器、跨负载只要量级对就算过。基线刷新是有意动作: npm run bench <N> -- --update-baseline / npm run bench:micro -- --update-baseline

稳定性提示:本机单轮差异小于 5% 属噪声(实测见过一次 61ms 的 GC 停顿把某个 median 抬高一倍)。 判断"有没有退化"请看多轮中位数与数量级,别盯单轮数字。

当前基线(2026-09-14,Apple M4 / Node 25 / darwin-arm64 / Chromium,dpr=1,N=2000)

指标实测说明
场景 A 静态重绘(稳态,仅 ice.dirty1.05 ms2000 组件;等效 ~950fps(仅引擎 JS,不含光栅化)
场景 B 动画(每帧全量 compose)1.75 ms2000 组件;等效 ~570fps
refreshQueue(flattenTree + sort)0.011 ms单次
场景 C 文本命中离屏缓存(N=501)0.12 ms跳过 measureText/fillText
场景 D 文本每帧重建缓存(N=501)1.06 ms对照;缓存加速比 ≈ 8.7×

跨版本复核(同脚本、同机器、各 3 轮):2.3.1 与 2.3.0 / 1.4.10 全部落在 ±3% 以内(N=5000/10000 亦然), 即 2026-09-13 那批改动(事件派发、连线插槽、脏区聚合预算)没有引入热路径退化

优化方向(逐条状态;标 ❌ 的别当成已有能力

  1. 把「计数门」换成「面积门」(或对"纯平移 + 已缓存"直接放行) —— 2026-09-13 实测后否决(结论与数据见 18 · 动画机制 §3.3):脏区一散开,并集盒必然撞 0.35 的面积阈值, 面积门并不能把 ③ 的断崖补回来,反而让"注定回退全量"的帧白花 30% 做收集与聚合; 成片脏区的交叉点在 2030% 且低于测量噪声。该场景的正解是分层渲染(见 18 §3.1 与本文末)。 评估中撞出并修掉了 coalesceRegions 的 O(k³) 卡帧缺陷(聚合预算 MAX_COALESCE_REGIONS = 32, 1000 脏块 111s → 1ms,单测钉住);
  2. 帧率分级 + 空闲停帧 —— ✅ 已落地(2026-09-13,详见 18 §3.4FrameManager 改成按需续帧(宿主用 needsFrame() 回答 "这一帧还要吗"),次要动画可写 fps: 30 降频,prefers-reduced-motion 下直接落终态。 真实浏览器实测:静止页面 500ms 内 0 次帧回调(改造前约 30 次)。回归 tests/FrameManager.idle.test.tstests/animation/animation-scheduling.test.tse2e/visual/animation-scheduling.spec.ts
  3. OffscreenCanvas / Worker(见 10:目前只有设计 + 最小可行性原型, 引擎尚未正式移植到 worker)。

已落地:分层渲染(静态层 + 动画层,2026-09-13)

npm run bench:layers(10000 静态元素 + 200 个动画标记;静态内容故意用"贵"的文本/折线):

方案单帧 p50动画期间静态层重绘次数
单画布(已在走局部重绘)26~34 ms
双实例分层(静态层画一次 + 动画层)0.4~0.6 ms(≈60×)0

机制:单画布即使走局部重绘,也要重画与脏区相交的静态内容(文本/折线是 risky 类别,clip 下重绘 与全量不一致 → 门控还会回退);分层把静态层位图整层复用,动画帧只画动画层。 引擎只提供原语(视口同步 ICE.linkViewport、输入穿透 setInputPassthrough、多实例事件归属过滤), 分层由应用按配方组织(层数 ≤2~3、上层默认穿透、静态层只在内容变化时置 dirty)—— 完整配方与约束见 18 · 动画机制 §3.1

这几条的目标架构、约束与验收指标已整理成 18 · 动画机制 (含"不用 CSS 变换做图元动画""不做每动画一个定时器"等决策记录);本节只保留实测数据。

写值通道与量化已固化进门槛:npm run bench:anim--check 会按宽松阈值判定,已接进 verify:full)。

Live Demo