☰
原生 Canvas 实现梦幻树生长动画:从递归骨架到性能优化
2026/10/13 4:17:20 网站建设 项目流程

最近给一个品牌落地页做动效,试了不少方案都没撑住场子,最后是一棵在 Canvas 上慢慢长出来的梦幻树把整个页面给带活了。这事其实没想象中那么难,从零到今天跑上线,我前后差不多花了一个周末的时间,中间踩了几个挺隐蔽的坑,想通之后整个逻辑就顺了。这篇就把完整的实现思路、关键代码和避坑过程都写下来,给要做 Canvas 生长动画的同行一个参考。

先说结果:最终效果是一棵从枝干到树叶逐帧生长的树,带轻微的光影漂浮粒子,移动端 60fps,静态资源全部打成一张 PNG 和一份 JS,首屏加载无压力。整个过程没有用任何图形库,全部原生 Canvas API 手写,这也意味着所有细节都在掌控内,后期想改形态、配色、生长速度都很快。

1. 为什么放着 WebGL 和 Lottie 不用,偏选原生 Canvas

做这个项目之前我先盘点过市面上常见的动画方案。Lottie 做矢量动画确实好,但它的本质是播放预设动画帧,没法做到真正随机的动态生长过程;WebGL 能做非常炫酷的效果,可对移动端的兼容性和内存占用是个考验,尤其是一些中低端安卓机,莫名其妙的黑屏问题足够让人头疼。对于一棵树来说,它的结构有天然的递归属性和随机性,这正好是 Canvas 2D 的舒适区。

选原生 Canvas 还有一层原因,就是我对整个绘制流程有绝对控制权。从主干的倾斜角度、树枝的递归深度、树叶的密度到光影粒子的飘动轨迹,全是代码里的参数,没有黑盒。这样做的好处是,后续设计师提出"这片区域的树枝再密一点""叶子的粉色调再重一些",我只需要改参数,不用重新导出资源、不用重新走一遍动画烘焙流程。

不过裸写 Canvas 的代价也很明显:一切从零开始。没有现成的粒子系统,没有缓动函数库,没有场景树,甚至连一个简单的绘制循环都要自己搭。但这反而是好事,因为树的生长动画本身就是一个相对独立的渲染逻辑,自己写比去适配某个框架来得更干净,也更利于逐帧调试。

2. 树的形态:递归结构是核心,但光递归远不够

树这种东西在自然界里有个很重要的特征:自相似。主干长出大枝,大枝长出小枝,小枝上又长细枝和叶子,每一层都在重复类似的结构,但又有细微差别。落到代码里,最自然的思路就是递归。

2.1 从分形到伪随机:树的骨架生成逻辑

最朴素的分形树画法很简单:从底部一个点向上画一条线,顶部作为分支点,向左右各偏一定角度继续递归。这种画法能生成结构严谨的树,但看起来太"数学"了,像雪花或者西兰花,缺少自然感。真实树的每个分支点、每段长度都带一定的随机偏移,长短错落,角度也不是完全对称的。

我的做法是在递归参数里加可控的随机扰动。每个分支的长度乘一个 0.7 到 0.9 之间的随机因子,分支角度在基础角度上浮动 ±15%。这样每一根树枝都不一样,整棵树看起来就是活的。但扰动不能满幅随机,要有区间,否则树会显得很杂乱。这个区间我调了很久,最后定在长度因子 0.72~0.88、角度偏摆 12~18 度之间,观感最舒服。

另外还有一个细节:真实树的树枝不是完全直线的,带一点自然弧度。我处理这个问题时给每一段树枝加了二次贝塞尔曲线的控制点,让线段呈现轻微的弯曲,弯曲方向由父节点的生长方向决定。这一步加完之后,整棵树的视觉效果立刻提升了一个量级,从"几何图形"变成了"有点意思的画"。

2.2 树叶和细节:密度、疏密和色阶分层

