也能做!Canvas梦幻树生长动画从0到上线(附完整代码避坑指南)

该文章已生成可运行项目,

小白也能做!Canvas梦幻树生长动画从0到上线(附完整代码避坑指南)

开篇:设计师能玩儿的,咱前端凭啥不行?

说真的,每次看到Dribbble上那些花里胡哨的动画效果,我心里都犯嘀咕——这玩意儿是不是得让UI设计师用After Effects做半个月,然后丢给个GIF给我们前端省事?直到有天半夜两点,我刷到一个会生长的树状动画,树枝像有生命一样分叉、延展,还带着那种朦朦胧胧的光晕效果,我当场就睡不着了。

凭啥认为这是设计师专属? Canvas就在那儿躺着呢,2D上下文API一直都是咱们前端的标准装备,只是大部分人拿它画个柱状图、饼图就觉得完事了。太浪费了,真的。这玩意儿能干的事多了去了,生成艺术、粒子系统、甚至是简单的游戏引擎,核心都是这块"破布"。

本文我要做的,就是把这个看起来高大上的"梦幻树"效果彻底扒光,从数学原理到代码实现,从性能优化到移动端适配,全部给你整明白。别怕,真·有手就行,只要你写过几行JavaScript,看完这篇文章明天就能在你的个人博客里挂上一棵会动的树。

更重要的是,我会把我自己踩过的坑、走过的弯路、甚至是一些"这代码明明看着对为啥就是不出效果"的玄学问题,全部倒出来。不是那种官方文档式的照本宣科,而是实打实的血泪经验。

缘起:那天半夜我刷到了啥?

事情是这样的。上周三凌晨一点半,我本该在睡觉,结果手贱点开了某个技术分享站。首页Banner里,一棵树从屏幕底部慢慢长出来,枝干分叉,然后开出一朵朵发光的小花,整个过程丝滑得像德芙。我第一反应是:“这得用WebGL吧?Three.js?shader代码是不是得写几百行?”

结果右键检查元素——淦,就是个canvas标签。没有WebGL,没有引入任何重型库,纯粹的2D Context,甚至连图片资源都没有,全是代码生成的。

那一刻我意识到自己陷入了一个误区:总觉得要出视觉效果就得往上堆技术栈,恨不得把GPU计算、物理引擎全搬出来。其实这个树效果的核心原理异常朴素:递归+随机数。就是高中数学那个递归,加上Math.random(),没了。

但朴素不代表简单。要让树看起来像树,而不是像电路板或者鸡爪子,里面的门道还是不少的。比如树枝的生长角度怎么控制才能自然?颜色怎么过渡才梦幻而不是杀马特?这些细节才是决定成品质感的分水岭。

Canvas这破布到底是啥?别被名字吓到

先给完全没接触过Canvas的同学(或者说接触过但只是画过 rectangle 的同学)快速补个课。

Canvas翻译成中文叫"画布",这个名字挺误导人的,让人觉得它像Photoshop一样是个图像编辑工具。实际上它更像是一张位图(Bitmap),你可以通过JavaScript在上面绘制像素。一旦画上去,那就是死的像素点,不是可编辑的矢量对象。这跟SVG有本质区别——SVG是基于DOM的,画出来的每个形状都是元素,可以单独绑定事件、修改属性;Canvas则是一次性涂鸦,画完拉倒。

// 基础 setup,每个 Canvas 项目都这么开始
const canvas = document.getElementById('treeCanvas');
const ctx = canvas.getContext('2d');

// 高清屏适配,这行代码不写,在手机上糊成一坨
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.scale(dpr, dpr);

// 现在 ctx 就是画笔了,所有绘制操作都通过它

注意上面那段高清适配代码,这就是第一个坑。如果你不处理devicePixelRatio,在Retina屏上画出来的线条全是虚的,锯齿严重得让人怀疑人生。很多初学者直接设置canvas.width = window.innerWidth,然后在MacBook上一看,效果感人。

说白了就是在画分形,但加点随机就_MAGIC_

