☰
用Canvas手写旋转椭圆花:从坐标变换到动效全解析
2026/10/14 3:53:01 网站建设 项目流程

小白也能画!用Canvas手搓会转的椭圆花(附动效秘籍)

前阵子一个刚入门前端的朋友问我,说学了挺久Canvas,但每次打开文档看到一堆API还是发怵,总觉得自己画不出什么好看的东西。我就跟他说,你不如先从一朵花开始——不是那种傻乎乎的正圆花瓣,而是带点设计感、会自己慢慢旋转的椭圆花。听起来好像有点意思,但真上手其实半小时就能写完核心代码,而且这朵小花背后藏着Canvas几乎所有最重要的基本功:坐标变换、路径绘制、逐帧动画、缓动节奏。今天把这套思路完整拆给你,就算你只是刚摸过几天代码,跟着走一遍也能在自己浏览器里看到一朵活的、会转的花。

这朵花的核心思路不复杂:先把一个椭圆“花瓣”画出来,再围绕中心点复制N份、各自转一个角度拼成一圈,最后用时间驱动的方式让整朵花持续旋转。听上去像小学数学里的旋转对称,但落到Canvas代码里,每一步都有不少值得抠的细节。

1. 先说清楚:为什么是“椭圆”而不是“正圆”花瓣

很多新手画花,第一反应是画正圆,然后一圈圆堆在一起,出来的东西更像泡泡,不像花瓣。花瓣的形态本质上是“长条形的圆”——一头宽、一头窄,或者整体呈梭形,这时候正圆就不够了,椭圆才是更接近的几何原形。

椭圆在数学上的定义是平面上到两个焦点距离之和为常数的点的轨迹,但在Canvas里我们不需要关心焦点,只需要一个关键参数:长短轴比例。短轴与长轴的比值,直接决定了花瓣的“胖瘦”。比值为1就是正圆,降到0.5左右就是明显的细长花瓣,再降到0.3就会偏像柳叶。我做这朵花的时候把长轴设成80、短轴设成35,比例大概0.44,视觉上刚好介于“饱满”和“纤细”之间,比较耐看。

这里还藏着一个很重要的细节:Canvas 2D API里的ellipse方法画的是“始终平行于当前坐标系”的椭圆,它不像arc那样自带方向感。如果你想画“斜着的椭圆”,不能靠改ellipse的入参去硬掰(虽然它确实有一个旋转参数,但那是椭圆自身围绕自己中心的旋转),而是要主动旋转整个坐标系。这个区别特别容易把人绕晕,后面第三部分我会具体演示怎么用坐标变换来“摆”花瓣的位置。

2. 搭建画布:先把高清屏的坑填平

正式开始画之前,画布本身得先弄利索。新手最容易忽略的一件事:直接在HTML里写<canvas width="600" height="600">,在普通屏幕上看着还行,但在高分屏(Retina屏)上会糊成一片。原因是Canvas的物理像素和CSS像素在设备上不是一比一。

我常用的初始化模板是这样:

<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8" /> <title>旋转椭圆花</title> <style> body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #1a1a2e; } canvas { background: #16213e; border-radius: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } </style> </head> <body> <canvas id="flower"></canvas> <script> const canvas = document.getElementById('flower'); const ctx = canvas.getContext('2d'); // 高清屏适配:拿 CSS 尺寸乘上设备像素比 const DPR = window.devicePixelRatio || 1; const CSS_SIZE = 600; canvas.style.width = CSS_SIZE + 'px'; canvas.style.height = CSS_SIZE + 'px'; canvas.width = CSS_SIZE * DPR; canvas.height = CSS_SIZE * DPR; // 关键:把上下文整体缩放 DPR 倍,这样后续绘图逻辑只用“CSS 像素思维” ctx.scale(DPR, DPR); </script> </body> </html>

