ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从零构建Scratch积木渲染引擎:Canvas 2D性能优化实战

从零构建Scratch积木渲染引擎:Canvas 2D性能优化实战 这个标题我斟酌了很久。“全站最强”四个字放上去按照社区惯例是要挨喷的所以我加了个“应该”算是把嚣张和心虚同时亮了出来。最近刷热搜的时候看到一个很有意思的词条build a large language model (from scratch) 中文版。里面的 from scratch 跟我们玩儿的 Scratch 完全是两码事一个是“从零构建大模型”一个是 MIT 那个积木编程平台。但有趣的地方也在这里——Scratch 这个平台的名字本身就有“从零开始、即兴拼搭”的含义。我做的这套积木渲染某种意义上也算是一次 from scratch从最底层的一条 Path、一个色值开始重写了一整套 Scratch 积木的绘制方案。这套东西做完之后我自己在本地实测了各种极限场景500 个积木同时渲染、拖拽、磁吸、发光、高亮全部稳定满帧。所以我把标题起成这样。但我也知道全站这么大肯定藏着各种大佬说不定明天就有人甩我一个“这个阴影算法还能再提 20%”的狠活。那也没关系把方案写出来本身就是交流。1. 这个“应该”是怎么回事积木渲染到底难在哪1.1 “from scratch”的巧合以及我为什么敢说“最强”先说结论我并不是要给官方 Scratch 渲染引擎挑刺官方在 Web 场景下已经做得很成熟了。我是在做一个积木数量特别大、交互特别频繁的场景时发现原有的渲染方案开始吃力——拖拽一多、积木一多会出现明显的掉帧和漂移感。这时候你有两条路一条是在原方案上打补丁哪里卡优化哪里另一条是把渲染这一层彻底抽出来自己写一套可替换的渲染内核。我选了第二条。原因很直接积木渲染本质上是一个“区域重绘”和“几何构建”的问题只要把底层绘制逻辑抓在自己手里性能上限就完全由你自己的算法水平决定而不是被第三方库的边界卡住。这也就是标题里“应该”的底气来源。我不敢说自己做的渲染一定是最优解但在我看过的开源方案和社区作品里能把积木的几何细节、颜色层次、动效流畅度同时做到这个水平的确实不多。1.2 积木不是矩形渲染的隐藏复杂度很多人第一次写 Scratch 编辑器时会以为积木就是一个带圆角的矩形顶多加个边框。等你真正做起来才发现事情完全不是这样。一个标准积木块的截面至少有四类几何特征第一顶部凹槽。积木上方朝下的嵌入区域让下一个积木能插进来这个凹槽在视觉上是“被挖掉”的一块需要和上一个积木的底部凸出形成互补。第二底部凸出。对应上一个积木插入的“插头”宽窄、圆角都要和凹槽严格匹配差一个像素都会在视觉上出现一条难看的缝隙或重叠。第三左侧凸榫和凹槽。用于横向连接前一个积木运动类积木多一个圆角凸榫控制类 C 形积木则在内侧有个凹口。这些形状全是非矩形路径只能靠矢量曲线构建。第四整体圆角和 3D 厚度。积木不是平面的它有一条亮面高光、一个暗面底边、一层外轮廓投影共同形成“塑料感”。也就是说你在界面上拖动的每一块积木其实都是一个精心设计过渐变方向、阴影层级和几何公差的复杂图形。渲染引擎的核心工作就是把这一整套几何和视觉规则稳定地、高速地重复绘制出来。1.3 官方渲染已经很好重做一套图什么那官方渲染都做了我为啥还要再造轮子因为官方方案面向的是“通用编辑器体验”它要平衡兼容性、可维护性和开发效率这不代表它在任何场景下都是最优解。举个例子官方默认用的渲染方式在单个积木上效果非常棒视觉还原度极高但当你需要在一个自定义工具里同时展示上千个积木比如代码块总览、积木地图、或者教学大屏展示并且希望拖拽依然跟手时通用方案就开始吃力了。这时候你需要的是一个更贴近底层、更“暴力”的渲染内核——用 Canvas 把所有积木一次性画出来而不是让浏览器为一个积木维护一个独立的渲染节点。另外还有一个更实际的需求自定义积木。我想给不同类型的积木加自定义主题色、自定义阴影风格甚至是完全自定义的积木形状而这些在官方渲染上动起来非常麻烦。自己写一套一切皆可配置这个自由度是值得付出开发成本的。2. 技术路线DOM/CSS、SVG 与 Canvas怎么选2.1 三条路线的能力对比我在动手之前把三条主流方案都试了一遍这里直接给大家一个对比表省得你们再踩一遍我试错的坑。方案视觉还原度大量积木性能内存占用动画灵活性开发复杂度DOM CSS高较差300 个积木后明显卡顿高每个积木都是独立节点低只能用 CSS transform低SVG官方方案很高中等受 DOM 节点数影响中每个图形也是节点中可做 CSS 动画中Canvas 2D高需要自己画极好千级积木仍可满帧低一张画布搞定高逐帧绘制想怎么变就怎么变高这里要说明一点官方用 SVG 是基于整体技术栈考虑的在节点数可控的场景下它完全够用而且 SVG 天生支持事件逐元素绑定对编辑器这种交互密集型场景非常友好。我不否定官方选型我只是说在“积木数量极大”这个极端场景下Canvas 更有优势。2.2 选择 Canvas2D 的四个依据我最后选了 Canvas2D而不是 WebGL原因有四个。第一积木渲染是典型的 2D 矢量图形问题不涉及复杂纹理和光照模型Canvas2D 的 Path API 天然匹配不需要引入着色器开发成本。第二Canvas2D 对 Path2D 的支持已经相当成熟我可以把积木的轮廓线缓存成一个 Path2D 对象每次绘制直接复用不需要重复构建几何路径。这一点对性能的影响是决定性的。第三Canvas2D 的像素级控制能力足够强。我要做的高光、渐变、阴影、发光效果全部可以通过 ctx 的渐变和 shadow 属性实现不需要额外写后处理。第四兼容性好。不管目标浏览器是哪家Canvas2D 都是最稳的选择不用担心 WebGL context 被抢占或者老机器 GPU 驱动出问题。2.3 混合架构Canvas 画DOM 管事件选型确定之后最容易被忽略的是事件层怎么设计。很多初次尝试 Canvas 渲染的人会栽在这里积木是画在 Canvas 上的它不是 DOM 节点没法直接监听点击、拖拽、鼠标移入。我采用的方案是Canvas 只负责渲染事件层用透明的 DOM 层或者全局事件监听来做再通过坐标换算确定命中了哪个积木。具体做了三件事第一维护一份积木对象的逻辑列表每个积木存有位置、尺寸、旋转角度和几何类型。第二为每种积木实现一个数学化的命中测试函数。积木不是矩形点击“外部镂空”的部分不算命中所以不能用简单的矩形包围盒。判断时需要利用构建 Path 时用到的圆弧和线段数据做真实的点在路径内判断。第三在鼠标移动时拿到 Canvas 相对坐标遍历命中测试找到最上层的积木对象作为当前操作目标。这套混合架构的好处是渲染性能完全由 Canvas 决定而交互逻辑还保留着类似 DOM 的“目标对象”思维开发体验很舒服。3. 把“塑料积木”画出来几何、配色、亮度与高光3.1 一条关键的 Path凹槽、凸榫与圆角先来拆解积木最核心的几何构造。一个标准命令积木块的左侧轮廓可以用一条连续路径来描述从积木左上角开始向右画顶边然后往内部画一个顶部凹槽——凹槽底部要有圆角不能是直角接着向右继续到右上角向下画右侧边到底部左侧画底部凸出——凸出底部也要圆角处理最后回到左侧经过一个凸榫或凹榫结构闭合路径。这段路径如果自己从零开始写最麻烦的是圆弧方向很容易搞反。Canvas2D 里ctx.arc()默认是顺时针方向但凹槽的圆角和凸出的圆角需要的是相反的弧线方向。如果方向写反绘制结果会多出一块奇怪的“鼓包”。我建议先把积木的几何拆解成几个独立的子路径分别调试主躯干一个带圆角的圆角矩形顶部凹槽在躯干顶部“挖”一个圆角矩形底部凸出在躯干底部“补”一个圆角矩形左侧榫结构根据积木类型选择凸榫或凹榫然后用Canvas2D的 Path2D 把它们组合起来。组合时要用Path2D.addPath()把子路径合并而不是简单地在同一段 path 上连续 moveTo这样后面做离屏缓存和命中测试都更方便。3.2 一组“颜色”而非一个颜色积木的完整配色体系这是很多渲染作品看起来“假”的核心原因他们用一个纯色铺满了整个积木。真实 Scratch 积木或者更准确地说任何有“塑料感”的 UI 组件都不可能是一个颜色。一块积木至少包含五个颜色层主色、亮面色、暗面色、描边色和阴影色。主色是积木的“身份色”亮面色通常在积木上边缘和左侧边缘模拟光源从左上角照下来暗面色在底边和右侧模拟厚度描边色勾勒外形轮廓阴影色负责投影让积木浮在画布上方。我整理了一套公开可查的 Scratch 分类主色作为基础色大家画积木时可以直接用积木类别主色运动#4C97FF外观#9966FF声音#CF63CF事件#FFBF00控制#FFAB19侦测#5CB1D6运算#59C059变量#FF8C1A自制积木#FF6680注意这只是主色。亮面和暗面不能直接用白色或黑色去叠加而应该在主色的基础上调整明度否则会得到一种很“脏”的塑料质感。3.3 “scratch亮度”搜索词背后的渲染算法如果去搜“scratch亮度”你会发现大量相关词条但大部分是问积木颜色亮度的调整方法的。这个需求恰恰就是积木渲染里最核心的颜色衍生算法。我的做法是先把主色从 Hex 转为 HSL然后通过调整 Lightness亮度通道来生成一组衍生色再把这些衍生色填充到积木的不同部位。具体调参逻辑如下亮面色L 8 到 15饱和度 S 稍微降低一点点-3 左右模拟光照带来的“褪色感”。主色原始 L 不变但可以整体略微提高 S让积木看起来更鲜活。暗面色L - 12 到 20S 可以适当提高5视觉上更厚重。描边色L - 25 到 30需要压得很暗但注意不要直接用黑色否则会像硬纸板剪出来的。阴影色在暗面色的基础上再压暗 15%配合透明度实现投影。核心逻辑可以简化成一段伪代码function getShade(hex, lOffset, sOffset) { const hsl hexToHsl(hex); hsl.l clamp(hsl.l lOffset, 0, 100); hsl.s clamp(hsl.s sOffset, 0, 100); return hslToHex(hsl); }我当时用这套算法跑了所有分类主色生成出来的暗面、亮面、描边色自动就形成了一套非常协调的配色不需要人工微调。这就是“一组颜色”而不是“一个颜色”的具体含义。3.4 阴影与高光的绘制顺序以及 shadowBlur 性能陷阱画积木时绘制顺序非常重要顺序错了视觉层级就崩了。我的绘制顺序是投影 → 暗面 → 主色渐变 → 亮面高光 → 内部细节 → 描边。投影最先画因为它位于最底层描边最后画因为它要把整个积木轮廓重新勾勒一遍压住前面所有图层的边缘。高光的具体画法是在积木上边缘画一条 2 到 3 像素的半透明白色条带透明度大概 35%。这条高光条不能是简单的直线要配合积木顶部凹槽的弧度做同样的弯曲否则高光会在凹槽处断裂非常奇怪。这里要特别强调一个性能陷阱ctx.shadowBlur。Canvas2D 的 shadowBlur 用起来很爽一行代码就有柔和投影但它的性能开销非常大。在绘制大量积木时每一个 shadowBlur 都会触发额外的离屏模糊计算帧率会肉眼可见地往下掉。我的替代方案是阴影效果改用“径向渐变圆角矩形”或“偏移 多层半透明矩形”来模拟效果接近性能却稳定得多。如果非要用 shadowBlur那就只在“当前正在拖拽的积木”上开其他静态积木一律禁用。4. 让积木“动”起来拖拽、磁吸和执行态发光4.1 拖拽渲染不再全量重绘脏矩形与离屏缓存积木编辑器最大的性能考验就是拖拽。一个积木在鼠标下方移动理论上整个画布都可能变化如果你每一帧都重绘全部积木500 个积木时帧率会直接崩到 20 以下。我采用的第一个方案是“脏矩形”记录上一帧和当前帧需要更新的区域只重绘这些区域其他的保持不动。具体实现时要注意Canvas 不像 DOM 那样可以自动处理局部更新你需要手动去做三件事第一用ctx.save()和裁剪路径把绘制范围限制在脏矩形内。第二清空脏矩形区域时不要用clearRect清全屏只清当前脏矩形。第三重绘时所有和脏矩形有交集的积木都要重新画一遍而不仅仅是正在拖拽的那个。“脏矩形”说难不难说简单也不简单最麻烦的是多个积木同时移动时脏区域会变成一个很大的合并矩形性能优势会显著下降。所以我又加了一层“离屏缓存”把那些不动的积木预先渲染到一个离屏 canvas 上每一帧先把整块离屏 canvas 按对应位置 drawImage 到主画布再把“正在动”的积木单独画上去。这样静态积木部分的绘制成本从“数百个 Path 填充”降为“一次位图 blit”性能直接跃升一个量级。4.2 磁吸动画距离阈值、插值与缓动积木拖拽到接近插槽位置时需要自动吸附。这个效果做得好不好直接决定编辑器的“手感”。我踩过的最大的坑是直接让它瞬间吸附。视觉上非常突兀感觉像是积木被“吸”过去而不是“滑”过去。真正舒服的效果需要一段几十毫秒的过渡动画。实现思路如下第一每次拖拽移动时遍历所有可插入的目标位置计算当前积木插入点与目标插槽的距离。第二当距离小于一个阈值我调的是 12 像素就进入“磁吸预备”状态。第三记录起始位置和目标位置在接下来的 80 到 100 毫秒内用缓动函数完成插值。缓动函数我推荐easeOutBack它会先稍微冲过头一点再回弹到位非常有“积木入槽”的弹性手感。如果嫌回弹太夸张可以把 overshoot 参数降到 0.2。4.3 选中态和执行态的发光效果积木除了静态外观和拖拽动效还有两个状态需要特殊渲染选中态和执行态。选中态点击积木时需要让它明显“亮”起来。我用的是外发光效果做法是绘制一个和积木路径完全相同、但略微放大的半透明发光层颜鱼使用积木主色透明度 30%叠加在积木底层。执行态积木正在被程序运行时需要一种“脉冲”效果。我做的是在积木外轮廓上循环绘制一圈光晕光晕的透明度按正弦函数周期性变化。注意这一圈光晕不能用 shadowBlur而是用ctx.lineWidth加粗描边 半透明颜色来实现性能稳定得多。另外用户如果按下鼠标但还没开始拖拽积木会进入“预选中”状态。我会把积木整体亮度提一格模拟“被轻轻抬起”的感觉这个细节对提升手感帮助非常大。5. 性能实测500 个积木从 15fps 到满帧5.1 先说基线数据所有优化做完之前我把 500 个积木铺在画布上每一帧全量重绘测出来的结果非常难看平均帧耗时约 33ms对应帧率约 30fps且不稳定拖动时掉到 15fps表现为积木拖拽明显滞后像在水里划这个数据其实不算意外毕竟 500 个积木每个积木都有复杂的 Path、渐变和描边全量重绘的计算量是实打实的。5.2 三个决定性的优化动作优化动作按收益排序我做了三件事第一离屏缓存静态积木。把画面中不动的积木烘焙到一张或多张离屏 Canvas 上。这一步效果最炸裂直接让拖拽场景里的静态部分绘制成本忽略不计。第二引入脏矩形机制。只重绘变化区域清屏范围从全画布缩小到局部。在“单个积木拖拽”场景下重绘面积通常只有全画布的四分之一甚至更小。第三禁用不必要的 shadowBlur。所有静态积木用“偏移 渐变”模拟投影只在拖拽中的积木上保留真实阴影甚至拖拽积木的阴影也用半透明矩形模拟彻底告别 blur 的额外开销。优化后同样场景下的数据平均帧耗时约 4.5ms含拖拽积木对应帧率稳定 60fps120Hz 显示器上接近满帧表现为500 个积木拖动跟手无拖影、无闪烁5.3 帧率测试的正确打开方式顺便讲讲帧率怎么测才靠谱。Chrome DevTools 的 FPS 面板可以看趋势但会受后台任务影响不够精确。我自己的方法是写一段最小测试脚本用requestAnimationFrame统计每一帧的耗时计算最近 60 帧的平均帧耗时再换算成帧率。let frames []; let lastTime performance.now(); function loop(now) { frames.push(now - lastTime); lastTime now; if (frames.length 60) frames.shift(); const avg frames.reduce((a, b) a b, 0) / frames.length; const fps 1000 / avg; // 只在控制台输出不在页面上实时渲染文字避免测出的帧率是“带 UI 的帧率” console.log(fps: ${fps.toFixed(1)}); requestAnimationFrame(loop); } requestAnimationFrame(loop);唯一要注意的是测试过程中不要开太多 Chrome 插件插件注入的脚本会影响帧率稳定性。6. 踩坑记录文档不会告诉你的五个细节6.1 1 像素圆角偏差拼出白缝积木拼接时上一个积木的底部凸出要插进下一个积木的顶部凹槽。我最初画两个积木时圆角半径都是 8px但拼接处就是有一条接近 1 像素的白色缝隙。排查了半天发现问题出在凹槽和凸出的坐标系舍入上。Canvas 是物理像素渲染坐标取整时可能向下取整或四舍五入导致两个积木的路径一个向左偏、一个向右偏。解决办法是所有积木位置坐标在计算时先取整到像素再开始绘制凹槽和凸出共享同一个圆角半径常量不允许各自微调。6.2 canvas 文字糊不是分辨率的问题给积木绘制文字标签时一开始发现文字发虚以为是字体加载或者字号太小的问题。折腾了很久才意识到是 devicePixelRatio 的问题。Canvas 在高清屏上需要把画布的物理像素尺寸设为 CSS 尺寸乘以 devicePixelRatio否则会被拉伸模糊。解决方法是在初始化时对 Canvas 进行缩放适配const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这个操作必须在任何绘制之前做一旦绘制开始再改 transform所有坐标都会乱。6.3 磁吸动画被拖拽打断后的抖动积木在磁吸动画进行中如果用户突然又拖动它很容易出现抖动。原因是最初进入磁吸状态后我立刻把积木坐标绑定到了动画插值结果上用户一接管鼠标位置插值结果和真实鼠标位置发生冲突导致积木反复横跳。解决办法是给磁吸动画增加一个“可打断”标记只要用户拖拽位移超过 2 像素立即终止磁吸动画把积木坐标重新绑定到鼠标位置不做任何平滑过渡。6.4 清屏没清干净拖出残影另一个高频 bug 是拖拽积木时会留下残影。排查来排查去问题出在脏矩形尺寸计算不准我把积木当前位置的包围盒算出来了但没有算上阴影的扩散范围导致阴影部分没有被清掉上一帧的残影就留了下来。建议在计算脏矩形时统一为积木包围盒加上一个安全边距我设置的是 16 像素既覆盖了阴影又不会让重绘面积膨胀太多。6.5 60Hz 之外高刷屏带来的新问题我最初是拿 60Hz 的笔记本测试的一切正常换到一台 120Hz 的电脑上发现磁吸动画变得明显偏快。原因很简单动画的插值进度如果按照帧数计算而不是按时间计算帧率越高动画就越快。你在 60Hz 上写了 6 帧完成动画到 120Hz 上就变成 3 帧完成。统一的解决方案是用时间戳驱动动画记录动画开始时间每帧通过(now - startTime) / duration计算进度而不是用“帧计数 / 总帧数”计算进度。这样无论屏幕是 60Hz 还是 144Hz动画时长都保持一致。做完这套渲染之后我最大的体会有两点。一是性能优化永远是“先测再说”的事情不要凭感觉优化二是积木渲染的难点其实不在绘图 API 本身而在几何路径的构建细节和颜色体系的统一管理。如果你也打算自己写一套 Scratch 积木渲染建议从三块积木开始一个普通命令积木、一个 C 形控制积木、一个帽子块。把这三个类型的几何和配色跑通整个体系的基本盘就立住了。至于“应该最强”这个说法就当是我给自己的一个目标吧等哪天看到更狠的方案再改标题也不迟。
返回列表