图形与动画(Canvas 与他的朋友们)
🎯 核心问题
以前的网页只能展示干巴巴的文字和图片。但如果你想做打砖块游戏、华丽的动态特效、或是可以自由拖拽的数据报表呢?这就是 Canvas(画布) 诞生的原因。
1. 什么是 Canvas?
如果说早期的那些 HTML 标签(如 <div>、<img>)是用乐高积木拼起一个静态的网页,那么 HTML5 的 <canvas> 标签就是扔给你一张巨大的数字白纸,然后递给你一支靠代码控制的画笔,剩下的全交给你自由发挥。
这里面的画没有任何标签结构,你用画笔涂上去的心血,一旦落笔就变成了最纯粹的“像素颜料”。
1.1 Canvas vs SVG:两种不同流派的艺术家
在前端画图界,Canvas 有个宿敌叫 SVG。它们代表了两种截然不同的绘画观念:
Canvas(位图画板):
- 原理:就像真实在纸上涂色,几笔画上去就变成一团颜料。
- 优势:电脑只管往屏幕上“洒颜料”,性能起飞!能同时画出大几千个活蹦乱跳的闪烁粒子。
- 缺点:画完就没法单独反悔(没法被 DOM 直接选择),而且你用浏览器一旦放大,画面就会马赛克发虚。
SVG(矢量图拼接):
- 原理:就像在做幻灯片(PPT)。你画一个圆,它就生成一个圆圈的“实体对象”放在画面上。
- 优势:不管被放大成 100 倍还是 10 万倍,永远极其清晰。而且因为每一个形状都是一个独立标签,你可以在任何时候用鼠标点中某个小正方形,命令它换一种颜色。
- 缺点:如果你试图放几万个对象乱飞,繁重的排版引擎会直接把浏览器卡死。
🎮 简单总结:玩动态游戏、做酷炫粒子特效用 Canvas;画精密的 Logo、写交互清晰的小图表用 SVG。
2. 第一笔:用代码找坐标
2.1 这张纸的上下怎么颠倒了?
当你准备下笔时,得先明白 Canvas 里的尺子是反着的。对于传统的数学课坐标系,中心点零点在中间,越往上越大。
但在屏幕显示领域,几乎所有设备的“原点(0,0)”都定在屏幕的最左上角。向右走 X 轴变大没问题,但是向下走,Y 轴变大。
👇 动手点点看: 拖拽下面的这些点,直观地感受一下坐标是如何变化的:
Canvas Coordinate System / Canvas 坐标系统
- Origin / 原点:在左上角,坐标为 (0, 0)
- X Axis / X 轴:向右为正方向,从 0 到 canvas.width
- Y Axis / Y 轴:向下为正方向,从 0 到 canvas.height
- Unit / 单位:像素 (px),与 CSS 像素 1:1 对应
Example Code / 示例代码
// 绘制坐标轴
const canvas = document.getElementById('myCanvas')
const ctx = canvas.getContext('2d')
// X 轴(红色)
ctx.strokeStyle = '#e74c3c'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(canvas.width, 0)
ctx.stroke()
// Y 轴(蓝色)
ctx.strokeStyle = '#3498db'
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(0, canvas.height)
ctx.stroke()
// 绘制点
ctx.fillStyle = '#2ecc71'
ctx.beginPath()
ctx.arc(100, 100, 5, 0, Math.PI * 2)
ctx.fill()💡提示: Canvas 的 Y 轴方向与传统数学坐标系相反,向下为正。这在处理图形定位时需要特别注意。
2.2 给你的魔法画笔上调料
有了坐标,我们就能召唤那支画笔了(在代码里这支笔叫 Context 或简称 ctx)。
就像拿着调色盘作画,流程总是固定的三步:
- 调色:告诉它你需要什么填充色(
fillStyle)和描边色(strokeStyle) - 构形:构思你是画一个圈、还是一条直线?
- 下笔:实打实地去填充(
fill( ))还是去勾勒边缘(stroke( ))
👇 动手点点看: 试试把下面代码面板里的形状颜色换换:
Code / 代码
const canvas = document.getElementById('myCanvas')
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#3b82f6'
ctx.strokeStyle = '#1e293b'
ctx.lineWidth = 2
// 绘制填充矩形
ctx.fillRect(250, 150, 100, 100)
// 绘制描边矩形
ctx.strokeRect(250, 150, 100, 100)3. 翻页动画书:如何让画面动起来极度丝滑
我们刚才说过,Canvas 一旦你填上了颜色,这就变成了永久的马赛克。你怎么可能让马赛克奔跑呢?
答案是“骗过你的眼睛”。这和翻页手翻书或者电影胶片的原理一模一样。
如果你想让一个球飞起来:
- 擦黑板:用
clearRect把这整块画布上的内容毫不留情地清空! - 挪位置:让那个球的 X 坐标往前偷偷加 2 毫米。
- 下笔重画:把球在新的位置重新画一次。
- 疯狂循环:浏览器内置了一个极其精准的神仙秒表叫
requestAnimationFrame。它会以每秒 60 次(即 60 FPS)的变态速度,重复着【擦除 -> 移动 -> 重绘】。由于人眼自带“视觉残留”,你在屏幕上看到的,不仅不是黑板被擦,反而是如同丝绸般顺滑的动画。
👇 动手点点看: 尝试添加或者减少物体的数量,感受每秒 60 帧带来的无缝快感:
Animation Loop Code / 动画循环代码
// 弹跳球动画
let balls = [
{ x: 100, y: 100, vx: 2, vy: 3, radius: 20 },
// ... 更多球
]
function animate(timestamp) {
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新和绘制每个球
balls.forEach(ball => {
// 更新位置
ball.x += ball.vx * 1
ball.y += ball.vy * 1
// 边界碰撞检测
if (ball.x + ball.radius > canvas.width || ball.x - ball.radius < 0) {
ball.vx = -ball.vx
}
if (ball.y + ball.radius > canvas.height || ball.y - ball.radius < 0) {
ball.vy = -ball.vy
}
// 绘制
ctx.beginPath()
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2)
ctx.fill()
})
// 请求下一帧
requestAnimationFrame(animate)
}
// 启动动画
requestAnimationFrame(animate)Animation Principles / 动画原理
- requestAnimationFrame: 浏览器提供的动画 API,在每次重绘前调用回调函数,通常为 60FPS
- Clear & Redraw: 每帧先清除画布,再重新绘制所有内容
- State Update: 更新对象位置、角度等状态
- Performance: 使用时间差计算位置,确保不同刷新率下动画速度一致
💡提示: 动画的本质是快速连续绘制静态画面。Canvas 每秒可以绘制 60 帧(60FPS),形成流畅的动画效果。
4. 瞎子摸象:我在 Canvas 里面怎么点击?
因为 Canvas 画布就只是一张没有任何结构的“颜料布”。假设你在这个布上画了一只哥布林:
如果你想写个代码:“当玩家点中了哥布林,哥布林阵亡”。你根本没法像写普通网页那样通过 getElementById 去直接绑定这个外星怪物。因为在浏览器的眼里,这里永远没有任何怪兽,只有一块宽 600 高 400 的 <canvas> 标签死死挡在这里。
那我们要怎么做事件交互呢?
- 监听布面被点:先获取你目前鼠标点在这个死板的 HTML 大布的哪个具体的 XY 位置。
- 拿账本去对:然后你必须自己翻你的代码记录,“我记得刚刚我在(100,100)的位置画了一个半径 50 的哥布林”。
- 勾股定理:我们用初中教的勾股定理公式去疯狂计算——当前鼠标点击的位置,是不是落在了那个(100,100)距离 50 半径的圆内?。
恭喜你!这种疯狂算几何数学距离的方法就是你在各大 3A 游戏里听过的 “碰撞检测 (Collision Detection)”
👇 动手点点看: 打开最下面的“Hover 悬停模式”,你就能看到它内部拼命去算距离有多累了。
Instructions / 操作说明
- Click Mode:点击画布创建圆形,按住 Shift 可创建不同颜色
Event Log / 事件日志
Event Handling Code / 事件处理代码
// 点击创建圆形
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const circle = {
x: x,
y: y,
radius: 30,
color: '#3498db'
}
circles.push(circle)
draw()
})Event Handling Tips / 事件处理要点
- 坐标转换: 使用 getBoundingClientRect() 获取 Canvas 在页面中的位置,计算相对坐标
- 碰撞检测: 对于圆形,计算鼠标位置到圆心的距离;对于矩形,判断点是否在范围内
- 事件委托: Canvas 只有一个元素,需要手动判断事件发生在哪个对象上
- 性能优化: 使用 requestAnimationFrame 优化重绘,避免频繁操作
5. 解放算力:粒子系统与视觉魔法
到了这一步,当你把【坐标不断重绘的动画】跟【颜色和大小变换】融合,再放进成百上千个小碎片里。这就是引爆视觉的终极杀器:粒子系统。
你只需要建立一个巨大的数组,里面塞满了几百个拥有独立生命值、独立初始随机速度的数字对象。每次“重绘”,让所有的点根据重力或者惯性去减速。你的浏览器里马上就能发生逼真的大爆炸或者漫天飞雪。
👇 动手点点看: 试试“烟花”和“鼠标轨迹”!
Particle System Code / 粒子系统代码
// 粒子系统核心代码
class Particle {
constructor(x, y) {
this.x = x
this.y = y
this.vx = (Math.random() - 0.5) * 1
this.vy = (Math.random() - 0.5) * 1
this.life = 1.0
this.decay = 0.01 + Math.random() * 0.02
this.size = 3
this.color = this.randomColor()
}
update() {
this.x += this.vx
this.y += this.vy
this.vy += 0.1 // 重力
this.life -= this.decay
}
draw(ctx) {
ctx.globalAlpha = this.life
ctx.fillStyle = this.color
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1.0
}
isDead() {
return this.life <= 0
}
}
// 动画循环
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新和绘制粒子
particles = particles.filter(p => !p.isDead())
particles.forEach(p => {
p.update()
p.draw(ctx)
})
requestAnimationFrame(animate)
}Particle System Tips / 粒子系统要点
- 粒子类: 每个粒子是一个对象,包含位置、速度、加速度、生命周期等属性
- 更新循环: 每帧更新所有粒子的位置和状态,移除死亡的粒子
- 性能优化: 限制粒子数量,使用对象池复用粒子对象
- 视觉效果: 使用透明度、混合模式、渐变等增强视觉效果
💡提示: 移动鼠标或点击画布来产生粒子!不同的效果有不同的交互方式。
6. 守护 FPS 荣耀:如何应对高烧的 CPU?
让成千上万个对象在一秒内计算重画 60 遍,这是极其消耗电脑算力(CPU 和内存)的。 很多野生小白刚做出来的游戏玩了两分钟可能风扇就起飞了。下面是真正的引擎大佬使用的降温护体绝技:
- 局部擦黑板(脏矩形 Dirty Rect)! 一个角色在一望无际的草原上奔跑。你千万别每帧把整块大草原都擦了重画!角色经过哪一小块,你就用小板擦把哪里擦掉然后只补哪里的洞,这能省下几千倍的力气。
- 隐藏后台魔法(离屏 Canvas)! 如果游戏背景是繁星漫天、有各种复杂绚丽的山脉。最好先偷偷在没人的后台建一个内存 Canvas 把它一次性精美地画上去。以后每秒 60 下的刷新,你直接把这幅“定格全图”通过贴图的方式贴到前端(
drawImage)就行了。 - 批量洗画笔! 如果画画时你要反复交替使用“红、蓝、红、蓝、红”这几种笔,频繁切换。可以提前把所有红色的兵全归档画完,再清空换蓝颜料画,省去了昂贵的上下文来回切换。
👇 动手点点看: 先把对象数量拉满,看着网页快掉进卡顿的深渊,再依次打开右下方的绝技进行抢救。
Optimization Code / 优化代码
// 脏矩形优化 - 只重绘变化的部分
function draw() {
// 不清除整个画布,只清除变化的区域
if (useDirtyRect) {
objects.forEach(obj => {
if (obj.moved) {
// 清除旧位置
ctx.clearRect(
obj.oldX - obj.size,
obj.oldY - obj.size,
obj.size * 2,
obj.size * 2
)
// 绘制新位置
obj.draw(ctx)
obj.moved = false
}
})
} else {
// 传统方式:清除整个画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
objects.forEach(obj => obj.draw(ctx))
}
}Performance Tips / 性能优化要点
- 减少重绘: 只重绘变化的部分(脏矩形技术),避免不必要的 clearRect
- 离屏 Canvas: 预渲染静态内容到离屏 Canvas,减少每帧的绘制操作
- 批量渲染: 减少状态切换(fillStyle、strokeStyle 等),批量处理相同类型的绘制
- 对象池: 复用对象,减少垃圾回收压力
- requestAnimationFrame: 使用浏览器提供的动画 API,优化渲染时机
7. 名词对照表
| 术语 | 解释 |
|---|---|
| Canvas | Html5 提供的 2D 画布。绘制极快,但画完就变成颜料像素,不支持通过 DOM 操作内容。 |
| SVG | 矢量图,放大永远不模糊,且每个图形都是独立的标签元素可以单独点击绑定事件。 |
| Context (ctx) | 获取到的“2D 上下文”,可以理解为用来在这张布上调各种颜色、干各种特殊效果的“画笔”。 |
| requestAnimationFrame | 浏览器内置的神级节拍器,会以显示器的刷新率(通常 60FPS)不断狂飙执行,专门用来做完美动画。 |
| FPS / Frame Rate | 帧率。60 FPS 代表一秒钟内浏览器帮我们默默擦除了 60 次黑板并画了 60 副新图,这骗过了视神经,看起来极其丝滑。 |
| Dirty Rect / 脏矩形 | 只在画面中发生变化的微小矩形区域内进行擦除和重绘,强力保留性能。 |
| Offscreen Canvas | 藏在内存里的“影子画布”,把静态且复杂的树木和山脉先画好,当作死的一张贴图重复利用。 |
现在,不管是一把简单的魔法画笔、还是由万千雪花组成的宏大粒子系统,整个能够不断刷新重绘的数字世界引擎,都在你的掌控之中了!