好了,核心原理时间。这棵树本质上是个分形(Fractal)。啥是分形?就是那种"整体和局部形状相似"的图形,比如雪花、海岸线、云朵。树也是典型的分形结构——大枝干分叉成小枝干,小枝干再分叉成更小的,循环往复。

最基础的分形树长这样:一条线段,末端分出两条线段,然后每条新线段的末端再分出两条,递归下去。如果用纯代码描述,核心就是个递归函数:

function drawBranch(startX, startY, length, angle, depth) {
  if (depth === 0) 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.stroke();
  
  // 递归画左右两个分支
  drawBranch(endX, endY, length * 0.7, angle - 0.5, depth - 1);
  drawBranch(endX, endY, length * 0.7, angle + 0.5, depth - 1);
}

但这样画出来的是什么?是一把叉子,还是特别规整的那种。自然界没有这么对称的树,所以在每个分叉点引入随机因素才是关键。长度随机、角度随机、甚至连分叉的数量都可以随机(有时候分出3个枝也不是不行)。

而且真实的树生长是有过程的,不是瞬间出现的。这就涉及到动画实现。你不能一次性递归到depth=0,而是要一帧一帧地"长"出来。这就引出了下一个关键点:requestAnimationFrame

动画流畅的秘诀:别再用setInterval了,求你了

我见过太多人做动画还在用setInterval或者setTimeout,然后跑过来问:“为啥我的动画一顿一顿的?”

兄弟,setInterval的定时器是不准确的。浏览器的事件循环机制决定了setInterval的回调可能不会被精准执行,尤其是当主线程忙碌的时候(比如你在挖煤…哦不,在解析JSON)。更重要的是,setInterval不管你屏幕的刷新率,硬着头皮往上画,结果就是掉帧。

requestAnimationFrame(以下简称rAF)是浏览器专门给动画准备的API。它跟屏幕刷新率同步(通常是60fps,高刷屏可能是120fps or 144fps),而且当页面不可见(比如切换了标签页)时,它会自动暂停,省电。这多贴心啊。

let startTime = null;

function animate(timestamp) {
  if (!startTime) startTime = timestamp;
  const progress = timestamp - startTime;
  
  // 清屏,准备画新一帧
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // 根据 progress 计算当前应该画到第几层了
  const currentDepth = Math.min(maxDepth, Math.floor(progress / 500));
  
  drawTree(currentDepth);
  
  if (currentDepth < maxDepth) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);

这里有个细节:清屏操作。很多人习惯用fillRect(0,0,width,height)然后用白色填充来"清屏",这在大多数情况下没问题,但如果你想做那种"拖尾效果"(就是旧帧慢慢淡出而不是完全消失),fillRect就力不从心了。而对于我们这种需要完全重绘的场景,clearRect是更好的选择,性能稍微好那么一丢丢,虽然现代浏览器优化后差别不大,但养成好习惯嘛。

递归:自己调用自己,代码看着晕但效果酷

递归这东西,对新手来说挺反直觉的。函数在执行过程中调用自己?不会死循环吗?会,所以一定要有终止条件(base case)。

在我们的树生长动画里,递归的终止条件就是深度(depth)归零,或者长度小于某个阈值。每一层递归,树枝的长度会衰减(通常是乘以0.7或0.8),角度会偏移。

但这里有个坑:JavaScript的调用栈深度是有限的。一般来说,几千层递归就会报"Maximum call stack size exceeded"。虽然我们画树不太可能用到几千层(通常7-10层就够了,不然密得看不见),但如果你要做那种无限生长的效果,就得考虑用迭代+队列(Queue)的方式来代替递归,也就是所谓的BFS(广度优先搜索)模式。

不过对于初学者,递归方式最直观,代码也最简洁。我们先搞定递归版,后面再聊优化。

L-System?算了算了,咱土法炼钢更实在

如果你去搜"程序化生成树",十有八九会跳出来L-System(Lindenmayer System)这个概念。这是个正经的数学形式化语言,用字符串替换规则来模拟植物生长。比如规则是:F -> FF+[+F-F-F]-[-F+F+F],然后不断迭代,最后把字符串解析成绘图指令。