树枝生成完毕,就要在末梢挂叶子。叶子不是简单地随机撒点,而是要跟着树枝末梢的分布密度走——树枝多的地方叶子密,反之则疏。这一步我是在生成骨架时收集所有末梢点的坐标,然后在每一帧根据这些点附近的树枝层级决定是否显示叶子以及叶子的大小。

叶子本身我用了多层设计。最底层是一层半透明的大色块,模拟树冠的整体氛围;中间层是中等大小的单个叶片,边缘带锯齿状;最顶层是一些小而亮的碎点,模拟阳光透过树叶洒下的光斑。颜色上不是纯绿色,而是偏暖的黄绿、带一点点洋红或粉色的混合,整体偏向梦幻感。每片叶子有个抖动参数,随风摆动,摆动幅度很小,但叠加起来树冠就有了呼吸感。

阴影和投影也重要。如果只是平面铺开,树会显得浮在背景上。我给树干根部画了一个椭圆形渐变的落地阴影,透明度很低,同时根据树枝的生长进度,阴影的范围也会同步扩大,这样树就是"长在地面上"的,而非贴了一张图。

3. 生长动画的驱动核心:不是简单透明度渐变,而是"剪枝"

做生长动画最容易想到的方法就是让整棵树从透明渐变到不透明。但这样没有"生长感"。真正让人感到"这棵树在长大"的,是视觉元素从无到有、由小到大、由短到长的连续变化。

3.1 时间驱动的逐帧生长模型

我的做法是把树的生长模拟成一个时间线上的"剪枝过程"。整个动画由主干开始,逐级向末梢推进。每一帧里,我记录当前已生长到的时间点,然后只绘制在时间点之前"应该存在"的树枝和叶子。没生长到的地方,就完全不画。

实现上,每一段树枝在生成时都记录它的"出生时间"——即这段树枝开始出现的时刻,以及"成熟时间"——即它完全长成、开始挂叶子的时刻。绘制循环里,如果当前时间小于出生时间,这段树枝跳过;如果介于出生时间和成熟时间之间,则这段树枝只画出部分长度,并做渐入处理。数学上,就是通过当前时间计算一个局部进度比例,然后用这个比例去裁剪贝塞尔曲线的长度。这里有个关键点:裁剪贝塞尔曲线不能用简单的线性比例去裁起点到终点的直线,而要调用贝塞尔曲线的参数方程求弧长比例对应的坐标点。如果直接平移点,看起来就会像树枝在滑动而不是生长。

这个设计的好处是:动画天然可回溯、可重播,而且可以很快地调节生长速度。试验阶段我经常把速度调慢 10 倍来观察某个分支的生长细节,调参效率很高。

3.2 缓动不是给整个动画用的,是给每个分支用的

很多人容易犯一个错:整棵树的动画用一个全局缓动函数,结果就是所有分支同时加速、同时减速,看起来非常机械,缺乏自然的错落感。我的做法是每段树枝的"局部进度"都有自己的缓动曲线,只是这个缓动曲线的起始时间不同。主干先动,主干动到一半的时候一级分支才开始动,一级分支又比主干慢半拍,越往末梢越延迟。这种时间差叠加出来的效果,就是肉眼看到的"从树干往树冠逐级波涌"的生长动画,非常接近延时摄影里植物真实生长的节奏。

实现这个错落感的代码很简单:每个分支在初始化时根据它的层级和父节点的进度偏移量计算一个时间延迟系数。主干的延迟是 0,一级分支延迟是 0.2 秒,二级分支在父分支基础上再加 0.15 秒,以此类推。层级越深,延迟累加越多,但每级的增量可以递减,以保证总动画时长不至于过长。

4. 光影粒子和背景氛围:从"一棵树"到"一个场景"

一棵单纯站着的树,哪怕结构再精细,也撑不起"梦幻"两个字。真正加分的是背景和氛围元素。这部分我用的是经典粒子系统。

