从零实现黑客帝国瀑布:Canvas 动画设计笔记
先看效果
访问这个博客任何一个页面(暗色模式),背景里有一层 “01” 字符在缓缓下坠。和传统 Matrix 雨不同,它的列距不规则、字符随机倾斜、每列速度略有差异,整体有一种”有机的节奏感”而不是机械复制。
这篇讲清楚:这个效果是怎么从想法到实现的。
整体架构
只有三个部分:
HTML → 一个 <canvas> 画布
CSS → 绝对定位铺满全屏,z-index 扔到最底层
JS → Canvas 2D 绘制循环
没有依赖,不装包,不调库。
HTML
<canvas id="matrix-canvas"></canvas>
一行。要点在 Layout.astro 里的位置——它必须在 <body> 最开头,在所有内容之前,这样 CSS 才能把它垫到底层去。
<body>
<canvas id="matrix-canvas"></canvas>
<!-- 其他内容都堆在这里 -->
CSS
#matrix-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -999;
pointer-events: none; /* 允许点击穿透 */
}
z-index: -999 保证它永远在最底下。pointer-events: none 让鼠标可以正常点击下层的页面内容。
背景透明,所以它会和页面的暗色背景叠加在一起,不抢内容视线。
JavaScript:完整代码
(function () {
if (!document.querySelector('#matrix-canvas')) return;
const canvas = document.getElementById('matrix-canvas');
const ctx = canvas.getContext('2d');
const FONT_SIZE = 11; // 字符大小
let colX = []; // 每列的 x 坐标(不规则间距)
let drops = []; // 每列的状态:y坐标、字符、倾斜角度
let animId = null;
// ── 初始化所有列 ──
function initDrops() {
colX = [];
drops = [];
let x = 0;
// 不规则列距:每个 gap = 1.2~3 倍字符宽度
while (x < canvas.width + FONT_SIZE * 4) {
colX.push(x);
drops.push({
y: Math.random() * canvas.height,
char: Math.random() > 0.5 ? '1' : '0',
angle: (Math.random() - 0.5) * 0.6 // ±0.6 弧度 ≈ ±34°
});
x += FONT_SIZE * (1.2 + Math.random() * 1.8);
}
}
// ── 响应窗口大小变化 ──
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initDrops();
}
// ── 绘制循环 ──
let frame = 0;
function draw() {
frame++;
// 隔帧渲染:降低 CPU 负担,同时让下落看起来更自然
if (frame % 2 !== 0) {
animId = requestAnimationFrame(draw);
return;
}
// 覆盖式淡出,产生拖尾效果
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px monospace`;
for (let i = 0; i < colX.length; i++) {
const x = colX[i];
const d = drops[i];
// ── 旋转字符 ──
ctx.save();
ctx.translate(x, d.y);
ctx.rotate(d.angle);
ctx.fillStyle = '#7dff9a'; // 亮绿色头部
ctx.fillText(d.char, 0, 0);
ctx.restore();
// ── 下落速度:列间有微小差异 ──
d.y += FONT_SIZE * (0.7 + (i % 3) * 0.15);
// ── 落出屏幕后随机重置到顶部 ──
if (d.y > canvas.height + FONT_SIZE) {
d.y = -FONT_SIZE * (1 + Math.random() * 3);
d.char = Math.random() > 0.5 ? '1' : '0';
d.angle = (Math.random() - 0.5) * 0.6;
}
}
animId = requestAnimationFrame(draw);
}
// ── 启动 ──
resize();
draw();
// ── 监听暗色模式切换 ──
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (e.matches) { resize(); draw(); }
else { ctx.clearRect(0, 0, canvas.width, canvas.height); }
});
})();
设计决策拆解
1. 为什么不用传统竖条文字?
经典 Matrix 雨是竖向排列的片假名字符,从上往下刷,同步度极高。好处是整齐,坏处是太机械——每一列和旁边 100% 一样,像贴满了同一版瓷砖。
我的改动:
- 列距随机(1.2~3 倍字符宽),每列起跑位置不同
- 字符本身随机旋转(±34°),打破正交感
- 每列速度略微不同(0.7x ~ 1.15x)
这三个加起来,视觉上就有了”错落有致的节奏”,像一群有序但不完全同步的信号。
2. 拖尾效果怎么来的?
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
每帧用 半透明黑色 覆盖画布而不是清空。这样字符在移动时,旧位置不会立刻消失,而是以 12 帧左右的速度淡出。视觉上就是流畅的拖尾。
如果用 clearRect 清空,字符会像静态水珠往下掉,没有那种”流动”感。
3. 为什么用 “01” 而不是片假名?
纯粹个人审美。博客是极客技术向,“01” 更直接地呼应二进制/程序员的视觉语言,比片假名更切题。而且 “01” 横排时两个字符挨在一起,旋转后更像”碎片”而非”字符”,整体更抽象。
4. Canvas vs CSS 动画
有人会用 CSS @keyframes + opacity 轮转实现类似效果。Canvas 的优势是:
- 像素级控制,想画什么字符就画什么
- JS 逻辑可以随时改(随机间距、随机速度、随机旋转)
- 不占用 DOM,不触发 CSS 重排
Astro 静态页面里,Canvas 的额外开销几乎为零(只有一个 <canvas> 节点 + 一段 IIFE)。
5. 性能考虑
requestAnimationFrame做渲染循环,浏览器自动匹配屏幕刷新率- 隔帧渲染(
frame % 2),实际约 30fps,足够流畅且 CPU 友好 - 窗口 resize 时重新初始化,这是唯一可能的性能峰值
暗色模式联动
用 matchMedia 监听系统主题切换:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (e.matches) { resize(); draw(); }
else { ctx.clearRect(0, 0, canvas.width, canvas.height); }
});
切到亮色模式时直接清空画布,亮色页面上不需要这个暗色效果。
总结
核心就三件事:
- 不规则列距 — 打破机械感
- 随机旋转 — 打破正交感
- 半透明覆盖 — 拖尾 + 节奏感
如果把这三个特性拿掉,变成整齐等距、竖直向下、同速的”01”条,那就只是一个普通跑马灯了。差别就在于这些”不规则”的细节里。