老实讲,L-System很强大,但对于咱们这种小demo来说太重了。你需要先实现一个字符串迭代器,然后做个语法解析器,还要考虑状态栈(因为方括号代表分支的开始和结束)。等你把这一套撸完,热情都消磨光了。

我的建议是:手搓法(Ad-hoc)。直接写逻辑判断,哪根枝往左偏,哪根往右偏,全用硬编码的随机数控制。虽然不够"学术",但可控性极高,你想让树长成啥样就能长成啥样,不用受制于L-System的语法规则。

比如你想要一个"风吹效果",让树整体往右倾斜,在L-System里你得改产生式规则;在手搓代码里,你只需要在计算角度时加个windOffset变量,完事。

// 手搓法的核心:一个配置对象走天下
const config = {
  branchAngle: Math.PI / 4,  // 分叉角度基础值
  angleRandom: 0.2,          // 角度随机波动范围
  lengthDecay: 0.75,         // 长度衰减系数
  minLength: 5,              // 最小枝长度,小于就停止递归
  growthSpeed: 2,            // 每帧生长的像素数
  windForce: 0               // 风力偏移量,可动态调整
};

function drawBranch(x, y, length, angle, depth) {
  if (length < config.minLength || depth <= 0) {
    // 画叶子,如果到了末梢
    drawLeaf(x, y);
    return;
  }
  
  // 风力影响,让整棵树可以随风摇摆
  const windOffset = Math.sin(Date.now() / 1000 + depth) * config.windForce;
  const actualAngle = angle + windOffset;
  
  const endX = x + length * Math.cos(actualAngle);
  const endY = y + length * Math.sin(actualAngle);
  
  // 根据深度控制线条粗细,越细的枝颜色越浅
  ctx.lineWidth = depth * 0.8;
  ctx.strokeStyle = `hsl(30, 60%, ${20 + depth * 5}%)`; // 棕色系
  
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(endX, endY);
  ctx.stroke();
  
  // 随机分叉,有时候分2个,有时候分3个,更自然
  const numBranches = Math.random() > 0.8 ? 3 : 2;
  
  for (let i = 0; i < numBranches; i++) {
    // 给每个分支不同的随机角度偏移
    const angleOffset = (Math.random() - 0.5) * config.angleRandom * 2;
    const newAngle = actualAngle - config.branchAngle * (numBranches - 1) / 2 + 
                     config.branchAngle * i + angleOffset;
    const newLength = length * config.lengthDecay * (0.9 + Math.random() * 0.2);
    
    drawBranch(endX, endY, newLength, newAngle, depth - 1);
  }
}

看到没,随心所欲。你想要3个分枝就3个,想要角度随机就随机,完全不用管什么文法产生式。

开撸!先让一根棍子在屏幕上长起来

理论说得差不多了,咱们开始正经写代码。第一步很简单:别想着直接画一整棵树,先让一根主干从屏幕底部升起来。

// 初始化设置,这部分前面讲过了,但完整代码还是要给
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

function resize() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();

// 树的状态管理
const treeState = {
  growing: true,
  progress: 0,        // 当前生长进度,0到1之间
  maxDepth: 10,       // 最大递归深度
  baseLength: 120,    // 主干初始长度
  startX: canvas.width / 2,
  startY: canvas.height,
  startAngle: -Math.PI / 2  // 向上生长,-90度
};

function drawTrunk() {
  const currentLength = treeState.baseLength * treeState.progress;
  
  ctx.beginPath();
  ctx.moveTo(treeState.startX, treeState.startY);
  ctx.lineTo(
    treeState.startX + Math.cos(treeState.startAngle) * currentLength,
    treeState.startY + Math.sin(treeState.startAngle) * currentLength
  );
  
  ctx.lineWidth = 12;  // 主干粗一点
  ctx.lineCap = 'round';  // 这行很重要,让线头变圆,不会又尖又锯齿
  ctx.strokeStyle = '#5D4037';  // 深棕色
  ctx.stroke();
}