4.1 粒子池与飘动轨迹设计

粒子数量我控制在 80 左右,每颗粒子是一个带有随机透明度、随机大小和随机色相的圆形光斑。它们在画布内做缓慢的布朗运动,带轻微的上升趋势,给人一种林间尘埃在飞舞的感觉。

实现上我用了一个粒子池数组,每个粒子有位置、速度、加速度、生命周期等属性。绘制循环每帧更新位置,并对超出边界或生命周期的粒子做回收复用,避免反复创建对象带来的 GC 抖动。粒子之间还会互相影响,不过这里的"影响"不是物理上的碰撞,而是在渲染时根据粒子距离做一个重叠提亮,这个效果能让粒子聚集处显得更柔和、更透光。

粒子飘动的轨迹不是直线,而是叠加了一个正弦函数的偏移,幅度很小,但看起来就很"自然"。这个正弦的周期和相位每颗粒子都不同,防止所有粒子整齐划一地往一边飘。

4.2 背景渐变与景深处理

背景我用了一个线性渐变,从顶部的深蓝紫色过渡到地面的暖橙色,营造出黄昏或清晨的氛围。树冠的颜色和背景是呼应的:近景的叶子亮、对比强,远景的树冠部分做模糊和暗化,模拟景深。Canvas 的 filter 属性可以做模糊,但要注意性能开销,我在移动端去掉了 filter 而改用预设的半透明图层替代,效果接近但帧率更稳定。

地面也不是纯色,我加了一层斜向的光照带,模拟太阳透过树冠洒下的光柱。光柱的透明度很低,由几层大三角形组成,伴随一个缓慢的左右摇摆动画。这样整幅画面的层次感就出来了:背景渐变、光柱、树主体、粒子依次叠加。

这里有一个取舍经验:粒子数量不是越多越好。80 颗看起来刚好,加到 200 颗视觉效果并不会翻倍,但低端安卓机的帧率会明显下滑。如果希望画面更"空灵",与其加粒子,不如调节粒子的透明度递变和大小衰减,效果来得更柔和。

5. 性能优化和上线前的最后一道坎

动画做出来只是第一步,真正让人想摔键盘的是上线前适配和压测阶段。我在这块踩了两三个特别想分享的坑。

5.1 设备像素比与模糊问题

第一版上线到真机后发现一个问题:在高分辨率屏上,树的边缘发虚。原因很简单——Canvas 绘制的像素数量没有匹配屏幕的物理分辨率。很多人知道"设 canvas.width = 窗口宽 × devicePixelRatio",但处理不当会引入另一个问题:所有坐标和尺寸都需要同步缩放,否则绘制出来的图形只有原来的一半大。

我的处理方式是设置一个全局的 scale 变量,等于 devicePixelRatio,初始化时把画布真实尺寸乘以这个比例,然后调用 ctx.scale(scale, scale),之后所有代码都按 CSS 像素坐标绘制。这样既保证了清晰度,也省去了逐个乘缩放的麻烦。需要留意的是,有些安卓机的 devicePixelRatio 高达 3,这时候如果还按 3 倍绘制,计算量会骤增。我后来把缩放上限限制为 2.5,超出部分让浏览器自己处理,实测观感上几乎没有差别,性能却回来了不少。

5.2 离屏 Canvas 缓存静态层

粒子是动态的,每一帧都要重新绘制;但背景渐变、光柱这些是静态的。如果每帧都重新计算并绘制这些静态元素,纯粹是浪费性能。我把背景、光柱、地面阴影这三层合并渲染到一个离屏 Canvas 上,初始化时只画一次。主循环里先把离屏 Canvas 整体 drawImage 到主画布,再绘制动态部分。

这一步优化带来的收益非常显著。尤其光柱那几层三角形,如果每帧重新计算路径和渐变,在低端机上直接掉到 30fps 以下;合并后动态部分只剩树枝生长和粒子更新,压力就小得多了。

