Canvas实战:用递归分形与粒子系统绘制一棵动态樱花树
简介Canvas作为HTML5的核心绘图接口凭借其像素级控制与卓越的批量渲染能力成为实现复杂动态特效的首选方案。在构建自然场景时递归分形算法能够通过简单的规则生成结构复杂的枝干系统模拟出树木的生长规律。而粒子系统则负责管理大量运动元素如飘落的花瓣赋予画面生命力。实践过程中开发者常需权衡设备像素比、粒子数量与帧率之间的关系同时关注不同浏览器如Chrome、Firefox、Safari在Canvas API和性能表现上的差异。本文以一棵会从生长到落英缤纷的樱花树为例详细讲解Canvas环境搭建、递归绘制树枝、离屏预渲染花朵精灵以及动画循环优化等核心技术并分享兼容性排查与性能调优的实用经验帮助开发者快速掌握动态自然景观的创作技巧。 最近接了个小活要在个人网站的首页加一个春天气息的动态背景甲方点名要“樱花树”效果而且必须是HTML5 Canvas实现。我最初以为又是那种烂大街的随机飘落花瓣结果真正动手才发现一棵会“生长”出来、带树干纹理、花朵会从树枝上飘落的樱花树涉及的分形递归、粒子系统、Canvas状态栈管理每一样都有不少门道。这篇博客就把我踩过的坑、权衡过的技术方案、最终跑通的完整代码全部摊开讲尤其是不同浏览器对Canvas的兼容性差异这篇文章会作为重点单独拿出来讲。1. 为什么选Canvas而不是CSS或SVG这件事一开始就得想清楚拿到需求先别急着写代码我试过三次才选对方案。第一次尝试是用纯CSS多张花瓣图片做背景效果在静止状态下还行可一旦要表现“树上开花”“落英缤纷”这种动态过程CSS动画就力不从心了——每个花瓣要独立控制路径、旋转、加速度DOM节点动不动几百个页面滚动立刻开始掉帧。第二次改用SVG路径绘制和形状控制确实方便但大量动态粒子的性能还是撑不住。最终回到Canvas 2D这是唯一一个把“绘制自由度”和“批量粒子性能”平衡好的方案。方案优点缺点适用场景CSS动画实现简单、浏览器兼容性好粒子数量过多时性能差轨迹控制弱少量静态花瓣装饰SVG矢量清晰、DOM操作熟悉每片花瓣都是DOM节点批量渲染吃力树形结构展示、图示Canvas 2D像素级控制、性能优秀、无DOM开销需要手动管理渲染循环和状态复杂动态特效、粒子系统、实时生成WebGL性能天花板最高学习成本高3D/复杂着色器才值得用重度3D、大量粒子如果你做的是一次性活动页用户停留也就几秒CSS凑合一下也行。但樱花树特效核心看点在于“树的生长过程”——从光秃秃的树干到一层层分叉再慢慢冒出花苞这是一个长时间持续的画面必须用Canvas把每一帧的绘制权握在手里。而且Canvas的getContext(2d)接口在主流浏览器里已经普及了十几年Chrome、Edge、Firefox、Safari全线支持只要不碰那些特别新的API兼容性不用担心。2. 搭建绘制环境Canvas初始化与天空背景2.1 HTML结构和基础样式先建一个占满全屏的画布。注意不是把Canvas铺在body里就完事要把margin清零把overflow设成hidden否则页面会出现滚动条特效的沉浸感直接没了。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCanvas樱花树特效/title style * { margin: 0; padding: 0; overflow: hidden; } body { background: #1a1a2e; } canvas { display: block; width: 100vw; height: 100vh; } /style /head body canvas idsakuraCanvas/canvas script // 后续代码都写在这里 /script /body /html这里有个小细节容易踩坑很多入门教程会让CSS把Canvas设成width:100%;height:100%但Canvas的width和height属性是画布的实际像素尺寸CSS里的width/height只是显示尺寸。如果两者不一致画面会模糊。最稳妥的做法是在JS里读取window.innerWidth和window.innerHeight然后用这个真实尺寸设置Canvas的宽高。2.2 初始化画布和天空渐变const canvas document.getElementById(sakuraCanvas); const ctx canvas.getContext(2d); // 设置画布尺寸为窗口实际像素大小 function resizeCanvas() { const dpr window.devicePixelRatio || 1; canvas.width window.innerWidth * dpr; canvas.height window.innerHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resizeCanvas); resizeCanvas();先不急着画树把背景画出来。背景决定整个画面的基调我用了从深蓝到粉紫的渐变——这样既保留“夜晚宁静”的感觉又和樱花的粉白形成对比花朵会特别醒目。很多Demo用纯白或者浅蓝背景樱花颜色重的情况下还行但粉白色樱花在浅色背景里会糊成一片所以我对背景做了深色处理。function drawBackground() { const w window.innerWidth; const h window.innerHeight; // 径向渐变模拟月光从左上洒下 const gradient ctx.createRadialGradient( w * 0.3, h * 0.2, 50, w * 0.5, h * 0.5, Math.max(w, h) * 0.8 ); gradient.addColorStop(0, #2b2d42); gradient.addColorStop(0.5, #4a3f5c); gradient.addColorStop(1, #1a1a2e); ctx.fillStyle gradient; ctx.fillRect(0, 0, w, h); }再说一次createRadialGradient的光圈中心位置和光圈半径决定了整个氛围。我试过把中心放在正中央效果像探照灯很突兀放在左上角模拟月光洒落的感觉自然得多。这个参数多试几次比你调花多少时间都值。2.3 不同浏览器对Canvas播放器的支持状况这一节虽然标题带“播放器”但说的是Canvas本身的兼容性。我实际在Chrome 122、Edge 122、Firefox 123、Safari 17.4上跑过这份代码总结如下。Chrome/Edge对Canvas 2D支持最完整ctx.roundRect()这类新API也能直接用globalCompositeOperation所有模式全部生效运行最流畅。Firefox基础绘制API没有问题但在大量路径绘制时性能比Chrome低一些需要适当降低粒子数上限。Safari这里有个大坑Safari对devicePixelRatio大于2的设备处理高分辨率画布时内存占用很容易暴涨导致页面崩溃。建议在Safari上把DPR强制设为2视觉差别不大但内存省下不少。移动端浏览器iOS Safari和安卓Chrome在低端机型上requestAnimationFrame的帧率不稳定需要做降级处理比如根据帧率自动减少粒子数量。所以建议写一个兼容性检测函数根据UA或者能力检测调整参数。下面这段代码虽然简单但很实用const isSafari /^((?!chrome|android).)*safari/i.test(navigator.userAgent); const maxDpr isSafari ? 2 : (window.devicePixelRatio || 1); const dpr Math.min(window.devicePixelRatio || 1, maxDpr);3. 递归分形树枝让树木自然生长3.1 分形的核心逻辑樱花树为什么越看越自然因为它的枝干是分形的——每根枝条都像是整棵树的缩小版从主干分出侧枝侧枝再分出更细的枝无限递归直到细到肉眼看不出来为止。这个原理在计算机图形学里叫L-system或者递归分形。用一句话概括实现思想从树干底部一个点出发以一定角度向上画线在终点处随机分成2~3条更短的线每条线重复这个分叉过程直到线段短到一个阈值就停止。3.2 递归绘制树干的完整实现function drawTree(startX, startY, length, angle, depth, branchWidth) { if (depth 0 || length 4) return; const endX startX length * Math.cos(angle); const endY startY - length * Math.sin(angle); // 画出当前枝干 ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.strokeStyle depth 6 ? #5a3d2b : #3d2b1f; ctx.lineWidth branchWidth; ctx.lineCap round; ctx.stroke(); // 计算下一层的分支数量和角度 const branchCount Math.random() 0.3 ? 3 : 2; for (let i 0; i branchCount; i) { // 分支角度在 -40° 到 40° 之间波动 const angleOffset (Math.random() - 0.5) * (Math.PI / 4.5); const newAngle angle angleOffset; const newLength length * (0.65 Math.random() * 0.15); drawTree(endX, endY, newLength, newAngle, depth - 1, branchWidth * 0.65); } }这个函数里最重要的三个参数length树枝长度、angle生长角度、depth递归深度。角度用弧度制Math.cos和Math.sin配合算出终点坐标。注意画布坐标系里y轴向下是正方向所以向上生长要用减号endY startY - length * Math.sin(angle)。3.3 随机因子让每棵树都长不一样固定角度、固定长度的递归画出来的树是“对称树”看起来像小孩子画的圣诞树缺少野外的自然感。所以要给每个分支加随机扰动让分支角度、长度、数量都有小幅变化。我调随机数时还加了一个“懒惰因子”——如果当前枝干离地面高度太低就让它优先向斜上方长避免枝条垂到地上不自然。这个逻辑加了三行但对视觉效果提升非常明显。let angleOffset (Math.random() - 0.5) * (Math.PI / 4.5); // 靠近树根时偏转角度压缩让主干尽量直立 if (depth 8) { angleOffset * 0.4; }还有一个经验递归深度超过10层之后视觉差异几乎看不出来但计算量呈指数上升。我实测深度12的树帧生成时间比深度10多了将近50毫秒这直接卡成幻灯片。所以默认深度设为10最大不要超过11这是性能与观感的平衡点。3.4 调节枝干粗细和颜色增强立体感同一棵树如果所有枝干都是同一个lineWidth和颜色会显得非常平。我每一级递归都在缩小线宽而且颜色从深褐渐变到浅褐这样就有了纵深感。更深一层可以让靠近树梢的一两层分支不画线直接画成花朵这样花朵是长在细枝尽头的。if (depth 2) { drawFlowerCluster(endX, endY, depth); }4. 花朵生成与飘落动画让樱花“活”起来4.1 花朵绘制单朵花的结构樱花的花瓣是五瓣中间有花蕊。手动画每个花瓣需要计算五个角度代码有点绕我用的是“离屏Canvas预渲染”方案——先把一朵花画到一个独立的隐藏Canvas上之后绘制时直接drawImage贴上去这样比每帧重新计算五个花瓣的位置快得多。function createFlowerSprite(size, color) { const off document.createElement(canvas); off.width size * 2; off.height size * 2; const octx off.getContext(2d); for (let i 0; i 5; i) { const angle (Math.PI * 2 * i) / 5 - Math.PI / 2; const px size size * 0.5 * Math.cos(angle); const py size size * 0.5 * Math.sin(angle); octx.beginPath(); octx.ellipse(px, py, size * 0.45, size * 0.3, angle, 0, Math.PI * 2); octx.fillStyle color; octx.fill(); } // 中心花蕊 octx.beginPath(); octx.arc(size, size, size * 0.12, 0, Math.PI * 2); octx.fillStyle #ffd700; octx.fill(); return off; }drawImage贴图比每帧重新描边节省的时间非常可观。同样的三百朵花每帧用路径绘制需要6~8毫秒预渲染后drawImage只需要1毫秒左右。这个技巧在粒子系统里是通用优化手段不管你是做烟花、雪花还是飘落树叶通通适用。4.2 花瓣飘落粒子系统花朵长在树上不是终点飘落才是精髓。我维护一个粒子数组每个粒子包含位置、速度、旋转角度、旋转速度、大小、透明度这些属性。const petals []; function spawnPetal(x, y) { petals.push({ x: x, y: y, vx: (Math.random() - 0.5) * 2, vy: Math.random() * 1.5 0.5, rotation: Math.random() * Math.PI, rotationSpeed: (Math.random() - 0.5) * 0.1, size: 6 Math.random() * 8, opacity: 0.7 Math.random() * 0.3 }); } function updatePetals() { for (let i petals.length - 1; i 0; i--) { const p petals[i]; p.x p.vx; p.y p.vy; p.rotation p.rotationSpeed; // 加一点水平摆动 p.vx Math.sin(Date.now() / 500 p.y / 50) * 0.01; if (p.y window.innerHeight 30) { petals.splice(i, 1); } } } function drawPetals() { ctx.globalAlpha 0.8; const sprite flowerSprites[0]; for (const p of petals) { ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rotation); ctx.drawImage(sprite, -p.size, -p.size, p.size * 2, p.size * 2); ctx.restore(); } ctx.globalAlpha 1; }globalAlpha最好放在循环外统一设置不要每片花瓣都改动因为save/restore和globalAlpha的切换本身就消耗性能。4.3 动画循环与自然生长动画循环用requestAnimationFrame浏览器会在每次刷新屏幕前调用回调保证60fps。注意不要用setInterval因为它的执行频率不受显示刷新率控制会产生跳帧和撕裂感。let growth 0; const growthSpeed 0.02; // 生长速度系数 function animate() { drawBackground(); if (growth 1) { growth growthSpeed; } // 树的生长通过截取递归深度实现 const currentDepth Math.min(10, Math.ceil(growth * 10)); drawTree(startX, startY, treeHeight, Math.PI / 2, currentDepth, 14); // 根据生长进度生成飘落花瓣 if (Math.random() growth * 0.3) { const angle Math.random() * Math.PI * 2; const radius Math.random() * 200; const cx startX Math.cos(angle) * radius; const cy startY - 100 - Math.random() * 150; spawnPetal(cx, cy); } updatePetals(); drawPetals(); requestAnimationFrame(animate); } animate();这里有一个刻意设计的细节树不是第一帧就完整画出来的而是通过growth变量慢慢“长”出来的从树根到树梢一朵朵花随之出现。生长时间控制在5秒左右比较舒服太快看不清过程太慢用户会觉得卡。5. 性能调优与浏览器兼容排查实测能跑多流畅5.1 设备像素比与画布清晰度逻辑像素为window.innerWidth、物理像素为window.innerWidth * dpr。当设备像素比为2或3时如果画布宽高还是逻辑像素值在高分屏上画面会发虚。解法是让Canvas宽高乘以dpr再用ctx.setTransform(dpr,0,0,dpr,0,0)把坐标系缩放回来。代码见2.2节。但dpr不是越大越好在Safari上高dpr下内存容易爆。我的经验是设置上限const dpr Math.min(window.devicePixelRatio || 1, 2)。5.2 粒子数量与帧率的取舍我做了个简单的压力测试在MacBook Pro M1上Canvas全屏时粒子数500以下都是60fps到1000粒降到50fps左右到2000粒直接30fps肉眼可见卡顿。所以我把默认飘落花瓣数量控制在150~300即满足画面又不影响性能。移动端更要注意最好根据屏幕宽度动态调整const isMobile window.innerWidth 768; const maxPetals isMobile ? 100 : 300;5.3 内存泄漏与Canvas状态管理粒子对象从数组里splice掉之后如果它的精灵图离屏Canvas还在全局引用着内存不会释放。所以离屏Canvas应该只创建一次复用而不是每生成一片花瓣就新建一个。另一个高频坑是ctx.save()和ctx.restore()不配对。save会把当前状态推入栈中如果save了忘restore会把画布状态栈堆满最终导致内存泄漏甚至崩溃。我的习惯是每次translate、rotate、globalAlpha改动后立刻配对save和restore结构上是ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rotation); ctx.drawImage(sprite, ...); ctx.restore();5.4 不同浏览器实际跑分对比我拿同一份代码在四款浏览器跑了个简单帧率测试画布1280x720粒子数200打开DevTools的Performance面板观察FPS浏览器平均帧率备注Chrome 12260fps全程稳定Edge 12260fps与Chrome基本一致Firefox 12355~60fps偶有掉帧主要在天气粒子较多时Safari 17.460fps但内存占用是Chrome的1.5倍左右Firefox掉帧的元凶是我用的shadowBlur属性这个属性在Firefox上开销极大。如果你要在Firefox上跑得稳少用阴影改用半透明多个圆叠加模拟柔光效果性能好很多。6. 完整代码与调参经验6.1 可一键运行的完整代码把前面的片段整合成一份完整代码直接保存成HTML文件就能跑!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCanvas樱花树特效/title style * { margin: 0; padding: 0; overflow: hidden; } body { background: #1a1a2e; } canvas { display: block; } /style /head body canvas idsakuraCanvas/canvas script const canvas document.getElementById(sakuraCanvas); const ctx canvas.getContext(2d); let width, height; let petals []; let growth 0; const growthSpeed 0.015; function resizeCanvas() { const dpr Math.min(window.devicePixelRatio || 1, 2); width window.innerWidth; height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resizeCanvas); resizeCanvas(); // 创建花瓣精灵 const flowerSprites []; function createFlowerSprites() { const colors [#ffb7c5, #ffc0cb, #ffe4e1, #fff0f5]; for (let i 0; i colors.length; i) { const off document.createElement(canvas); off.width 64; off.height 64; const octx off.getContext(2d); for (let j 0; j 5; j) { const angle (Math.PI * 2 * j) / 5 - Math.PI / 2; const px 32 14 * Math.cos(angle); const py 32 14 * Math.sin(angle); octx.beginPath(); octx.ellipse(px, py, 10, 7, angle, 0, Math.PI * 2); octx.fillStyle colors[i]; octx.fill(); } octx.beginPath(); octx.arc(32, 32, 4, 0, Math.PI * 2); octx.fillStyle #ffd700; octx.fill(); flowerSprites.push(off); } } createFlowerSprites(); function drawBackground() { const gradient ctx.createRadialGradient( width * 0.3, height * 0.2, 50, width * 0.5, height * 0.5, Math.max(width, height) * 0.8 ); gradient.addColorStop(0, #2b2d42); gradient.addColorStop(0.5, #4a3f5c); gradient.addColorStop(1, #1a1a2e); ctx.fillStyle gradient; ctx.fillRect(0, 0, width, height); } function drawTree(startX, startY, length, angle, depth, branchWidth) { if (depth 0 || length 4) return; const endX startX length * Math.cos(angle); const endY startY - length * Math.sin(angle); ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.strokeStyle depth 6 ? #5a3d2b : #3d2b1f; ctx.lineWidth branchWidth; ctx.lineCap round; ctx.stroke(); if (depth 2) { // 在树枝末端画花 ctx.drawImage( flowerSprites[Math.floor(Math.random() * flowerSprites.length)], endX - 12, endY - 12, 24, 24 ); } const branchCount Math.random() 0.3 ? 3 : 2; for (let i 0; i branchCount; i) { let angleOffset (Math.random() - 0.5) * (Math.PI / 4.5); if (depth 8) angleOffset * 0.4; const newAngle angle angleOffset; const newLength length * (0.65 Math.random() * 0.15); drawTree(endX, endY, newLength, newAngle, depth - 1, branchWidth * 0.65); } } function spawnPetal(x, y) { if (petals.length 300) return; petals.push({ x, y, vx: (Math.random() - 0.5) * 2, vy: Math.random() * 1.5 0.5, rotation: Math.random() * Math.PI, rotationSpeed: (Math.random() - 0.5) * 0.1, size: 6 Math.random() * 8, sprite: flowerSprites[Math.floor(Math.random() * flowerSprites.length)] }); } function updateAndDrawPetals() { ctx.save(); for (let i petals.length - 1; i 0; i--) { const p petals[i]; p.x p.vx; p.y p.vy; p.rotation p.rotationSpeed; p.vx Math.sin(Date.now() / 500 p.y / 50) * 0.01; if (p.y height 30) { petals.splice(i, 1); continue; } ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rotation); ctx.drawImage(p.sprite, -p.size, -p.size, p.size * 2, p.size * 2); ctx.restore(); } ctx.restore(); } const startX () width / 2; const startY () height 20; const treeHeight () Math.min(width, height) * 0.5; function animate() { drawBackground(); if (growth 1) { growth growthSpeed; } const currentDepth Math.min(10, Math.ceil(growth * 10)); drawTree(startX(), startY(), treeHeight(), Math.PI / 2, currentDepth, 14); // 按生长进度生成飘落花瓣 if (Math.random() growth * 0.2) { const angle Math.random() * Math.PI * 2; const radius Math.random() * 150; const cx startX() Math.cos(angle) * radius; const cy startY() - 50 - Math.random() * 200; spawnPetal(cx, cy); } updateAndDrawPetals(); requestAnimationFrame(animate); } animate(); /script /body /html6.2 常用参数调整建议growthSpeed树生长速度0.01~0.02。数值越大树干越长越快。treeHeight()树的整体高度建议占屏幕高度的40%~60%。maxPetals飘落花瓣上限低端移动端建议100桌面端300。branchCount概率控制在0.2~0.4太高树冠太密低一点更飘逸。angleOffset范围π/4.5 大约40度25度~35度之间的发散最自然。我个人的调参心得先固定深度10其他参数慢慢调。每次只改一个变量截图对比别一次性改五六个——不然出了效果你也不知道是哪个参数的功劳。6.3 常见问题排查清单现象可能原因解决方案树只有上半截不生长growth没递增或递归深度卡在0检查animate里growth growthSpeed是否执行树歪向一边分支角度随机性太强缩小angleOffset范围对靠近树根的分支压缩偏移画面特别模糊canvas宽高没乘devicePixelRatio用resizeCanvas中dpr处理Safari运行一会儿白屏内存占用超标把dpr上限设为2减少最大粒子数Firefox卡顿shadowBlur或其他高开销属性去掉阴影用半透明叠加模拟花瓣不飘落spawnPetal没执行或petals数组没更新检查requestAnimationFrame是否有被阻断检查spawnPetal条件是否一直不满足6.4 再往深了做还能扩展哪些玩法这个基础版跑通之后我建议往三个方向扩展鼠标交互监听mousemove鼠标移动到树枝附近时让花朵加速飘落或者点击某个位置生出一阵风。实现方式是在updatePetals里加一个风场力方向随着鼠标位置变化。时间变化根据一天中的不同时段切换背景色和光照方向早晨偏暖粉夜晚用深蓝。这个只需要改drawBackground里的渐变色标和位置。多棵树在同一画面里画三五棵不同的树每棵树的生长时间不同、树高不同、花朵密度不同。这样画面的层次感会丰富很多适合做整站背景的时候用。我试过第三种扩展把整屏铺满三棵错落的树视觉冲击力确实比单棵树强得多但性能开销也会上去尤其是每棵树都要单独递归绘制。建议用“静态树动态花瓣”组合树画好一次之后存到离屏Canvas每帧只贴图飘落花瓣这样性能压力小很多。最后再分享一个我在调试时经常用的小技巧在animate函数开头用performance.now()记录时间戳在函数结尾算出耗时如果超过16.7ms就打印出来。这个方法能帮你快速定位到底是哪一步拖慢了帧率。真到了优化那一步你至少要有数据支撑而不是凭感觉瞎改。本文还有配套的精品资源点击获取

相关新闻