function animate() {
  if (treeState.growing) {
    treeState.progress += 0.01;  // 每帧长1%
    
    if (treeState.progress >= 1) {
      treeState.progress = 1;
      treeState.growing = false;
      // 主干长完了,开始长分支,这里先留个钩子
    }
  }
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawTrunk();
  
  if (treeState.growing) {
    requestAnimationFrame(animate);
  }
}

animate();

跑一下这段代码,你会看到一根粗线从屏幕底部长到中央。这就是地基。下一步是让这根主干顶端分叉。

给树枝整点性格:歪七扭八才自然

自然界没有完美的对称。如果每个分叉角度都一样,树看起来就像雪花或者电路板。混乱才是美

我们要在几个维度上引入随机性:

1. 角度随机:基础分叉角度是45度,但可以在±20%范围内波动
2. 长度随机:子枝干长度是父枝的0.7倍,但可以有±10%的浮动
3. 粗细渐变:从根部到梢部,线条逐渐变细
4. 长度衰减非线性:有时候走得更慢,有时候更快

还有一个关键技巧:单亲继承。不要每个新枝都完全独立计算,而是让子枝的某些属性(比如弯曲方向)继承父枝的倾向。如果父枝向右偏了,子枝也倾向于向右偏,这样整个树会有统一的"姿态",而不是东倒西歪像炸开了一样。

class Branch {
  constructor(x, y, length, angle, depth, parentBias = 0) {
    this.x = x;
    this.y = y;
    this.length = length;
    this.angle = angle;
    this.depth = depth;
    this.parentBias = parentBias;  // 继承父枝的弯曲倾向
    this.children = [];
    this.grown = false;
    this.currentLength = 0;  // 用于动画,当前显示的长度
  }
  
  grow() {
    if (this.currentLength < this.length) {
      this.currentLength += 2;  // 每帧长2像素
      return true;  // 还在生长中
    }
    
    if (!this.grown && this.depth > 0) {
      this.spawnChildren();
      this.grown = true;
    }
    
    // 递归让子枝生长
    let growing = false;
    for (let child of this.children) {
      if (child.grow()) growing = true;
    }
    return growing;
  }
  
  spawnChildren() {
    const numChildren = 2 + Math.floor(Math.random() * 2); // 2或3个分支
    const baseAngle = Math.PI / 3; // 60度基础分叉角
    
    for (let i = 0; i < numChildren; i++) {
      // 分叉角度分布均匀,但加上随机偏移和父枝倾向
      const angleOffset = (i - (numChildren - 1) / 2) * baseAngle;
      const randomOffset = (Math.random() - 0.5) * 0.4; // ±0.2弧度随机
      const newAngle = this.angle + angleOffset + randomOffset + this.parentBias * 0.3;
      
      // 长度衰减,越往上越短,也有随机
      const lengthDecay = 0.7 + Math.random() * 0.1;
      const newLength = this.length * lengthDecay;
      
      // 计算子枝起点(当前枝的末端)
      const endX = this.x + Math.cos(this.angle) * this.length;
      const endY = this.y + Math.sin(this.angle) * this.length;
      
      // 子枝继承一部分父枝的弯曲倾向,并加上自己的随机倾向
      const newBias = this.parentBias * 0.5 + (Math.random() - 0.5) * 0.3;
      
      this.children.push(new Branch(endX, endY, newLength, newAngle, this.depth - 1, newBias));
    }
  }
  
  draw(ctx) {
    const endX = this.x + Math.cos(this.angle) * this.currentLength;
    const endY = this.y + Math.sin(this.angle) * this.currentLength;
    
    ctx.beginPath();
    ctx.moveTo(this.x, this.y);
    ctx.lineTo(endX, endY);
    
    // 根据深度控制颜色和粗细,营造立体感
    const thickness = Math.max(1, this.depth * 1.2);
    ctx.lineWidth = thickness;
    ctx.lineCap = 'round';
    
    // HSL颜色:深度越大(越靠近根部),颜色越深
    const lightness = 20 + (10 - this.depth) * 3; // 30%到20%之间
    ctx.strokeStyle = `hsl(25, 40%, ${lightness}%)`;
    
    ctx.stroke();
    
    // 递归绘制子枝
    for (let child of this.children) {
      child.draw(ctx);
    }
  }
}