5.3 切后台、切标签页后的时间错乱

这个坑特别隐蔽,而且只在真机操作时暴露:动画跑着跑着,用户切换到其他 App,再回来,树突然"瞬移"了很长一段——因为它默认把后台流逝的时间也算进了动画进度。

Web 页面在后台时计时器会暂停,但如果你用 Date.now() 做时间差驱动动画,等你回到页面时,时间差会把后台那段时间也加进来,导致动画直接跳到结尾。解决思路很粗暴但有效:监听 visibilitychange 事件,页面从后台切换回来时,重置上一次的时间戳,让下一帧的时间差趋近于 0,动画自然就从刚才的进度继续。这个坑排查的时候花了我将近两个小时,一度怀疑是设备性能抖动导致的,后来在 PC 端"切窗口再切回来"也非常稳定地复现,才恍然大悟是时间基准出了问题。

5.4 防抖与节流:重绘也要省着点用

最后上线前,我强制要求自己检查了 resize 事件的处理。窗口大小变化时,我需要重新计算画布尺寸、重新生成树的骨架、重新绘制离屏背景,这个成本是不低的。如果每次 resize 都触发,用户在移动端旋转屏幕时会明显感觉卡顿。我做了约 300ms 的防抖,并且限制重绘只在尺寸真正变化一定阈值时才触发。同时,对于超高频的滚动场景,我会暂停整个动画循环,滚动静止后再恢复,这个策略在长页面里非常关键。

6. 一份可直接运行的示例代码结构和参数调优队列

完整工程代码因为我项目里还混了一堆业务逻辑,不适合直接贴,所以下面给一个精简到只剩核心逻辑的最小示范,包含树的骨架生成、剪枝绘制和粒子更新,拿去就能跑。理解了这个结构,再看完整版基本无障碍。

6.1 核心代码骨架与注释

// 树的分支节点定义 function createBranch(startX, startY, angle, length, depth) { return { startX, startY, angle, length, depth, // 随机扰动:长度因子和角度偏移 scale: 0.72 + Math.random() * 0.16, angleOffset: (Math.random() - 0.5) * 0.5, // 记录该分支的出生时间和成熟时间 bornAt: 0, matureAt: 0, children: [], // 用于弯曲弧度的贝塞尔控制点 cpX: 0, cpY: 0 }; } // 递归生成整棵树的骨架 function generateTree(startX, startY, angle, length, depth, bornDelay) { const branch = createBranch(startX, startY, angle, length, depth); branch.bornAt = bornDelay; branch.matureAt = bornDelay + length * 0.01; // 计算弧线控制点 const bend = (Math.random() - 0.5) * 0.3; branch.cpX = startX + Math.cos(angle + bend) * length * 0.3; branch.cpY = startY - Math.sin(angle + bend) * length * 0.3; if (depth > 0) { const childLen = length * branch.scale; const leftAngle = angle + 0.3 + branch.angleOffset; const rightAngle = angle - 0.3 - branch.angleOffset; const endX = startX + Math.cos(angle) * length; const endY = startY - Math.sin(angle) * length; // 子分支出生时间错开 branch.children.push( generateTree(endX, endY, leftAngle, childLen, depth - 1, branch.matureAt + 0.1), generateTree(endX, endY, rightAngle, childLen, depth - 1, branch.matureAt + 0.1) ); } return branch; }

绘制部分的核心在于根据当前时间裁剪贝塞尔曲线。我封装了一个函数,接受起点、控制点、终点和进度 t,返回裁剪后的新终点坐标:

// 裁剪贝塞尔曲线,得到进度 t 时的坐标 function getPointOnBezier(p0, cp, p1, t) { const u = 1 - t; return { x: u * u * p0.x + 2 * u * t * cp.x + t * t * p1.x, y: u * u * p0.y + 2 * u * t * cp.y + t * t * p1.y }; }