这段代码里有两处值得解释。第一,ctx.scale(DPR, DPR)放在初始化阶段,目的是让后面所有绘图指令都自动按CSS尺寸工作,不需要每个坐标都手动乘DPR,逻辑上清爽很多。第二,如果你直接用clientWidth之类的值去设画布尺寸,要注意必须在DOM渲染完成之后拿,否则会读到0,所以简单起见这里直接用固定600。

注意:ctx.scale是永久性的坐标变换,一旦设置,后续所有绘制都会被缩放。因此要放在绘制逻辑之前执行,并且在整段代码里只执行一次,不要放在动画循环里反复调用,否则缩放会叠加,画面会越画越大。

3. 手搓核心:一枚花瓣的绘制与N枚花瓣的圆周排布

万事俱备,现在开始画花。我先来画一枚花瓣。在画之前,先把坐标系原点搬到画布中心,这样后面所有旋转操作都以中心为轴,逻辑顺很多。

const CENTER_X = 300; const CENTER_Y = 300; ctx.translate(CENTER_X, CENTER_Y);

然后画一枚椭圆花瓣:

// 花瓣参数 const petalWidth = 35; // 短轴半径 const petalHeight = 80; // 长轴半径 ctx.beginPath(); ctx.ellipse(0, -petalHeight / 2 - 10, petalWidth, petalHeight / 2, 0, 0, Math.PI * 2); ctx.fillStyle = '#ff6b9d'; ctx.fill(); ctx.strokeStyle = '#ff2e63'; ctx.lineWidth = 2; ctx.stroke();

这枚花瓣的位置是“中心点向上偏移”的:因为ellipse的圆心默认在花瓣中心,而我需要花瓣根部朝向花的中心,所以把花瓣中心抬高了一点,露出一点根部间隙。- petalHeight / 2 - 10这里的10是花心半径,让花瓣不是贴在原点,而是绕着花心外围一圈,比较自然。

一组花瓣围绕中心排列,核心就是“每次旋转固定角度,再画一枚同样的花瓣”。数学上角度是2π / N,N是花瓣数量。我一般取8枚或12枚,8枚更利落,12枚更饱满。

const PETAL_COUNT = 8; for (let i = 0; i < PETAL_COUNT; i++) { ctx.save(); ctx.rotate((Math.PI * 2 / PETAL_COUNT) * i); ctx.beginPath(); ctx.ellipse(0, -petalHeight / 2 - 10, petalWidth, petalHeight / 2, 0, 0, Math.PI * 2); ctx.fillStyle = '#ff6b9d'; ctx.fill(); ctx.strokeStyle = '#ff2e63'; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); }

这里ctx.save()和ctx.restore()是兄弟组合,save会把当前状态(包括旋转、平移、缩放等)压入栈,restore把上一次保存的状态弹出来复位。为什么这很重要?因为ctx.rotate一旦执行就永久改变坐标系方向,如果不在每轮循环末尾restore,下一次旋转会叠加在上一次基础上,角度会越转越大,花瓣排列全乱。我在早期踩过这个坑,画出来的东西像电风扇扇叶一样散开。

再解释一个新手常问的点:为什么ellipse第一个参数是(0, -petalHeight/2-10)而不是直接用绝对坐标?因为我在循环之前已经把坐标系中心translate到画布中心了,所以此时(0,0)就是花心。后续所有操作都以花心为原点,相对坐标思考,旋转才有效。如果你还想着绝对坐标,就永远绕不明白。

到这里,一朵静态的、均匀排列的椭圆花已经出现了。但它的状态还是“活”的,因为还没动画。下一步就是本文重头戏:让它转起来。

4. 动起来:requestAnimationFrame与“永远旋转”的写法

Canvas动画的本质是“擦掉上一帧,画下一帧”。最常见的错误写法是直接在循环里疯狂ctx.fill(),不擦除上一帧,画面会糊成一大团颜料。正确的做法是每帧开始时用ctx.clearRect把整个Canvas清理干净,然后重新绘制整个花。

旋转的核心不是旋转花瓣本身,而是旋转坐标系。我维护一个全局角度变量angle,每帧增加一个固定的小增量,然后ctx.rotate(angle)再画花。画完恢复,下一帧继续增加。