// 使用方式
const root = new Branch(canvas.width/2, canvas.height, 120, -Math.PI/2, 10);

function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  const stillGrowing = root.grow();
  root.draw(ctx);
  
  if (stillGrowing) {
    requestAnimationFrame(animate);
  }
}

animate();

看到这段代码里的parentBias了吗?这就是让树有"性格"的关键。如果没有这个参数,每次分叉都是完全独立的随机方向,树会长得很乱;有了这个继承机制,整棵树会有一种自然的整体弯曲感,就像被风吹拂或者向光生长那样。

配色别用RGB了,HSL才是梦幻感的爹

如果你还在用ctx.strokeStyle = '#FF6B6B'这种RGB十六进制写法来搞梦幻效果,那我只能说…也不是不行,但很折磨。想要那种柔和、会呼吸、会渐变的颜色,**HSL(色相、饱和度、亮度)**才是正解。

HSL的好处是参数意义明确:H(Hue)控制是什么颜色(0是红,120是绿,240是蓝),S(Saturation)控制鲜艳程度,L(Lightness)控制明暗。特别是做 Glow(发光)效果时,调整L值比调整RGB的三个分量直观多了。

梦幻色的秘诀通常是:低饱和度(20%-50%)、高亮度(60%-80%)、色相在连续范围内渐变。比如从青蓝(180)过渡到紫(280),营造出那种极光的感觉。

// 梦幻配色生成器
function getDreamColor(depth, time = 0) {
  // 深度越小(越靠末梢),色相越偏蓝紫
  const baseHue = 200 + (10 - depth) * 8; // 200-280之间,青到紫
  const hueShift = Math.sin(time / 1000) * 20; // 色相随时间缓慢流动
  
  const saturation = 50 + Math.random() * 20; // 50%-70%,不会太艳
  const lightness = 60 + Math.sin(depth + time/500) * 15; // 呼吸灯效果
  
  return `hsl(${baseHue + hueShift}, ${saturation}%, ${lightness}%)`;
}

// 在绘制时应用
ctx.strokeStyle = getDreamColor(this.depth, Date.now());

而且HSL配合ctx.shadowBlurctx.shadowColor做发光效果简直绝配:

ctx.shadowBlur = 15;
ctx.shadowColor = ctx.strokeStyle; // 阴影颜色跟线条颜色一致,营造发光感
ctx.stroke();
ctx.shadowBlur = 0; // 画完记得重置,不然影响其他元素

注意:shadowBlur是个性能杀手,在移动端尤其明显。如果你发现帧率掉到20fps以下,第一个要优化的就是减少shadowBlur的使用,或者只在最末梢的枝条上使用。

树叶飘落?先别急,让树能长起来就不错了

我知道你很急,想加上粒子效果、树叶抖动、甚至还有背景星空。但我建议先按住躁动的双手,确保核心的树生长逻辑完美无缺再说。

如果你现在就想加叶子,最简单的做法是在depth === 0(末梢)的位置画一些小圆点:

drawLeaf(x, y) {
  ctx.beginPath();
  ctx.arc(x, y, 3, 0, Math.PI * 2);
  ctx.fillStyle = `hsl(${100 + Math.random() * 60}, 70%, 60%)`; // 绿色系
  ctx.fill();
}

但即便如此,也建议先完成主干生长,再回头细化叶子。做这种视觉效果,迭代开发比一次性追求完美更靠谱。先跑起来,再美化。

性能警告:别把手机搞成暖手宝

说到这儿我得严肃点。Canvas动画是吃性能大户,尤其是递归绘制+阴影效果+全屏尺寸。在电脑上可能跑满60fps,到了手机上直接变成PPT,顺便把你的iPhone变成取暖器。

几个保命优化技巧:

清屏优化:前面说了用clearRect。

减少绘制调用: ctx.stroke()每次调用都会有 overhead,如果能用beginPath画多条线然后一次性stroke,性能会更好。但对于我们这种树形结构,很难合并路径,所以退而求其次:如果帧率不够,降低递归深度

