· 渲染与性能
渲染策略:脏标记 + 脏矩形局部重绘(默认),条件回退全量
引擎采用「脏标记 + 惰性渲染」,并在 v1(2026-09-09)起把默认绘制路径从「整屏全量重绘」升级为「脏矩形局部重绘」:
- 脏标记
ice.dirty:任何状态变化(setState、增删组件)置true;渲染器仅在为真时工作。 - 帧入口决策:
refreshQueue()后,若renderMode==='dirty-rect'且场景满足局部条件 →doRenderDirtyRect();否则回退doRenderFull()(旧全量逻辑原样保留,为参考与兜底)。 - 局部重绘:脏区按组件旧世界盒 ∪ 新世界盒 + paint pad 收集,聚合成若干块互不相接的裁剪区(
coalesceRegions,上限 6 块,超出时合并「面积增量最小」的两块;脏块数超过聚合预算MAX_COALESCE_REGIONS = 32时直接塌缩成并集盒,避免 O(k³) 聚合把一帧卡死),逐块clearRect+clip,块内按 z 序重画与区域相交的组件。渲染器用WeakMap保存每组件「上次实际绘制的世界轴对齐盒」快照,用于旧区域擦除与相交判断。- 两个坐标系:脏区收集与相交判定在世界坐标里做(快照盒是世界盒),而
clearRect/clip必须用渲染坐标 = 世界坐标 × 渲染视口(dpr · viewport)。两者之间只有一个换算点mapBoxToRender()(向外取整防接缝)。
- 两个坐标系:脏区收集与相交判定在世界坐标里做(快照盒是世界盒),而
- 组件上下文自包含:每个组件 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≠1 或 tx/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 反而不划算)。解开它需要一次产品级取舍,二者之一:
- 缓存连线位图:命中时主画布只是贴图,clip 安全;代价是每条连线一张(可能很大的)位图;
- 接受 clip 边缘的 AA 接缝:放弃「full 与 partial 逐像素一致」这条既有不变量(当前所有像素回归都在守它)。
在做出这个取舍前,局部重绘对这个应用没有收益(其余优化都被这一条门吃掉)—— 这也是后续排期该优先处理它的原因。 另外两条已被本次验证排除的猜测:文本离屏缓存命中率是 100%(240/240),所以不是缓存问题; 工具层(变换面板)不参与阻塞(实验:禁用工具层 risky 判定后仍 0 次局部重绘)。
组件级离屏缓存(ObjectCache)
CanvasRenderer 内置 ObjectCache(WeakMap,与 __snap 同生命周期,不写入组件 state/props)。
缓存「非编辑态、可见」的 ICEText 与「封闭点集路径」(星形/正N 边形/玫瑰;排除连线类 isLine
与蚂蚁线 lineDashFlow;且面积 >= 40000(200x200)),把组件预渲染成一张不透明位图,
主画布上只 drawImage。此外缓存「半透明普通 path 图形」(rgba/阴影/globalAlpha/composite;
排除容器/图片/连线),把 alpha 落墨先画进位图,再 source-over 贴回。
大量小图形不缓存——小图形的 drawImage 光栅化会反超直接 fill/stroke。
收益三点:
- 缓存命中帧跳过
measureText(DOM 测量)与fillText/strokeText,或跳过calcDots、路径重建与fill/stroke; - 纯平移(内容与线性变换不变,仅 left/top 变化)复用位图,只刷新贴图位置;
- 已缓存组件在
__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 · composedMatrix(baseMatrix把世界盒平移到离屏左上角)。render()语义不变。- 缓存决策(
ObjectCache.render):- 未 dirty 且已有 cache → 直接贴图;
- dirty → 先
refreshParams()按需刷新派生状态(只有自身派生参数变脏时才重算点集 / 文本量测; 祖先移动导致的「只需重绘」不重算),再composeMatrix()后比较contentKey与linearKey(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 |
两点关键约定:
- 这些缓冲是普通数组(非
mat2d.create()返回的Float32Array),以保持矩阵为Array类型,兼容Array.isArray与序列化。 - 优化不得破坏语义:
calcAbsoluteLinearMatrix仍要实时重算祖先(见 03),零分配只是复用存储、不是复用缓存值。
applyStyleToCtx 同样做了零分配:用 for...in 直接遍历 props.style/state.style 写 ctx,不用 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