最近我写了一个叫Rainbow-Sea的 Canvas 动画 demo,目标很朴素:在浏览器里渲染一片波光粼粼的海面,再叠一道半透明的彩虹色光晕,做成可以选择这里的水面体积更小,浪线间距更密,营造“远浪碎,近浪缓”的反差。相位 phase 一定要错开,否则三条线会同时上同时下,看起来像在跳机械舞,完全没有水的松弛感。我在实际调的时候,把三条线的相位分别设置成t * 0.4、t * 0.3 + 2、t * 0.2 + 5,这样错开后依然能保持整体运动方向一致,不会互相打架。
3.3 波光粒子与彩虹离屏缓存
波光粒子是画面里的“点睛之笔”,理论上应该做成背景里的细碎光斑。我的实现思路是把粒子看成一个随机散布的集合,每个粒子有自己的横坐标、纵坐标、尺寸、闪烁速度和相位种子。每帧根据当前时间计算一个亮度系数,再通过透明度变化让粒子像真实海面上的反光一样忽明忽灭。
const particles = []; function buildParticles() { particles.length = 0; const count = Math.min(1200, Math.floor((width * height) / 1800)); for (let i = 0; i < count; i++) { particles.push({ x: Math.random() * width, y: height * 0.25 + Math.random() * height * 0.6, size: Math.random() * 1.8 + 0.4, speed: Math.random() * 2 + 0.8, seed: Math.random() * Math.PI * 2 }); } }粒子数量不是固定写死的,而是根据屏幕面积动态计算,width * height / 1800的值在普通笔记本上大约是 700 到 1400 之间。我限制到 1200,是因为粒子越多,每帧的arc()调用就越密集,超过临界点后帧率会突然暴跌,尤其是移动端。更重要的一点:粒子的亮度不要直接 0 和 1 跳变,而是用正弦函数输出一个连续变化的亮度,再压到 0.3 到 1.0 之间,这样闪烁起来才像自然界的反光,不会刺眼。
彩虹部分是本项目最值得优化的地方。彩虹渐变本身不随时间变化,如果你每帧都重新创建一个createLinearGradient,虽然现代浏览器也能扛住,但在我实测中,60Hz 刷新率下每帧多出几十毫秒的 GC 压力,帧率会明显不稳。我的做法是:把彩虹先绘制到一个离屏 Canvas 上,之后每帧只需要drawImage()一次,这个思路和游戏开发里的“纹理缓存”是同一个道理。
const rainbowCanvas = document.createElement('canvas'); const rainbowCtx = rainbowCanvas.getContext('2d'); const rainbowWidth = 700; const rainbowHeight = 220; rainbowCanvas.width = rainbowWidth; rainbowCanvas.height = rainbowHeight; const gradient = rainbowCtx.createLinearGradient(0, 0, rainbowWidth, rainbowHeight); for (let h = 0; h <= 360; h += 15) { const pos = h / 360; const alpha = Math.sin(pos * Math.PI); gradient.addColorStop(pos, `hsla(${h}, 90%, 60%, ${alpha})`); } rainbowCtx.fillStyle = gradient; rainbowCtx.fillRect(0, 0, rainbowWidth, rainbowHeight);这里alpha = Math.sin(pos * Math.PI)是整段代码里最容易被人忽略但最关键的一行。它让颜色透明度从中心向两端呈正弦衰减,也就是中间最实、两边逐渐透明。如果直接用alpha = 1,彩虹带两端会出现两个生硬的颜色切面,尤其是红和大青的起点终点,在深色背景下会像贴纸一样粘在海面上。有了sin衰减,红色和紫色都能自然融进深海色里,整体光晕感一下子就出来了。
彩虹的叠加也不是普通的source-over,而是screen:
function drawRainbow() { const scale = Math.max(width, height) / 900; const w = rainbowWidth * scale; const h = rainbowHeight * scale; ctx.save(); ctx.globalAlpha = 0.35; ctx.globalCompositeOperation = 'screen'; ctx.translate(width * 0.5, height * 0.42); ctx.rotate(-0.35); ctx.drawImage(rainbowCanvas, -w / 2, -h / 2, w, h); ctx.restore(); }screen模式的原理是颜色越亮,结果越亮,也就意味着彩虹和海面是“光融合”而不是“色覆盖”。如果换成默认的source-over,彩虹下面的海面会完全被遮住,叠加的结果就是一块半透明的彩色条,气质完全不对。screen让彩虹像一层光雾,既能透出下面的波纹,又能保持彩虹本身的颜色鲜艳度。
3.4 动画循环与页面切后台处理
最后一步是把这个流程串起来。我用requestAnimationFrame作为动画驱动,每一帧依次执行:清屏、绘制海面渐变、绘制波纹、绘制彩虹、绘制粒子。
let rafId = null; function drawSea(time) { const t = time / 1000; // 转换为秒 ctx.clearRect(0, 0, width, height); const seaGrad = ctx.createLinearGradient(0, 0, 0, height); seaGrad.addColorStop(0, '#0a2a3f'); seaGrad.addColorStop(0.5, '#104f63'); seaGrad.addColorStop(1, '#1e7a8a'); ctx.fillStyle = seaGrad; ctx.fillRect(0, 0, width, height); drawWaves(t); drawRainbow(); drawParticles(t); rafId = requestAnimationFrame(drawSea); } drawSea(0);requestAnimationFrame本身就带有帧率和设备适配能力:屏幕刷新率高,它就调得密;屏幕刷新率低,它就自动减速。这里有一个很容易掉坑的点:页面切到后台时,浏览器会自动暂停requestAnimationFrame的调度,但如果你在代码里直接把rafId存起来,切回来之后直接调用drawSea(0),画面会从零时间重新开始,导致所有粒子状态突然重置。正确做法是在visibilitychange事件里判断页面是否可见,不可见时取消动画,可见时用当前时间重新启动,并且保留粒子状态的连续性。
document.addEventListener('visibilitychange', () => { if (document.hidden) { cancelAnimationFrame(rafId); } else { drawSea(performance.now()); } });这里传入的performance.now()代表当前真实时间,这样切回页面时粒子会立刻跳转到正确位置,而不是从头开始。
4. 调试实录:模糊、过曝、掉帧,一次全讲清
写代码只是一部分,真正让项目变得可用的是调试过程。我在调 Rainbow-Sea 时踩过不少坑,这里挑几个最典型的写出来,每个都给了对应的解决思路。这些经验比代码本身值钱,因为网上几乎不会有人专门收集这种视觉 bug 的解法。
4.1 画面发虚,所有线条像隔了一层雾
这个问题的根源 90% 是 Canvas 的像素尺寸和 CSS 尺寸不一致。很多人会写canvas.style.width = 屏幕宽度,却忘了设置canvas.width,结果 Canvas 内部默认是 300×150,被 CSS 拉伸到全屏,所有绘制指令都被放大,边缘自然模糊。
解决办法就是第 3.1 节的 DPR 处理。要注意Math.min(window.devicePixelRatio || 1, 2)这个限制,如果设备像素比是 3 或者更高,Canvas 的物理尺寸会变成 3 倍于 CSS 尺寸,绘制面积瞬间变成 9 倍。对高分辨率屏来说,这个性能代价完全没必要,限制到 2 就已经足够细腻。
4.2 彩虹过曝,海面变成一片惨白
Screen 叠加模式效果好,但也容易用力过猛。当globalAlpha超过 0.5 时,彩虹和海面叠加区域会迅速逼近纯白,画面瞬间失去层次,彩虹的颜色饱和度也被冲掉,变成一块白带子。我当时第一次调试就把globalAlpha设成 0.6,结果整个海面像是被闪光灯直射,完全看不到底下波纹。
最终我把globalAlpha稳定在 0.3 左右,彩虹自身的最大透明度也限制在 0.5 以下。数字不是绝对的,但原则是:屏幕叠加模式下,宁可弱,不可过。颜色不够可以靠提高彩虹渐变的饱和度来补,但一旦过曝,后期处理会很麻烦。
4.3 粒子闪烁太硬,像广告牌灯
这是粒子动画最常见的问题。如果粒子的 alpha 在 0 和 1 之间直接跳变,画面会出现明显的频闪感,视觉上像故障灯。我后来改成用正弦函数做连续亮度变化,并且把亮度下限保持在 0.3 以上,让粒子即使暗的时候也不是彻底消失,这样在海面背景上才能维持“水下反光”的质感。
另外提一句粒子位置漂移:我让每个粒子在纵向上做一个很微小的Math.sin(t * speed * 0.7 + seed) * 3的浮动,幅度只有 3 像素。这个幅度太小你可能看不出具体变化,但整体上会让粒子有一种在水面上漂浮的错觉,而不是死板地钉在原地。这个技巧很廉价,效果却很自然。
4.4 低端设备掉帧严重,怎么降级
如果你的目标用户里有大量中低端手机,必须要做降级方案。我列出几个实测有效的手段,按优先级排列:
| 手段 | 实现方式 | 收益 |
|---|---|---|
| 减少粒子数量 | 把总数量按屏幕面积再乘 0.6 | 减少 40% 的 arc 调用 |
| 关闭彩虹带 | 检测帧率低于 45 时直接不画彩虹 | 减少一次全屏 drawImage |
| 降低波纹复杂度 | 三条波线改成两条 | 减少 1/3 的路径绘制 |
| 切换静态背景 | 帧率持续低于 30 时停止 requestAnimationFrame,改投一张静态渲染图 | 彻底消除动画开销 |
动态降级不要一开始就做,建议在循环里每 2 秒统计一次平均帧率,低于阈值再执行切换。用户打开页面的头一秒往往因为资源加载和缓存预热而较慢,这时直接降级会让高端设备也享受不到完整效果。
4.5 表格速查:调试问题一览
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 画面模糊、字线发虚 | 未按 DPR 设置 canvas 尺寸 | 设置canvas.width = 宽 × dpr,并ctx.setTransform(dpr, 0, 0, dpr, 0, 0) |
| 彩虹带两端生硬 | 渐变透明度没有衰减 | 用sin(pos * PI)作为透明度系数 |
| 海面过曝泛白 | globalAlpha或彩虹亮度太高 | 降低globalAlpha到 0.3,或控制渐变颜色亮度到 60% |
| 粒子闪烁刺眼 | alpha 在 0/1 之间跳变 | 用正弦输出 0.3~1.0 的连续亮度 |
| 页卡顿但 CPU 占用不高 | 可能是复用了大量对象导致 GC 频繁 | 把不变化的渐变和图形绘制到离屏 Canvas 缓存 |
| 切后台再回来动画跳动 | 时间轴从 0 重新计 | 用performance.now()重新作为初始时间 |
| 移动端彩虹太占资源 | 彩虹尺寸按全屏放大 | 限制彩虹最大宽度,或直接降级关闭 |
4.6 关于配色一个容易被忽略的细节
彩虹带里的青色和紫色,在深色背景下容易因为亮度不够而变暗。HSL 模型里,亮度参数 60% 其实是一个中间值,但纯青色的感知亮度比红色高很多,也就是说同样的hsla(120, 90%, 60%, 1)和hsla(0, 90%, 60%, 1)放在一起,青色会显得特别亮。我建议在彩虹带里对明度做一点修正:绿到青区域,亮度降到 55% 左右;红到橙区域,亮度提到 65%。这个差异在单色渐变里看不出来,但放在全屏大色块上,感知差别非常明显。
Rainbow-Sea 的整个调试过程中,真正花时间最多的不是写代码,也不是性能调优,而是在 alpha 与饱和度的组合里寻找那个让彩虹既突出、又不抢海面层次感的平衡点。我在项目里把参数都做成配置对象,每次调试直接在控制台改配置并重新渲染,比一遍遍重载页面快得多。
如果你也想做类似动态背景,建议先把基础效果跑通,再加纹理、加辅助特效。我之前试过在一开始就追求复杂的光晕和粒子爆炸,结果代码写了一大堆,视觉反而乱。按照这个顺序来:海面打底,波纹建立空间感,彩虹制造氛围,粒子最后点缀。每一层都确认没问题了,再往下一层走。这个顺序没跳步,最后出来的画面不会差。