避免在循环内创建对象:JavaScript的垃圾回收机制会导致卡顿(GC pause)。如果你在每一帧都new很多对象,内存忽上忽下,动画就会抽搐。

// 错误示范:每帧都创建新对象
function animate() {
  const particles = []; // 糟糕,每帧都新建数组
  // ...
}

// 正确做法:对象池
const particlePool = [];
function getParticle() {
  return particlePool.pop() || {};
}
function recycleParticle(p) {
  particlePool.push(p);
}

降帧策略:在移动端或者低性能设备上,可以主动把帧率降到30fps,人眼其实不太容易察觉,但性能压力减半:

let lastTime = 0;
function animate(timestamp) {
  if (timestamp - lastTime < 33) { // 每33ms画一帧,约30fps
    requestAnimationFrame(animate);
    return;
  }
  lastTime = timestamp;
  // ... 绘制逻辑
}

离屏Canvas:如果树是静态的(长完之后不再变化),可以把最终状态画到一个内存中的canvas(offscreen canvas),然后每一帧只是把这张图片drawImage到主canvas上,而不是重新递归计算整棵树。这招对于复杂背景或者静态元素特别有效。

// 创建离屏canvas
const offscreen = document.createElement('canvas');
offscreen.width = canvas.width;
offscreen.height = canvas.height;
const offCtx = offscreen.getContext('2d');

// 只在初始化时画一次
drawTree(offCtx);

// 动画循环里直接复制
function animate() {
  ctx.drawImage(offscreen, 0, 0);
  // 只更新动态部分,比如飘落的叶子
}

那些年我踩过的坑:树为啥长得像章鱼?

好了,到了吐血分享环节。我敢说下面这些坑你肯定也会遇到。

坑1:坐标系搞反了,树往下长或者横着长

Canvas的坐标系是:原点(0,0)在左上角,X轴向右,Y轴向下。所以如果你想让树向上生长,角度应该是-Math.PI/2(-90度),而不是Math.PI/2。我一开始搞反了,树直接往屏幕底下钻,跟土行孙似的。

还有就是Math.cos和Math.sin的参数是弧度不是角度,如果你传了90 expecting向上,它会给你算出一个奇怪的方向。

坑2:线条又尖又锯齿,看着像针管

默认的ctx.lineCap是’butt’(平头),线条端点是垂直于线段的平切,看起来特别生硬。加上ctx.lineCap = 'round'瞬间圆润,配合ctx.lineJoin = 'round'(线段连接处也圆角),质感提升十个档次。

坑3:动画播到一半卡住,刷新又好了

多半是递归里的终止条件写错了,或者cancelAnimationFrame用岔了。如果你在外部调用了cancelAnimationFrame但传错了ID,动画其实没停,然后你又启动了一个新的rAF循环,结果两个循环在争抢绘制,运气好能看到树发疯似的乱抖,运气差直接卡死。

建议用一个全局的animationId变量管理:

let animationId = null;

function startAnimation() {
  if (animationId) cancelAnimationFrame(animationId);
  function loop() {
    // ... 绘制
    animationId = requestAnimationFrame(loop);
  }
  animationId = requestAnimationFrame(loop);
}

function stopAnimation() {
  if (animationId) {
    cancelAnimationFrame(animationId);
    animationId = null;
  }
}

坑4:树太对称,像假花

前面提过的随机性不足问题。记住:完美的对称只存在于数学公式,不存在于自然界。哪怕你引入0.1弧度的随机偏移,效果都会自然很多。

坑5:颜色太艳,像东北花棉袄

HSL的饱和度别拉太高,50%以下比较安全。还有就是别用纯黑纯白,稍微偏点色温(比如黑里加点蓝)会显得更高级。这是我从UI设计师朋友那儿偷来的经验。

高级骚操作:让树跟着音乐蹦迪

基础效果已经够炫了,但如果你想彻底秀翻全场,可以加上音频响应

基本原理是用Web Audio API的AnalyserNode提取音乐频谱数据,然后把不同频率的能量值映射到树的属性上——比如低频(鼓点)控制树的整体摇摆幅度,高频(镲片)控制叶子的闪烁频率。

