先讲个真实经历。有阵子我想给一个活动页面做星空氛围,产品君丢过来一句话:“撒点五角星上去,要动的那种。”我心想,五角星有啥难的,循环五个点连起来不就成了?结果画出来的东西连我自己都看不下去——有的像被踩过的塑料瓶盖,有的像长歪了的海星,旋转起来简直是在扭秧歌。
后来我把这事彻底搞明白了:五角星看着简单,实际涉及两个圆的坐标换算、黄金分割比例、Canvas路径闭合、随机数分布边界、设备像素比……一整套东西。这篇文章就用“100个五角星随机撒屏”这个实战项目,从数学原理讲到代码实现,再把我调试过程中踩过的坑和优化思路完整摊开。零基础的读者照着敲,也能跑出效果;有基础的可以重点看第二章的几何推导和第五章的排坑记录。
1. 为什么是Canvas:四种“撒星星”方案的真实差距
先给结论:100个五角星这个数量级,Canvas 2D是最合适的选择。不是说其他方案不行,而是Canvas对上这个场景的性价比最高。尤其是标题里强调“随机撒屏”而不是“摆放五角星图标”,这意味着星星会动、会闪、会转,对渲染方案的动态性能有硬要求。
1.1 方案对比:DOM、SVG、Canvas 2D、WebGL
很多新手看到“100个五角星”,第一反应是往页面里塞100个div或者100个SVG图形。这两种思路在少量静态元素时没问题,但一旦让它们动起来,麻烦就来了。
我把四个方案放在一起对比一下:
| 方案 | 100个静态星星 | 100个动态星星 | 小白上手成本 | 典型瓶颈 |
|---|---|---|---|---|
| DOM节点 | 容易 | 稍卡 | 低 | 主线程频繁更新样式,帧率不稳 |
| SVG | 容易 | 事件方便但运动开销大 | 中 | 每个图形都是独立DOM节点,动画触发大量重排 |
| Canvas 2D | 容易 | 流畅 | 低 | 数量到几千以后性能开始下降 |
| WebGL | 繁琐 | 性能最强 | 高 | 需要写着色器、管理缓冲,入门曲线陡 |
表格里其实藏着一条主线:越往下的方案,离“直接操作像素”越近,性能越高,但代码复杂度也越高。
DOM和SVG属于保留模式渲染——你创建了一个星星对象,浏览器会一直记住它并在屏幕上管理它。好处是每个星星天然支持click、hover等事件;坏处是浏览器要为这100个对象建立一棵庞大的“文档树”,每次位置或角度的变化都要走一遍样式计算、布局、绘制的完整流程。100个节点还好,如果哪天你想把数量翻到500,页面会明显开始“喘”。
WebGL是另一个世界。它直接调用GPU,几万个粒子都能轻松跑,但你需要理解顶点着色器、片元着色器、缓冲区管理。为了让100个五角星发光而去学这一整套,性价比太低,对小白尤其不友好。
Canvas 2D落在这条线的中间偏左,但它恰恰是“粒子类视觉项目”的甜点区。它背后是一块二维位图画布,你用画笔往上涂,涂完就完了,浏览器不保留任何“星星对象”——所有状态都得你自己用JavaScript对象维护。这听起来麻烦,实际上给了你最大的自由度:星星怎么动、怎么排列、怎么消失,完全由你说了算,没有文档树在背后拖后腿。
1.2 为什么100这个数量级正好落在Canvas舒适区
我实测下来的体感是这样的:Canvas 2D画10万个圆点会卡到怀疑人生,画1万个圆点要开始认真优化,画100个五角星则属于“闭着眼睛写也不会卡”的范围。因为100次beginPath、fill操作,对现代浏览器来说连热身都算不上。
那么为什么还要单独讨论这个数量级?因为100这个数字很微妙。少于50个,你其实用SVG甚至DOM也能做出流畅效果,技术选型的差异不明显;一旦超过100,文档树方案开始出现偶发性的掉帧,而Canvas依然稳如老狗。更重要的是,100能覆盖绝大多数真实场景:活动页星空背景、节日彩带、加载动画、简单的粒子模拟,基本都在这个量级。
选Canvas还有一个隐形好处:你提前把“对象状态+渲染循环”这套粒子系统的骨架搭好了。后面哪怕需求膨胀到“500颗流星”“1000片花瓣”,你改的只是对象字段和绘制函数,架构不用推倒重来。我做了几年前端,最大的感受是——方案之间差的不是功能,而是你为未来需求留了多少余地。Canvas在这里,余地最大。
2. 五角星画不像的根源:两个圆、十个顶点和0.382的比例
在动手写代码之前,必须先解决一个灵魂问题:为什么那么多人画出的五角星不像五角星?
因为大多数人的直觉错了。你以为五角星是“画五个点连起来”,但五角星的轮廓根本不是五边形。你把一个标准五角星放在放大镜下看,会发现它的边缘一共有10个转折点:5个向外凸的尖角,5个向内凹的谷点。如果你只画5个点,得到的是一颗“五边形星”——圆鼓鼓的,像被撑开的盾牌,完全没有星星的精气神。
2.1 五角星是两个同心圆的嵌套变形
把五角星解剖一下,你会发现它其实是两个圆的组合。外圆半径R决定星星整体的大小,内圆半径r决定星星“肚子”凹进去的深度。
外圆上均匀分布着5个外顶点,两两相隔72度(360度除以5)。内圆上同样均匀分布着5个内谷点,但它们的位置正好落在两个外顶点的正中间。也就是说,内谷点相对外顶点偏移36度。外顶点、内谷点、外顶点、内谷点……依次用直线连起来,就是五角星的轮廓。
这段话值得多看两遍。它是五角星所有几何问题的地基:外顶点作用在半径为R的圆上,内谷点作用在半径为r的圆上,两个圆的圆心相同。
那这个r取多少合适?如果你随手取一个值,比如r = R / 2,画出来会是一颗“胖星星”——五个角变钝,凹陷几乎看不出来,更像一个齿轮。如果你让r小到R / 10,星星会变成五根细长的尖刺,中间几乎没有“身体”。这两个极端都不符合大家对五角星的日常认知。
2.2 0.382这个比例是怎么来的
标准五角星有一个被反复验证的黄金比例:内谷点所在圆的半径,约为外圆半径的0.382倍。也就是:
r = R * (3 - √5) / 2 ≈ R * 0.381966这个数字看着眼熟吧?它就是黄金比例φ的平方的倒数。为什么五角星会和黄金比例缠在一起?因为标准五角星可以理解为正五边形的五条对角线互相切割后的结果,而对角线的交点恰好把每条对角线按黄金分割切开。你不需要把这条几何链完整推一遍,只需要记住结论:0.382是最“有精神”的五角星比例,每个尖角正好是36度,整体轮廓最符合直觉。
我在调试中试过各种数值,经验是这样的:想让星星更饱满圆润,把比例调到0.45到0.5之间,它会像一枚勋章;想让星星更尖锐凌厉,调成0.3左右,它会像一块荆棘。小于0.3之后,星星开始朝“暗器”的方向走,超过0.5则像被压扁的五边形。实战里如果客户没有明确要求,直接用0.382永远不会出错。
2.3 从数学到canvas路径:一段坐标代码
有了上面的几何模型,画五角星的代码就水到渠成了。核心公式就一个:圆上角度对应的坐标是cos(angle) * radius和sin(angle) * radius。
需要注意一个细节:我习惯把第一个外顶点放在-90度方向,也就是正上方。如果你从0度(正右方)开始画,星星会整个歪过来,最常见的表现是“尖角朝向45度斜向”,看起来像被人拧了一把。
function drawStar(ctx, x, y, outerR, innerR, rotation) { ctx.beginPath(); for (let i = 0; i < 5; i++) { // 外顶点:每72度一个,起始位置在正上方(-90度) const outerAngle = -Math.PI / 2 + (i * 4 * Math.PI) / 5 + rotation; const outerX = x + outerR * Math.cos(outerAngle); const outerY = y + outerR * Math.sin(outerAngle); ctx.lineTo(outerX, outerY); // 内谷点:相对外顶点偏移36度(即π/5) const innerAngle = outerAngle + Math.PI / 5; const innerX = x + innerR * Math.cos(innerAngle); const innerY = y + innerR * Math.sin(innerAngle); ctx.lineTo(innerX, innerY); } ctx.closePath(); }这里有个关键点:innerAngle要基于outerAngle加Math.PI / 5,而不是重新计算一个独立的起始角度。因为旋转参数rotation加在outerAngle上之后,内谷点必须跟着一起转,否则星星整体旋转时,内外顶点会错位,画出来就是一个“扭曲的螺旋星”。
另外,closePath()这句特别重要。五角星的路径是“外顶点-内谷点-外顶点”交替画出的,画完最后一个内谷点后,需要一条直线回到起点,closePath()做的就是这件事。我遇到过好多次,代码写对了但填充出来的星星缺了一个角,排查到最后就是漏了这行。如果你之前用Python海龟绘图画过五角星,一定体会过“填充不完整”的诡异现象——Canvas里对应的坑就是路径没闭合,原理一模一样。
3. 从画一个五角星到一百个:代码逐段拆解
几何问题解决了,接下来把整个项目跑起来。我直接把完整代码放在3.4,但在那之前,先拆开讲清楚每一段的职责。
3.1 画布初始化:尺寸、设备像素比和resize
canvas元素有三个尺寸概念需要分清:canvas.width是画布位图的物理宽度,canvas.height是位图的物理高度,而style.width和style.height是它在页面上显示的CSS尺寸。三者如果不对齐,画面要么模糊,要么变形。这里先给出一版稳妥的初始化:
const canvas = document.getElementById('starCanvas'); const ctx = canvas.getContext('2d'); function resizeCanvas() { const dpr = window.devicePixelRatio || 1; const cssWidth = window.innerWidth; const cssHeight = window.innerHeight; // CSS尺寸决定画布在页面上占据多大空间 canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; // 位图尺寸要按物理像素来,高分屏下必须乘以DPR canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); // 把坐标系放大DPR倍,后续逻辑坐标直接用CSS像素即可 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener('resize', resizeCanvas); resizeCanvas();这段代码是Canvas全屏项目的“标准开场白”。第2章强调过canvas.width和style.width的区别,这里就是落地。不处理DPR的表现很典型:在Retina屏上,星星边缘全是毛刺,像隔着一层起雾的玻璃;处理之后,线条立刻锐利清晰。
3.2 Star对象:5个字段和一组随机参数
Canvas是立即模式,它不保存星星对象,所以每一位“五角星居民”都需要一个普通的JavaScript对象来记录自己的状态。我把这个对象命名为Star,它包含的字段可以分成两大类:描述“长什么样”的和描述“怎么动”的。
| 字段 | 含义 | 生成方式 |
|---|---|---|
| x, y | 星星中心坐标 | 随机位置,画布范围内 |
| outerR | 外圆半径,决定大小 | 8到32像素之间随机 |
| innerR | 内圆半径(谷点深度) | outerR × 0.382,或按需要调整 |
| rotation | 当前旋转角度 | 0到2π随机 |
| rotationSpeed | 旋转速度 | -0.03到0.03之间的随机值 |
| vx, vy | 漂移的速度分量 | -0.3到0.3之间随机 |
| baseOpacity | 基础不透明度 | 0.4到1之间随机 |
| phase | 闪烁相位 | 0到2π随机 |
| twinkleSpeed | 闪烁速度 | 0.5到2之间随机 |
| hue | 色相 | 0到360随机 |
参数取值范围不是乱定的,我下面讲动画章节时会展开说。现在先看代码实现:
class Star { constructor(options = {}) { this.x = options.x ?? Math.random() * window.innerWidth; this.y = options.y ?? Math.random() * window.innerHeight; this.outerR = options.outerR ?? 8 + Math.random() * 24; this.innerR = this.outerR * (options.innerRatio ?? 0.382); this.rotation = Math.random() * Math.PI * 2; this.rotationSpeed = (Math.random() - 0.5) * 0.03; this.vx = (Math.random() - 0.5) * 0.6; this.vy = (Math.random() - 0.5) * 0.6; this.baseOpacity = 0.4 + Math.random() * 0.6; this.phase = Math.random() * Math.PI * 2; this.twinkleSpeed = 0.5 + Math.random() * 1.5; this.hue = Math.random() * 360; // 颜色字符串只生成一次,避免每帧重复拼接 this.color = `hsl(${this.hue}, 75%, 65%)`; } }注意到color字段了吗?很多人会在绘制函数里写ctx.fillStyle =hsl(${Math.random()*360}, 75%, 65%)``,这样每帧都在创建新字符串。100颗星每帧100次字符串拼接,看起来不痛不痒,但垃圾回收的压力是真实存在的。把颜色缓存到对象里,是粒子系统的基本功。
3.3 drawStar绘制函数:一次绘制一个五角星
绘制函数接收一个Star实例和一个时间值,做三件事:先保存画布状态,再把坐标系平移到星星中心并旋转,最后绘制路径填充颜色。
function drawStar(star, time) { // 闪烁:用正弦函数把透明度变成时间相关的波浪 const twinkle = 0.6 + 0.4 * Math.sin(time / 1000 * star.twinkleSpeed + star.phase); ctx.save(); ctx.translate(star.x, star.y); ctx.rotate(star.rotation); ctx.beginPath(); for (let i = 0; i < 5; i++) { const outerAngle = -Math.PI / 2 + (i * 4 * Math.PI) / 5; ctx.lineTo(star.outerR * Math.cos(outerAngle), star.outerR * Math.sin(outerAngle)); const innerAngle = outerAngle + Math.PI / 5; ctx.lineTo(star.innerR * Math.cos(innerAngle), star.innerR * Math.sin(innerAngle)); } ctx.closePath(); ctx.globalAlpha = star.baseOpacity * twinkle; ctx.fillStyle = star.color; ctx.fill(); ctx.restore(); }这段代码里,ctx.translate和ctx.rotate配合使用,相当于我把“画笔”拿起来、挪到星星中心、旋转一个角度,然后在这个局部坐标系里画五角星。这样x、y、rotation都不用参与顶点坐标的复杂运算,代码可读性高得多。save和restore是成对出现的,保证画完一颗星之后,画布状态恢复原样,下一颗星不会继承莫名其妙的旋转或透明度。
3.4 主循环与完整代码
动画主循环用的是requestAnimationFrame。浏览器会在每次刷新屏幕前调用我们传入的回调函数,通常每秒60次。它比setInterval靠谱的地方在于:标签页切到后台时,浏览器会自动暂停动画,不浪费资源。
const stars = Array.from({ length: 100 }, () => new Star()); function update(time) { // 清空上一帧,否则星星会叠出拖影 ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); for (const star of stars) { // 更新状态:旋转、漂移 star.rotation += star.rotationSpeed; star.x += star.vx; star.y += star.vy; // 边界反弹,后面会细说 if (star.x < star.outerR || star.x > window.innerWidth - star.outerR) { star.vx *= -1; star.x = Math.max(star.outerR, Math.min(window.innerWidth - star.outerR, star.x)); } if (star.y < star.outerR || star.y > window.innerHeight - star.outerR) { star.vy *= -1; star.y = Math.max(star.outerR, Math.min(window.innerHeight - star.outerR, star.y)); } drawStar(star, time); } requestAnimationFrame(update); } requestAnimationFrame(update);整个项目只需要一个HTML文件就能跑:一个canvas标签、一个背景色为深蓝的body、一个script脚本,把上面的代码按顺序拼进去,100颗五角星就会在屏幕上旋转、漂移、闪烁。没有框架、没有构建工具、没有依赖,这大概就是Canvas对新手的最大友好度。
不过这里有个取舍需要提一下:旋转漂移的速度我直接写在代码里,没有用deltaTime修正。这意味着在高刷新率屏幕(比如120Hz)上,星星运动会比60Hz屏幕上快一倍。对要求不高的小项目来说无所谓;如果严格追求跨设备一致性,你可以在主循环里计算两帧间隔delta,然后让rotation += rotationSpeed * delta。这是进阶优化,放到第4章末尾再说。
4. 撒屏动画调优:转速、闪烁、边界反弹与疏密控制
代码跑起来之后,你大概率会发现“能看,但不够好看”。这很正常,随机参数能保证多样性,但不能保证美感。这一章讲怎么把“能看”变成“耐看”。
4.1 三种基础动画:旋转、闪烁、漂移
旋转是最容易出效果的。我这里给每个星星一个随机转速,正负都有,方向有快有慢。如果所有星星都朝同一方向转,屏幕会有一种“洗衣机甩干”的眩晕感。更自然的做法是:大约三分之一的星星正转,三分之一反转,三分之一几乎静止。实现方式就是上面代码里的(Math.random() - 0.5) * 0.03——减0.5让结果均匀分布在-0.5到0.5之间,再乘系数控制速度。
闪烁的本质是让透明度随时间变化。我用的公式是sin(time * speed + phase),输出的范围是-1到1,通常要映射到0到1之间,所以写成0.6 + 0.4 * sin(...),让透明度在0.2到1之间波动。phase参数保证所有星星不在同一时刻眨眼,否则会整齐地一明一暗,像安装了节拍器。
漂移是高阶玩法。给星星一个速度向量之后,它会像碎纸屑一样慢慢飘动。注意速度不能太大,否则屏幕像在下冰雹;也不能都在0附近,否则和静态图没区别。我常用的范围是-0.3到0.3像素每帧,折合每秒最多18像素,属于“能感觉到在动但不抢注意力”的节奏。如果你想让某些星星飘得快一点,可以特意让少量星星的速度落在0.6以上,给画面增加一点动态层次。
4.2 边界反弹:别让半颗星挂在屏幕边
如果你给星星加了漂移,迟早会遇到“半颗星卡在屏幕边缘”的问题。原因很朴素:边界判断用的是星星中心的坐标,当中心即将超出屏幕时,外半径outerR已经把一部分画到屏幕外面去了。
所以边界阈值不是0,而是star.outerR:
if (star.x < star.outerR || star.x > window.innerWidth - star.outerR) { star.vx *= -1; star.x = Math.max(star.outerR, Math.min(window.innerWidth - star.outerR, star.x)); }Math.max和Math.min套在一起,目的是把坐标“夹紧”回合法区间。防止星星在高速移动时,一帧之间冲过头,导致它卡在边界外一直抖动。这种“先反转方向,再拉回合法位置”的处理,比单纯的反转速度更稳。
同样的逻辑也适用于初始化坐标。如果你直接用Math.random() * width作为初始x,那些出生在边缘附近、半径又比较大的星星,会从一开始就缺个角。正确的随机位置生成要考虑半径余量:x = outerR + Math.random() * (width - 2 * outerR)。我把这个坑放在后面第五章专门讲,这里先记住结论。
4.3 分布调优:随机也可能“太均匀”
很多人以为“随机撒屏”就是完全随机,但真跑起来你会发现一个有趣的现象:纯随机出来的100个点,视觉上反而有一种均匀感,像一张渔网均匀撒在屏幕上,少了夜空那种天然的疏密层次。
原因在于,完全独立均匀的随机分布,在样本量足够大时,确实会趋向于均匀铺满。如果你想要的是“有的角落挤成一团,有的区域空荡荡”的自然感,需要引入一些额外手段。
我推荐一种简单可控的方案:分层随机,也叫网格抖动。把屏幕想象成一张棋盘,把100颗星分配到各个格子里,每格先放一颗,然后在格子内做小范围随机偏移。这样既能保证星星不会全部挤到某个角落,又保留了随机分布的自然感。
function generateStarsByGrid(count) { const cols = Math.ceil(Math.sqrt(count * (window.innerWidth / window.innerHeight))); const rows = Math.ceil(count / cols); const cellW = window.innerWidth / cols; const cellH = window.innerHeight / rows; const result = []; for (let i = 0; i < count; i++) { const star = new Star(); const gx = (i % cols) * cellW + cellW / 2; const gy = Math.floor(i / cols) * cellH + cellH / 2; star.x = gx + (Math.random() - 0.5) * cellW * 0.6; star.y = gy + (Math.random() - 0.5) * cellH * 0.6; star.x = Math.max(star.outerR, Math.min(window.innerWidth - star.outerR, star.x)); star.y = Math.max(star.outerR, Math.min(window.innerHeight - star.outerR, star.y)); result.push(star); } return result; }(Math.random() - 0.5) * cellW * 0.6把星星限制在格子中心左右30%的范围内,保证相邻格子不会串得太离谱。如果你想要星星之间有更多“空档”,就把0.6调小;想让分布更乱,调大甚至直接去掉网格,退回纯随机。这一格之差,就是我说的“随机撒屏”和“均匀撒屏”的核心取舍。
5. 实测踩坑记录:模糊、变形、被裁切与掉帧排查
如果说前四章是教程,这一章就是事故报告。我把这几年做Canvas动画遇到的高频问题集中写出来,每一个都是我或者我身边同事真正踩过的,按现象、根因、解决方案三步来。
5.1 星星发虚:设备像素比(DPR)适配
现象很典型:星星在电脑浏览器上锐利清晰,拿到手机上一看,边缘全是毛刺,像透过磨砂玻璃看东西。很多人第一反应是“Canvas抗锯齿没开”,其实Canvas的2D绘图默认就有抗锯齿,问题出在物理像素和CSS像素没有对齐。
高分屏上,一个CSS像素对应多个物理像素。如果你把canvas.width直接设置成CSS宽度,画布只有一半甚至三分之一的物理像素可用。浏览器把这张小位图拉伸到大屏幕时,每个像素都要被插值放大,画面自然就糊了。
解决方案已经在3.1节写过了,这里再强调一次它的核心:canvas.width = cssWidth * window.devicePixelRatio,然后调用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系放大。setTransform这行很多人不写,于是后面所有坐标都要乘以dpr,改起来极其痛苦。加上它,后续代码就能一直用CSS像素思考问题。
5.2 窗口缩放后变形与拉伸
第二个高频坑出现在监听窗口resize事件的时候。有些同学只在resize里改了canvas.width和canvas.height,没改CSS尺寸。结果一阵风把窗口拖大或者缩小,星星全部被拉成了椭圆形,形状歪歪扭扭。
根因还是3.1节那个“三位一体”问题:canvas.width是位图尺寸,style.width是显示尺寸,两者必须同步。更隐蔽的是,如果DPR很高,你只改位图尺寸而不改CSS尺寸,位图像素和显示区域错位得更厉害。我的习惯是封装一个resizeCanvas函数,里面四行代码成对出现:style.width、style.height、canvas.width、canvas.height。resize事件触发时统一调用,保证永远不出错。
5.3 边缘被裁切:忘记扣除外接半径
这个坑很蠢,但极其常见。我给星星设了漂移,边界反弹也写了,结果跑几分钟后还是发现有的星星半只挂在屏幕外。排查到最后发现,我把边界判断的阈值写成了0,而不是star.outerR。
星星的中心还在屏幕内,但它的外接圆半径已经让一部分身体超出了画面。正确的边界条件应该是“星星中心离边缘的距离不能小于外接半径”。同时,初始化随机坐标时也要预留这个余量:x = star.outerR + Math.random() * (width - 2 * star.outerR)。这样100颗星星无论出生还是漂移,都不会出现“半颗星”的尴尬。
5.4 明明只有100个对象,为什么还是掉帧
100颗五角星,理论上闭眼跑都流畅,但有人实测掉到了30帧。逐个排查,最常见的原因有三个:
第一,每帧重新生成颜色字符串。ctx.fillStyle =hsl(${Math.random() * 360}, 75%, 65%)``这种写法让每颗星每帧都在创建字符串,100颗星一秒60帧就是6000次字符串分配。改法很简单:在Star构造时生成this.color,绘制时直接复用。
第二,开启了ctx.shadowBlur。阴影效果好看,但开销非常大,低端设备上100个带阴影的图形足以把帧率拉到不可用。我的建议是:要么不开,要么只给少数“最亮的星星”开。第6章会给出更便宜的发光替代方案。
第三,用了半透明填充清屏。如果你看到星星拖着残影,多半是有人在update开头用ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; ctx.fillRect(...)来制造拖影。这不是错误,但如果不想要拖影,就应该用clearRect老老实实清屏。拖影方案的代价是每帧多一次全屏填充,加上半透明叠加重绘,100颗星的时候帧率也会跟着抖。
我把这四种常见问题的排查表列在一起,方便以后当速查手册:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 边缘发虚 | 没适配DPR | canvas尺寸乘以devicePixelRatio,setTransform放大 |
| 窗口缩放后变形 | 位图尺寸和CSS显示尺寸不同步 | resize时同步更新style和canvas的width/height |
| 星星被裁切 | 边界判断没算外接半径 | 阈值用outerR,初始化坐标留余量 |
| 帧率掉到30 | 反复生成颜色字符串、开shadowBlur、半透明全屏填充 | 缓存颜色、限制阴影数量、用clearRect清屏 |
6. 还能怎么玩:交互点击、发光叠加与星座连线
100颗五角星撒屏跑通之后,这个项目只是起点。Canvas粒子系统的骨架已经搭好,接下来加什么都只是往Star数组里塞新行为。
6.1 点击生成新星与数量上限管理
给Canvas加交互,首先要解决坐标系转换问题。鼠标事件里的clientX和clientY是相对浏览器窗口的,如果canvas在页面中不占满全屏或者页面有滚动条,直接使用会错位。正确的写法是减去canvas元素自身的偏移:
canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const star = new Star({ x: e.clientX - rect.left, y: e.clientY - rect.top, outerR: 10 + Math.random() * 20 }); stars.push(star); // 控制总数量,防止无限增长拖垮性能 if (stars.length > 300) { stars.shift(); } });getBoundingClientRect返回的是canvas在页面上的实际位置,这个操作本身不贵,但注意不要在每帧动画里调用它,只在事件回调里用就好。
限制总数的shift()有一个妙用:它把最老的星星移出数组,新星星不断加入,视觉上就像一连串新的五角星在屏幕中“生出来”,适合做节日许愿、烟花类型的互动。你完全不用停下来优化性能,因为300颗星的上限已经被明确框死了。
6.2 发光效果:shadowBlur和叠加合成
想让星星发光,第一个想到的是shadowBlur,但我在5.4说了,它太贵。这里给两个更实惠的方案。
第一个是globalCompositeOperation,把叠加模式改成lighter。它的原理是像素颜色相加,重叠区域会越来越亮,特别适合星空背景:
// 在绘制星星之前设置 ctx.globalCompositeOperation = 'lighter'; // ...绘制所有星星... // 画完后恢复 ctx.globalCompositeOperation = 'source-over';实测下来,lighter模式在暗色背景上的效果非常惊艳,星星重叠的地方会产生自然的“辉光”,而且开销远小于shadowBlur。缺点是完全重叠的两颗星星会亮到发白,如果你的画面里允许星星大量堆叠,需要对叠加后的亮度做过曝约束。
第二个方案是给星星内部画一个径向渐变。用ctx.createRadialGradient在星星中心做一个从亮到暗的渐变,再把它作为fillStyle填充。这样每一颗星自带光晕,视觉柔和,性能开销只有创建渐变的成本。我建议只在大星星上用渐变,小星星直接用纯色填充即可,层次感反而更好。
6.3 星座连线与下一步想象
最近很多星空类页面喜欢加一个“星座连线”特效:星星之间距离小于某个阈值,就画一条低透明度的线,距离越近线越亮。这个效果在Canvas里实现起来出乎意料地简单,只需要在绘制星星之前先遍历所有星星对:
const LINK_DISTANCE = 130; function drawConstellationLines() { for (let i = 0; i < stars.length; i++) { for (let j = i + 1; j < stars.length; j++) { const dx = stars[i].x - stars[j].x; const dy = stars[i].y - stars[j].y; const dist = Math.hypot(dx, dy); if (dist < LINK_DISTANCE) { ctx.beginPath(); ctx.moveTo(stars[i].x, stars[i].y); ctx.lineTo(stars[j].x, stars[j].y); ctx.strokeStyle = `rgba(255, 255, 255, ${0.3 * (1 - dist / LINK_DISTANCE)})`; ctx.stroke(); } } } }代码里唯一需要留心的是复杂度。100颗星两两比较是4950次距离计算,每帧跑完全没问题。但如果有一天你把数量加到1000,两两比较就变成约50万次,那就需要用空间网格优化了——这是“粒子系统从入门到进阶”的经典门槛。我个人的建议是:星星少于300颗时,这种暴力遍历完全可接受;超过300再做优化不迟。
再往下,你可以把星星换成任意形状——五角星可以变成圆形光点、心形、雪花,甚至一张张缩略图;把漂移模式改成绕着中心点公转,就是一个微缩星系;给星星加上鼠标排斥力,点击时四散开去,就成了互动粒子场。Canvas最迷人的地方就在这里:你维护的始终是一堆“有状态的对象”,画面只是状态的可视化结果。改状态的逻辑,就是改整个世界。
最后说点个人体会。撒屏这个小项目看着简单,但它是一个极好的Canvas入门练手:几何计算、随机数使用、渲染循环、状态管理、性能优化,全都在里面了。我强烈建议你把星星数量从10开始,10、50、100、500、2000这样逐级加,观察帧率和画面表现的变化——这个动作比任何教程都更能帮你建立“性能量级感”。等你把100颗星玩得滚瓜烂熟,再回头看任何粒子系统,都会发现内核全是同一个套路:维护对象状态,画出来,更新状态,再画出来。这套功夫,今天就算是正式入门了。