实际绘制分支时,先判断当前动画时间是否超出 bornAt 和 matureAt,如果未到 bornAt 直接跳过;如果在两者之间,就按进度裁剪线段长度:

function drawBranch(ctx, branch, now) { if (now < branch.bornAt) return; const progress = clamp((now - branch.bornAt) / (branch.matureAt - branch.bornAt), 0, 1); // 计算贝塞尔曲线上的裁剪点 const start = { x: branch.startX, y: branch.startY }; const cp = { x: branch.cpX, y: branch.cpY }; const rawEnd = { x: start.x + Math.cos(branch.angle) * branch.length, y: start.y - Math.sin(branch.angle) * branch.length }; const endPoint = getPointOnBezier(start, cp, rawEnd, progress); ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.quadraticCurveTo(cp.x, cp.y, endPoint.x, endPoint.y); ctx.lineWidth = branch.depth < 3 ? 6 : Math.max(1, 6 - branch.depth * 1.2); ctx.stroke(); // 递归绘制子分支,注意子分支的进度需按自己的出生时间计算 branch.children.forEach(child => drawBranch(ctx, child, now)); }

这份代码并不是完整工程而是骨架示范,跑起来已经能看到树的基本生长逻辑。真正的完整代码里还有树叶绘制、粒子、背景离屏渲染、resize 处理和树的颜色渐变逻辑,篇幅太长就不贴了,核心要点上面都提到了。

6.2 参数调试顺序建议

树的最终观感像是几个参数共同作用的结果,我建议新项目按顺序调这几个维度,避免一头扎进细节里改了三个小时也没看出变化。

第一优先调的是递归深度和长度衰减率。深度决定树的繁茂上限,衰减率决定树冠的紧凑程度。长度衰减率太大,树冠会像鸡爪;太小,每层树枝都很长,树就成了一个规则的多级分叉,缺乏层次。

第二调的是角度偏摆范围。这个参数对树的形状影响最直接,太规则像几何图形,太随机像一团杂草。建议从 ±10 度起步,每次加 2 度,停下来观察。

第三调的是叶子的色阶和透明度层。树叶不是均匀撒上去的,每片叶子在透明度、大小、颜色上要有渐变,我建议至少分三档色值,冷暖交替,深度感才出得来。

最后是粒子的数量和运动幅度。粒子的作用是补氛围,不是抢视觉焦点,数量控制在画布面积的十分之一以下,运动幅度越小越显高级。

7. 最终补充的经验:动画不可回退的取舍

做完这个项目,我心里最值钱的经验倒不是某个函数怎么写,而是"动画过程中尽量只做增量绘制"这条原则。树生长的过程中,已经画出来的部分在下一帧不应该被清掉重画。如果每次都清屏重绘,那么粒子也好、树叶也好,位置都会有细微的抖动,而且在低端机上根本跑不动。

实现"增量绘制"有个前提:已经画好的部分必须是"完成后不再变化"的。对于树叶部分,我加了一个透明度从 0 到 90% 的稳定期,过了这个阶段就认为绘制完成,后续叶子即使有放大或旋转,也通过全局的叠加绘制而不是推翻重画来处理。这样每帧的重绘区域就只剩粒子层和正在生长的那一小截树枝,性能压力自然下来了。

如果你也想做类似的东西,我建议你从最简易的分形树写起,先把骨架和剪枝逻辑跑通,再逐步往上叠叶子和粒子。不要把粒子系统从一开始就加进去,否则出现问题真不知道是树的逻辑还是粒子的问题。分层调试、分层优化,这个习惯能让你少加一个通宵的班。

最后再补充一个小细节:Canvas 动画上线前,记得在真机上检查一次"系统省电模式"下的表现。部分系统会强制降低不可见动画的帧率,但这不影响我们页面本身的性能优化——该做的离屏缓存、动态绘制裁剪还是要做到位,毕竟省电模式下的掉帧,优化之后的表现会明显比不优化强很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询