// 音频初始化
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);

// 假设你已经把audio元素连接到了analyser
// audioSource.connect(analyser);

function getAudioData() {
  analyser.getByteFrequencyData(dataArray);
  // 简单处理:取平均值作为整体强度
  const average = dataArray.reduce((a, b) => a + b) / bufferLength;
  return average / 255; // 归一化到0-1
}

// 在绘制时应用
function animate() {
  const audioLevel = getAudioData();
  config.windForce = audioLevel * 0.5;  // 音乐越强,树摇摆越厉害
  
  // 也可以让颜色随音乐变化
  const hueShift = audioLevel * 60;
  ctx.strokeStyle = `hsl(${200 + hueShift}, 50%, 70%)`;
  
  // ... 绘制树
}

注意Web Audio API需要用户交互(点击)才能触发,不能页面一加载就自动播放,这是浏览器的安全策略。

另外,暂停和重播功能其实就三行代码的事,但很多人会想复杂了:

let isPaused = false;
let growthProgress = 0;

canvas.addEventListener('click', () => {
  isPaused = !isPaused;
  if (!isPaused) animate();
});

function animate() {
  if (isPaused) return;
  // ... 绘制逻辑
  growthProgress += 0.01;
  if (growthProgress < 1) requestAnimationFrame(animate);
}

导出视频/GIF:想把动画发朋友圈?用canvas.toBlob()或者canvas.toDataURL()把每一帧导出成图片,然后用ffmpeg.js或者后端工具合成视频。实时录屏的话,可以用MediaRecorder API捕获canvas流:

const stream = canvas.captureStream();
const mediaRecorder = new MediaRecorder(stream);
const chunks = [];

mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = () => {
  const blob = new Blob(chunks, { type: 'video/webm' });
  const url = URL.createObjectURL(blob);
  // 生成下载链接
};

mediaRecorder.start();
// ... 动画播放
setTimeout(() => mediaRecorder.stop(), 5000); // 录5秒

这玩意到底能干啥用?给客户演示神器

你可能会问:花这么大功夫搞棵树,除了发朋友圈骗赞,业务上能用吗

太能了。

首页加载动画:传统的loading spinner看吐了,让用户盯着一棵慢慢生长的树等3秒,体验好十倍。心理学上这叫"感知性能"——用户觉得有趣的时候,等待时间会变短。

404页面:“您访问的页面像这棵树一样还在生长中”——诗意又解嘲。

数据可视化:虽然是2D,但抽象成树状图展示层级数据(比如文件系统、组织架构),比D3.js轻量多了。

SaaS产品的科技感动效:当客户说"要有科技感、要有生命力"这种抽象需求时,甩出这个效果,大概率能过稿。比那些旋转的立方体强多了。

而且Canvas是浏览器原生支持的,不需要引入React、Vue、Three.js这些重型依赖,一个HTML文件就能跑,部署到CDN上加载速度飞快。这在某些对性能要求苛刻的场景(比如嵌入式设备、老旧浏览器)反而是优势。

尾声:别卷WebGL了,2D也能玩出花

写到最后我想说的是,现在前端圈有点太卷WebGL了,动不动就要上Three.js、Shader、粒子系统。但其实Canvas 2D Context的能力被严重低估了。配合好的算法和审美,2D完全可以做出让人哇塞的效果,而且调试成本低、兼容性好、代码量少。

下次你可以试试:

  • 让树在生长过程中开花(在末梢用粒子效果模拟花瓣飘落)
  • 让树的形状向鼠标靠近(类似磁铁吸引,但柔和一点)
  • 甚至让树长成公司logo的轮廓(把分叉角度按预设路径调整)

脑洞比代码重要。就像这次,谁能想到简单的递归+随机数+渐变色,就能造出梦幻感呢?

代码已经全给你了,去试试吧。搞砸了也没关系,反正除了你的浏览器,没人会嘲笑你。搞成功了记得发给我看看,让我也哇塞一下。

(完)

在这里插入图片描述

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值