let angle = 0; function draw() { // 清空整块画布(用 CSS 像素尺寸) ctx.clearRect(0, 0, CSS_SIZE, CSS_SIZE); ctx.save(); ctx.translate(CENTER_X, CENTER_Y); ctx.rotate(angle); // 在这里绘制整朵花(循环画花瓣) for (let i = 0; i < PETAL_COUNT; i++) { ctx.save(); ctx.rotate((Math.PI * 2 / PETAL_COUNT) * i); // ... 花瓣 fill / stroke ... ctx.restore(); } ctx.restore(); // 更新角度 angle += 0.02; requestAnimationFrame(draw); } requestAnimationFrame(draw);

关于angle += 0.02,这个数值就是每帧旋转的弧度。0.02弧度约等于1.15度,如果按60帧/秒算,一秒转约69度,大约5.2秒完成一整圈。看起来不紧不慢,适合观赏。如果你想更快,可以调到0.03或0.04;想更慢,0.01会更优雅。

有一点我需要单独提醒:用angle += 0.02这种方式是“帧驱动”的,它假设每帧间隔相同。但浏览器标签页切走再切回时,requestAnimationFrame会被暂停,回来时角度的增量是连续的,不会跳变,反而还好。真正的问题是如果遇到低刷新率屏幕(比如30Hz),同样代码下花会转得更慢,因为每帧角度增量不变但每秒帧数减半了。

如果你介意这种差异,可以改成“时间驱动”:

let lastTime = performance.now(); function draw(now) { const delta = (now - lastTime) / 1000; // 秒 lastTime = now; angle += delta * 0.6; // 每秒转 0.6 弧度,约 10.5 秒一圈 // 其余绘制逻辑一样 requestAnimationFrame(draw); }

performance.now()返回的是从页面加载开始至今经过的毫秒数,用它做时间差,动画速度就与帧率无关了。算一下:每秒0.6弧度,转完一圈要2π / 0.6 ≈ 10.47秒,把0.6换成你自己的手感数值即可。我个人更推荐时间驱动,代码差别不大,但实际体验的稳定性和专业感提升很明显。

5. 动效秘籍:从“会转”到“好看”的三重细节

到这里,花已经会转了。但说实话,只是一朵硬邦邦的“旋转贴图”还很单调。下面这三个进阶技巧,才是撑起“动效秘籍”这个说法的核心内容,我一个个拆开讲。

第一个是“呼吸感”。真实的花在微风中会有一种轻微缩放或花瓣张合的感觉。要实现这种效果,不需要多复杂的物理模拟,只要用一个正弦波控制花瓣长短轴半径即可。

const breathe = Math.sin(Date.now() / 800) * 4 + 4;

这里Date.now() / 800决定了呼吸速度,800毫秒一个周期;Math.sin的结果在 -1 到 1 之间,乘以4后是 -4 到 4,加上4后变成0到8,这样花瓣长度就会在一定范围内循环伸缩。把petalHeight和petalWidth替换成basePetalHeight + breathe和basePetalWidth + breathe * 0.3,花就有了“一张一合”的生动感。注意宽度方向的缩放比例要小于长度方向,因为花瓣呼吸时主要是纵向舒展,横向变化没那么夸张。

第二个是“渐层着色”。如果所有花瓣都是同一个纯色,视觉上会很平。我常用HSL颜色模式,把色相值也随着角度和时间微调:

const hue = (i / PETAL_COUNT) * 30 + angle * 10; ctx.fillStyle = `hsl(${hue}, 80%, 65%)`;

这里i / PETAL_COUNT * 30让相邻花瓣色相有些微差异,angle * 10则让整个色带随着旋转整体漂移,像极光一样有流动感。色相值30度的范围就是橙色到粉紫之间的小跨度,不会太花哨,但又比纯色耐看很多。你还可以在里面叠加透明度globalAlpha = 0.85,让相邻花瓣重叠部分出现自然的深浅叠色效果,层次立刻丰满。

第三个是“中心点缀与外部余韵”。一朵花不能只有花瓣,花心也得有内容。我用一个渐变圆做花心,再在花瓣外围画一小圈细小的粒子或光晕,增加细节丰富度。花心渐变是Canvas很典型的用法:

const gradient = ctx.createRadialGradient(0, 0, 2, 0, 0, 20); gradient.addColorStop(0, '#fff5b8'); gradient.addColorStop(1, '#f6c945'); ctx.beginPath(); ctx.arc(0, 0, 20, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

这里createRadialGradient的前两个参数是内圆圆心,第三个是内圆半径,后三个是外圆圆心和半径。颜色从圆心亮黄渐变为边缘深黄。外圈粒子的画法是用小arc随机分布在半径120~160的环形区域,每个粒子同样参与整体旋转,模拟花粉飘散的感觉。

建议:把粒子数量控制在20~30个以内。粒子太多且每帧都实时计算位置和绘制,会带来不必要的性能开销。花瓣本身已经是8~12个椭圆,再加上粒子,大致在一帧内50个绘制调用以内,现代浏览器轻松扛住。

6. 进阶玩法:鼠标交互与阻尼跟随

动画做出来后,加一点交互会让作品“活”得更加分。我个人最喜欢的玩法是让花跟着鼠标缓慢旋转,而不是永远匀速转——就像向日葵朝向太阳那种感觉。

思路不复杂:目标角度是“鼠标相对画布中心的方向角”,当前角度是现有的angle,每一帧让当前角度朝目标角度靠近一小段距离。这个“一小段距离”就是阻尼感的关键。直接写成:

canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; targetAngle = Math.atan2(my - CENTER_Y, mx - CENTER_X) + Math.PI / 2; });

Math.atan2计算的是从画布中心指向鼠标位置的向量与x轴正方向的夹角,返回值在 -π 到 π 之间。加Math.PI / 2是因为我画的花默认是“向上”生长的,如果不加偏移,花会横向指向鼠标。那么“接近目标角度”怎么写?一种朴素写法是直接线性插值:

const diff = targetAngle - angle; angle += diff * 0.08;

但这里有个隐藏bug:角度是循环的,从170度到-170度,直接相减得到 -340度,插值时花会猛地朝反方向绕一大圈。正确做法是把差值归一化到 -π 到 π 区间:

let diff = targetAngle - angle; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; angle += diff * 0.08;

这串while循环看着简单,其实干了一件很微妙的事:它把角度差“卷”到了最近的方向上。比如目标在179度,当前在-179度,真实最短路径只需要转2度,而不是绕358度。diff * 0.08这个系数是每一帧向目标靠近的百分比,0.08表示每帧移动当前差值的8%,在60帧下大概一秒多追上目标,手感非常顺滑,不会像指针一样生硬瞬移。

如果想继续叠加,还可以让花本身在跟随鼠标转的同时,内部花瓣做轻微错位波动。比如每个花瓣在原有旋转角度上再加一个正弦偏移:

ctx.rotate((Math.PI * 2 / PETAL_COUNT) * i + Math.sin(Date.now() / 300 + i) * 0.08);

0.08弧度约4.6度,这个幅度不会破坏花的整体形状,但会让花瓣像被风吹拂一样微微颤动,非常推荐试一下。

7. 常见问题与排查技巧实录

按惯例分享几个我在调试这种Canvas动态图形时经常遇到的“坑”,每个都附带排查思路,帮你少走几步冤枉路。

问题一:画出来模糊、边缘发虚。这个几乎都是高清屏适配没做对。排查方法:打开浏览器开发者工具,检查canvas的width属性值和CSS宽高。如果width等于CSS宽高而非CSS宽高乘以DPR,那多半就是漏了第二部分的适配代码。修复很简单,按前面的模板补上canvas.width = CSS_SIZE * DPR和ctx.scale(DPR, DPR)即可。注意如果忘写ctx.scale(DPR, DPR),画面会只画在左上角四分之一的区域里。

问题二:花转了,但转的时候中心偏移、像在画一个大圆圈。这是坐标系平移没做对。如果你直接ctx.rotate(angle)而不先ctx.translate(CENTER_X, CENTER_Y),旋转的中心是Canvas左上角(0,0),花会围着左上角甩出去。解决办法:每次绘制前把坐标系平移到画布中心,绘制完restore()回原位。检查代码里translate和rotate的执行顺序,一定是先平移再旋转。

问题三:拖动鼠标交互时,花突然猛转一圈然后又正常。就是我上面说的角度跳变问题。你需要把角度差归一化到 -π 到 π。如果你已经接了mousemove事件,但没做归一化,就会看到这个现象。这也是Canvas动画里比较经典的循环角度处理技巧,适用范围远不止这一朵花,做钟表、雷达、仪表盘都会用上。

问题四:动画越跑越卡。先检查是不是在循环里创建了新的Canvas渐变、新的路径对象且没有闭合。尤其注意beginPath与fill成对出现,每条路径都要beginPath另起炉灶。另一个常见原因是粒子数组无限增长——如果你有“每个粒子每个帧push一个新粒子”的操作,内存会迅速爆掉,应该设置粒子数量上限,或者用固定数组预生成。我上面示例里粒子数固定20个,每帧只是重新计算位置,不会累积。

问题五:标签页切走再回来,花转得慢了很多。这是帧驱动写法在后台标签被节流导致的。针对这个场景,建议换成时间驱动,也就是我上文演示的performance.now()求delta再乘速度系数的方法。时间驱动下动画永远保持相同速度,和帧率无关。

8. 最后一层优化:把性能吃透,动画更丝滑

前面做出来的花,在大多数电脑上已经跑得飞快了,但作为进阶内容,我还是建议试试下面这两个优化点,感受一下“精致”和“粗糙”的差别。

第一个是尽量避免在动画循环里创建新对象。比如createRadialGradient,如果每帧都重新创建一个渐变对象,会额外触发对象分配与内存回收。固定的花心渐变完全可以放在初始化时创建一次,在循环里直接引用。对于粒子位置,也建议用预生成的数组存储基础参数(半径、初始角度、粒子大小),循环里只做简单的三角计算更新位置。这类改动单帧可能看不出差别,但在老旧设备上或叠加到更复杂的场景里,差距会非常明显。

第二个是合理利用save/restore。我经常看到新手每画一个花瓣就套一层save/restore,循环里嵌套得密密麻麻。save/restore本质是状态栈的压栈与弹栈,频繁大量调用也是有一点开销的。一个折中方案是:把同一种旋转层级下的所有绘制集中在一起,只在必要的地方切换状态。比如先统一旋转到某个角度,连续画完几枚花瓣(前提是花瓣画法完全相同),再旋转到下一个角度。这样可以把循环次数里的save/restore调用压到最低。不过这属于微优化,对8~12瓣的花来说不是必须的,但养成这个习惯对以后画更复杂的Canvas场景很有帮助。

如果还想继续扩展,可以考虑把花瓣改成三次贝塞尔曲线绘制,让花瓣边缘更有弧度感;或者用globalCompositeOperation = 'lighter'让花瓣重叠处产生发光叠加效果,这种“加法混合”模式很适合做梦幻光效。甚至可以让花蕊的粒子按各自的轨道做圆周运动,形成类似星系环绕的效果。Canvas的世界就是这样,一个基础图形加上坐标变换和逐帧动画,能演化的花样远超你的预期。

我个人做完这朵花之后的最大体会是:Canvas入门最容易卡住人的不是API数量,而是坐标系变换。一旦你真正理解translate是移动原点、rotate是旋转整个坐标系、save/restore是状态栈,画任何复杂图形都只是在此基础上叠加组合而已。把这朵椭圆花调通,等于把这个理解练扎实了——下一次不管你想画齿轮、雪花、旋转星空,还是做交互式数据可视化,底层都是这套逻辑。希望你也动手试一遍,改改参数,加加自己的小心思,看它长成你独有的那朵花。

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

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

立即咨询