前段时间有个演示材料要用到一条动态的圆内滚线轨迹图要求线条干净、能实时调参数、颜色最好能跟着主题变。我去试了一圈现成的动图工具折腾了一个多小时出来的东西要么比例被压扁要么改一个参数得整段重做最后索性写了三四十行代码直接在浏览器里生成几何动图还做了个能拖参数的小面板。这件事让我再次确认了一个判断几何动图的本质是数学可视化与其靠现成工具补关键帧不如让代码把每一帧都算出来。这篇文章我会从最小可运行的例子讲起把参数方程、渲染路线选型、交互面板和最后导出视频/GIF 这几个环节完整梳理一遍。如果你刚接触这类动图可以直接照抄前两个例子如果已经有基础可以直接跳到第 4 节以后的选型和导出部分。1. 为什么我放着现成工具不用非要写代码生成几何动图1.1 现成工具的三个短板我不否认现成工具在插画、剪辑、合成方面的效率但放在几何这件事上它们的短板非常明显。几何图形的核心是数学关系而这类工具的数据模型是手工绘制的路径和关键帧。一条本来可以用公式描述的曲线在工具里通常需要密集的锚点去拟合想调整振幅或半径得手动拖动关键帧想整体换一套配色几十个对象要逐个改样式。做一次性静态插画还好只要涉及参数连续变化这套链路立刻变得笨重。另一个痛点是不可复用。这周做一张圆形动图下周要做一张摆线动图两者从零开始流程完全重来。但用代码实现的话圆的参数方程和摆线的参数方程只是两三行公式的区别框架完全一致改完就能用。还有导出质量的问题。经过导出、压缩、二次转换之后线条边缘容易糊背景色也可能被压出色带尤其是渐变部分观感明显下降。浏览器实时渲染则没有中间损耗矢量清晰度直接取决于显示设备。1.2 代码生成的核心思路代码生成几何动图的核心思路可以压缩成一句话几何对象加参数方程再叠加时间变量。时间每前进一帧绘制函数重算一次画面。旋转就是角度随时间递增呼吸就是半径随 sin 函数摆动颜色渐变就是 RGB 三个分量各自做插值。这样做的好处有三个一切可参数化。半径、速度、边数、颜色在代码里都是变量改一个数字整张图联动变化。轻量可复用。几十到几百行代码复制进任何网页就能跑比导出的视频灵活得多。天然可交互。滑块、鼠标拖拽、随机化按钮能让观看者直接操纵参数一张静态的演示图变成可探索的小工具。适合这种写法的场景也很明确课程教学里的数学可视化、讲解齿轮/摆线/波形时的配套动画、网页开屏的氛围动效、甚至纯粹的数字艺术作品。只要图形的运动规律能写成数学表达式代码生成几乎是性价比最高的方案。2. 第一条能跑的动画一个 40 行 Canvas 例子拆开讲2.1 最小文件骨架在浏览器里做几何动图Canvas 2D 是门槛最低的入口。它不需要安装任何依赖一个 HTML 文件就够。新建一个文件把下面这段代码保存成demo.html用浏览器打开你就能看到第一个动图一个匀速旋转的正方形。!doctype html html head meta charsetutf-8 title旋转方块/title /head body canvas idc width600 height400/canvas script const canvas document.getElementById(c); const ctx canvas.getContext(2d); const cx 300; const cy 200; function draw(time) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(cx, cy); ctx.rotate(time * 2 * Math.PI / 1000); ctx.strokeRect(-80, -80, 160, 160); ctx.restore(); } function loop(time) { draw(time); requestAnimationFrame(loop); } requestAnimationFrame(loop); /script /body /html这段代码里所有核心概念都出现了canvas是画布ctx是绘图上下文time是页面打开以来的毫秒数。ctx.rotate的角度由时间决定所以正方形会持续转圈。save和restore用来保护坐标系让矩阵变换只影响当前这个图形。2.2 动画循环里的清屏-更新-绘制动画循环其实只有三件事清掉上一帧残留、更新状态、重新绘制整帧。顺序不能乱。clearRect第一步清空画布否则上一帧的线条会叠加在当前帧下面画面不是动画而是一团越来越密的线条网。第二步更新状态。这里的状态可以是角度、半径、颜色甚至整个坐标系的变换矩阵。我的例子用了time直接计算也可以用一个递增变量angle 0.02;第三步绘制。用更新后的状态把图形画出来。整个过程由requestAnimationFrame驱动它表示浏览器在下次绘制之前调用我一次。显示器刷新率是 60Hz 时它大约每秒调用 60 次刷新率是 144Hz它也会跟着变。这里必须强调一下为什么不用setInterval。requestAnimationFrame会在页面切到后台时自动暂停节省 CPU 和电量而setInterval不管页面是否可见都会一直执行在移动端很容易带来性能和发热问题。几何动图的循环应该始终优先用requestAnimationFrame。2.3 从旋转方块到呼吸圆把方块换成圆只需要改动绘图部分的几行。下面这个例子通过Math.sin让半径在 40 到 120 之间连续变化效果类似呼吸function draw(time) { ctx.clearRect(0, 0, canvas.width, canvas.height); const radius 80 40 * Math.sin(time / 500); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke(); }Math.sin的结果在 -1 到 1 之间来回摆动天然适合做周期性往复运动。把时间参数time / 500改小呼吸就变慢把振幅 40 改大圆的尺寸变化就更夸张。这个模式几乎会出现在后面所有几何动图里建议先亲手改几个系数感受一下。3. 从圆到利萨如曲线参数方程怎么写才不晕3.1 把参数 t 当成一块秒表很多人觉得参数方程难其实可以这样想想象一个机器人手持画笔在纸上游走t是你手里的秒表读数x(t)告诉机器人此时在左右方向上的位置y(t)告诉它上下方向上的位置。如果t只从 0 走到 2π你得到一条静态轨迹如果每秒让t多走一段你看到的就是画笔运动的动画。之前画圆用的ctx.arc之所以直观是因为它已经帮你封装了下面这层关系x cx r * cos(t) y cy r * sin(t)t从 0 扫到 2π得到的就是一个完整的圆。但如果你想画任意曲线就得回到公式本身手动计算每一个点的坐标。这也是代码生成几何动图最核心的一步把数学公式翻译成循环里的x和y。3.2 高频曲线公式表建议收藏我经常用到的几组参数方程整理成一张表可以直接抄曲线名称参数方程参数范围圆x r·cos(t)y r·sin(t)[0, 2π)椭圆x a·cos(t)y b·sin(t)[0, 2π)螺旋线x t·cos(t)y t·sin(t)[0, 10π] 等心形线x 16·sin³(t)y 13·cos(t) - 5·cos(2t) - 2·cos(3t) - cos(4t)[0, 2π)圆内摆线x (R-r)·cos(t) d·cos((R-r)·t/r)y (R-r)·sin(t) - d·sin((R-r)·t/r)[0, 多周期]利萨如曲线x A·sin(a·t δ)y B·sin(b·t)[0, 2π) 或多周期使用这张表时有两个容易踩的坑。第一JavaScript 的三角函数只认弧度360 度等于 2π一个系数写错画面立刻乱掉。第二Canvas 的 y 轴方向向下所以公式里的sin部分在视觉上通常上下颠倒如果你对图形方向有要求记得把 y 写成cy - r * Math.sin(t)。3.3 动态利萨如曲线完整示例利萨如曲线是我特别推荐新手尝试的因为参数a和b稍微一变图案就从麻花变成圆再变成直线反馈非常直观。下面这段代码会在画布上画出一条动态旋转的利萨如曲线canvas idc width600 height600/canvas script const canvas document.getElementById(c); const ctx canvas.getContext(2d); const cx 300; const cy 300; function draw(t) { ctx.clearRect(0, 0, 600, 600); ctx.beginPath(); for (let i 0; i 400; i) { const theta (i / 400) * Math.PI * 2; const x cx 180 * Math.sin(3 * theta t / 1000); const y cy 180 * Math.sin(2 * theta); if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } function loop(time) { draw(time); requestAnimationFrame(loop); } requestAnimationFrame(loop); /script注意第 12 行的t / 1000。这个相位偏移让整个闭合曲线随时间慢慢旋转如果把它改成0曲线就是完全静止的。另一个好玩的改动是让循环次数上限动态变化比如让图形展示一笔一笔被画出来的过程这会让动图更有讲故事的感觉。3.4 正多边形的顶点公式与多角星除了曲线正多边形也是几何动图里最常见的元素。它本质上是均匀分布在圆上的一组点第i个顶点的角度是2π·i / n坐标公式为x cx r * Math.cos(angle) y cy r * Math.sin(angle)只要把这个公式写进循环就能画出任意边数的正多边形const n 6; ctx.beginPath(); for (let i 0; i n; i) { const angle (i / n) * Math.PI * 2; const x cx 150 * Math.cos(angle); const y cy 150 * Math.sin(angle); if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.stroke();还有一个进阶玩法连接顶点时不是连接相邻的两个而是连接第i个顶点到第(i * m) % n个顶点。当m和n互质时会得到一笔画的正多角星比如五角星就是n5, m2。这个知识点在生成万花筒类动图时特别实用后面加交互面板时也会用到。4. Canvas、SVG、Three.js不同几何动图各自的路4.1 三条路线的定位差异浏览器里的几何动图并不只有 Canvas 一种做法。SVG 和 Three.js 在特定场景下效率更高选错路线会平白增加很多工作量。三者定位用一张表可以看得很清楚维度Canvas 2DSVGThree.js / WebGL图元模型像素绘图无对象概念DOM 节点每个图形都是元素网格、几何体对象适合规模几千到几万个点/粒子几十到几百个节点大量顶点、复杂 3D 场景事件交互需要自己做点/区域命中天然事件绑定有 Raycaster 但复杂度高学习曲线低低较高典型场景粒子、波形、实时逐帧图形图表动画、UI 动效、可点击几何图三维几何体、相机环绕、着色器4.2 大多数 2D 几何动图我用 Canvas 2DCanvas 2D 是最接近数学函数直接翻译的方案。它没有对象的概念只是往像素上画线所以当你需要画几千个粒子、数千条线或者图形形状每帧都在发生变化时它没有 DOM 节点数量的压力性能非常稳。我个人的经验是用 Canvas 画同样一张动图几千个点轻松流畅如果换成 SVG几千个节点一起操作 DOM浏览器很快就吃力了。Canvas 的缺点是需要自己做命中检测既没有节点的概念也没法直接给某个图形绑定点击事件。但纯几何动图通常不需要点击所以这个缺点基本可以忽略。4.3 SVG 适合少而精致的交互场景如果图形数量少又需要文字标注、悬停高亮、点击反馈SVG 是更好的选择。它里面的每个图形都是 DOM 元素可以直接绑定事件样式也能用 CSS 控制动画甚至不需要 JavaScript。SVG 里有一个很冷门的原生动画方式叫 SMIL不写一行 JS 就能让圆呼吸svg width200 height200 circle cx100 cy100 r20 fillnone strokeblack animate attributeNamer values20;40;20 dur2s repeatCountindefinite/ /circle /svg这种写法适合做轻量级 UI 动效和图表动画。但注意别把 SVG 用在需要大量节点的地方DOM 节点多了浏览器重排和重绘的成本会非常明显。4.4 三维几何动图直接交给 Three.js如果你要做三维几何体、相机环绕、带光照的球体或者复杂的着色器效果不要去尝试手写 WebGL。WebGL 是 GPU 层面的裸 API一个能动的三角形都要几十行样板代码还要自己写着色器。Three.js 把场景图、相机、几何体、材质都封装好了一个旋转的齿轮环只需要十行核心代码import * as THREE from three; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, 1, 0.1, 100); camera.position.z 5; const renderer new THREE.WebGLRenderer(); renderer.setSize(400, 400); document.body.append(renderer.domElement); const mesh new THREE.Mesh( new THREE.TorusGeometry(1, 0.3, 16, 64), new THREE.MeshNormalMaterial() ); scene.add(mesh); function loop() { mesh.rotation.x 0.01; mesh.rotation.y 0.015; renderer.render(scene, camera); requestAnimationFrame(loop); } loop();如果你的页面用 CDN 方式引入 Three.js把import替换成全局THREE变量即可。记住90% 的平面几何动图用 Canvas 2D少量精致交互图用 SVG三维场景直接上 Three.js这个默认组合能省掉大量纠结时间。5. 把参数暴露成滑块动图就变成了探索工具5.1 一个滑块的完整接入方式几何动图最大的红利是参数可调。给动图加一个滑块它就从演示变成了探索工具观众自己拖动边数、速度、相位马上能看到不同图案的出现过程。下面是一个可调节边数和旋转速度的正多边形示例input typerange idn min3 max24 value6 input typerange idspeed min0 max100 value30 canvas idc width600 height400/canvas script const canvas document.getElementById(c); const ctx canvas.getContext(2d); const nSlider document.getElementById(n); const speedSlider document.getElementById(speed); function draw(t) { const n parseInt(nSlider.value, 10); const speed parseInt(speedSlider.value, 10) / 100; ctx.clearRect(0, 0, 600, 400); ctx.save(); ctx.translate(300, 200); ctx.rotate(t * speed * Math.PI * 2 / 1000); ctx.beginPath(); for (let i 0; i n; i) { const angle (i / n) * Math.PI * 2; const x 150 * Math.cos(angle); const y 150 * Math.sin(angle); if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.stroke(); ctx.restore(); requestAnimationFrame(draw); } requestAnimationFrame(draw); /script这里有一个很多教程没讲透的点滑块的值只需要在draw()里读取不需要监听input事件。因为requestAnimationFrame每帧都会执行一次draw()你拖动滑块时下一帧自然就读取到了新值动画实时更新。相比在input事件里手动调用draw()的做法这种写法更简单也不会出现事件触发频率高于帧率导致的重绘抖动。如果真的需要监听input比如要同步更新一个数字显示也只在事件里更新数字文本不要把绘制逻辑塞进去。5.2 让鼠标直接成为参数滑块适合精确调参但鼠标拖拽更适合直觉探索。监听 Canvas 的pointermove事件把鼠标 x 坐标映射到 0 到 2π 的相位区间再在draw()里把这个相位加到角度上图形就会跟着鼠标方向转动let phase 0; canvas.addEventListener(pointermove, (e) { phase (e.clientX / canvas.clientWidth) * Math.PI * 2; });这个交互方式在利萨如曲线、齿轮咬合、摆线演示中尤其好用。注意事件回调里只存坐标值绘制统一放在requestAnimationFrame循环里这是保持动画流畅的基本原则。5.3 暂停与随机化两个容易被忽略的小设计暂停按钮的价值在导出和截图时才会体现。只需要一个布尔状态循环里判断一下let running true; function loop(time) { draw(time); if (running) requestAnimationFrame(loop); } pauseBtn.onclick () { running false; }; resumeBtn.onclick () { requestAnimationFrame(loop); };随机化按钮更有意思。给参数赋一组随机值往往能撞见意想不到的漂亮图案这在利萨如曲线和多角星里非常明显。建议把随机范围限制在合理区间不然很容易出现一团乱麻randomBtn.onclick () { nSlider.value 3 Math.floor(Math.random() * 21); speedSlider.value 10 Math.floor(Math.random() * 90); };很多惊艳的几何纹理不是设计出来的而是随机化之后碰巧遇见的。多留一个按钮就是多给灵感留一条路。6. 导出成片把 Canvas 动图录制成 GIF / 视频文件6.1 先想清楚目标平台要什么文件网页里跑的 Canvas 动画自己看没问题但一旦要放进文档、发给别人或者传到社交平台就需要导出成视频或 GIF 文件。先判断一下目标平台的要求网页内嵌Canvas 实时渲染最好无需导出。文档/社交平台通常需要 WebM、MP4 或 GIF。简单短循环的线条动图GIF 完全够用且兼容性最好。大面积渐变或者复杂纹理建议用视频GIF 只有 256 色调色板渐变区域容易出现色带和噪声。6.2 用 MediaRecorder 原生录制 WebM浏览器自带MediaRecorder接口配合canvas.captureStream()就能把 Canvas 动画直接录成 WebM 视频不需要任何第三方库。下面是一个带开始、停止按钮的录制方案const chunks []; let recorder null; const options MediaRecorder.isTypeSupported(video/webm;codecsvp9) ? { mimeType: video/webm;codecsvp9 } : {}; startBtn.onclick () { chunks.length 0; const stream canvas.captureStream(60); recorder new MediaRecorder(stream, options); recorder.ondataavailable (e) chunks.push(e.data); recorder.onstop () { const blob new Blob(chunks, { type: video/webm }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download geometry.webm; a.click(); URL.revokeObjectURL(url); }; recorder.start(); }; stopBtn.onclick () { if (recorder) recorder.stop(); };captureStream(60)里的 60 是期望帧率实际帧率依然由requestAnimationFrame决定。录制期间不要切换浏览器标签页否则requestAnimationFrame会暂停录制内容会卡住。如果目标平台需要 MP4建议先录 WebM再通过桌面端工具转封装浏览器端直接录 MP4 的兼容性目前还不够稳定。提示录制时给画面留一点呼吸时间动画启动后等一两秒再点开始停止前也留一点余量方便后期裁剪。6.3 为什么你导出的视频总是不够清晰这个问题十有八九出在 Canvas 的尺寸上。canvas.width和canvas.height是实际像素缓冲区大小CSS 里的width和height只是显示尺寸。如果你平时在页面上显示的是 600x400直接把 Canvas 录制出来导出的视频分辨率大概率也是 600x400放大之后自然模糊。想要高清导出录制前把canvas.width和canvas.height改成目标分辨率比如 1920x1080。但很多几何代码里会把中心坐标硬编码成300, 200改尺寸后图形会偏到角落。所以建议从一开始就把中心坐标写成变量const cx canvas.width / 2; const cy canvas.height / 2;另外注意高分屏设备。如果初始化时为了屏幕清晰做了canvas.width cssWidth * devicePixelRatio导出前一定要把尺寸重置为固定目标值否则视频分辨率会变成两倍或三倍不仅文件变大观感也没有实质提升。6.4 非要 GIF 时的兜底方案某些老旧平台确实只认 GIF那就需要逐帧收集 Canvas 画面再交给 GIF 编码库合成。大致思路是每帧调用canvas.toBlob()或读取ImageData保存当前画面。用一个帧率计数器控制采集间隔比如每 1000/30 毫秒采集一帧。全部采集完成后交给 GIF 编码库合成设置循环次数为无限。控制总帧数。两三秒、每秒 30 帧就是 60 到 90 帧内存占用已经不小超过一百帧建议降低帧率或缩短时长。说实话现在绝大多数平台都已经支持视频文件GIF 更多是兜底选择。如果动图里有大量渐变效果转成 GIF 后画质下降会非常明显这时候优先用 WebM 或 MP4 才是更合理的选择。提示导出时不要把分辨率盲目调高1920x1080 在绝大多数场景已经足够。分辨率再高只是增大文件体积线条类动图的观感提升有限。最后说一点个人体会。我做了不少几何动图之后发现最惊艳的效果往往不是提前设计出来的而是把参数范围放宽之后意外看到的。上面这些示例代码都很短改几个系数、拖几次滑块、顺手点几下随机化你可能在十分钟内就得到一张很喜欢的图。别怕数学三角函数翻来覆去也就那一两个公式。先把第 2 节的例子跑起来把边数、半径、速度都拖一遍后面的事自然水到渠成。