目前一些AI和web3欢迎页都流行一种设计风格:鼠标划过后会荡漾起像素化的涟漪,本文中探讨这种效果的实现。
思路概览
目标:把文字的形状转换成由小方块组成的点阵。
方法:在 Canvas 上绘制文字 → 读取像素数据 → 采样 → 判断哪些点在文字区域内 → 在 SVG 里生成 <rect>。
使用 Canvas 绘制文字
const canvas = document.createElement('canvas');canvas.width = 1000;canvas.height = 320;const ctx = canvas.getContext('2d');
ctx.font = '900 200px Inter, sans-serif';ctx.fillStyle = '#fff';ctx.textBaseline = 'middle';ctx.fillText('BASE', 100, 160);这一段在离屏 Canvas 上绘制文字。
Canvas 会自动把矢量字体光栅化成像素阵列。
读取像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);const pixels = imageData.data; // RGBA 数组Canvas 像素数据以一维数组形式存储:
[R, G, B, A, R, G, B, A, ...]
每四个值对应一个像素。
A 通道(Alpha)表示透明度,255 代表完全不透明。
采样像素点
为了生成清晰的像素风需要考虑采样方式,这里我采取的简单办法是隔一定步长采样(例如每 8px 取一次),应该有更好的办法这里暂不深入研究。
const step = 8;for (let y = 0; y < canvas.height; y += step) { for (let x = 0; x < canvas.width; x += step) { const index = (y * canvas.width + x) * 4 + 3; // 取 alpha 通道 const alpha = pixels[index]; if (alpha > 128) { // 落在文字区域 } }}生成 SVG 像素矩阵
一旦判定该点属于文字区域,就在 SVG 中创建一个 <rect>(或 <use> 复用方块模板)。
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', x); rect.setAttribute('y', y); rect.setAttribute('width', step * 0.65); rect.setAttribute('height', step * 0.65); rect.setAttribute('fill', '#79ffe1'); svgGroup.appendChild(rect);最终用这些 <rect> 拼在一起形成了点阵版文字。
离屏 Canvas
在生成过程中并不需要在页面上显示这个Canvas,只是作为一个采样器来使用。所以Canvas主要负责三点:
-
负责把文字转成像素信息
-
提供每个像素的 RGBA 值
-
判断这个格点是不是文字的一部分
这样就可以扫描获取文字轮廓的二值矩阵(1=在文字内,0=在文字外)。
完整流程
| 步骤 | 说明 |
|---|---|
| 光栅化 (Rasterization) | 将文字从矢量转为像素网格。 |
| 采样 (Sampling) | 按固定步长扫描像素阵列。 |
| 阈值化 (Thresholding) | 判断哪些像素属于文字。 |
| 重建 (Reconstruction) | 在 SVG 中用 <rect> 重建形状。 |
每个阶段都可以调整密度、颜色或形状,甚至用圆形点、随机抖动,制造不同视觉风格。
扩展
-
可视化采样过程
在 Canvas 上动态显示哪些采样点命中文字区域。 -
点大小映射亮度
用原图的亮度控制点的大小,做出「点描画」效果。 -
动画化点阵
给点阵加上鼠标交互或物理运动(如靠近鼠标发光、分散、聚合)。 -
图片/艺术画像素化
应用场景
-
字体像素艺术
-
Logo 点阵化动画
-
Canvas 像素采样可视化
完整代码
<!doctype html><meta charset="utf-8" /><style> html,body{height:100%;margin:0;background:#0b0f14;display:grid;place-items:center} svg{width:min(1100px,92vw);height:auto} .pixels use{shape-rendering:crispEdges} /* 底层常亮的暗色像素 */ #base { fill:#7f95a8 } /* 顶层亮色像素,默认不可见;被激活时将 opacity=1(无过渡) */ #glow { fill:#79ffe1 } #glow use { opacity:0 }</style>
<svg id="stage" viewBox="0 0 1000 320" role="img" aria-label="pixel text"> <defs> <symbol id="dot" viewBox="0 0 1 1"> <rect width="1" height="1" rx="0.2" /> </symbol> </defs> <g id="base" class="pixels"></g> <g id="glow" class="pixels"></g></svg>
<script> // ===== 可调参数 ===== const TEXT = 'BASE'; // 你要显示的字 const W=1000, H=320; // 画布 const STEP=8; // 采样步长(像素网格间距) const DOT = STEP*0.65; // 方块边长(略小于步长,留缝) const OFF = (STEP-DOT)/2; const PADX=30, PADY=20; // 内边距 const RADIUS_CELLS = 2; // 鼠标影响半径(单位:格) const DECAY_MS = 300; // 延迟熄灭时长(毫秒) const USE_CIRCLE = true; // 邻域是圆形(true)还是方形(false)
const svg = document.getElementById('stage'); const base = document.getElementById('base'); const glow = document.getElementById('glow');
// ===== 1) 离屏 Canvas:把文本栅格化 -> 采样出需要放像素的网格点 ===== const off=document.createElement('canvas'); off.width=W; off.height=H; const ctx=off.getContext('2d'); ctx.clearRect(0,0,W,H); ctx.fillStyle='#fff'; ctx.textBaseline='middle'; ctx.font=`900 200px Inter, system-ui, -apple-system, Segoe UI, Roboto`; const tw=ctx.measureText(TEXT).width, x=(W-tw)/2, y=H/2+6; ctx.fillText(TEXT, x, y); const data=ctx.getImageData(0,0,W,H).data;
// 索引:网格坐标 -> 顶层亮像素元素 与 它的定时器 const idx = new Map(); // key: "gx,gy" -> { el:SVGUseElement, t:number|null } const key = (gx,gy)=> gx+','+gy;
function addDot(parent, xx, yy){ const u=document.createElementNS('http://www.w3.org/2000/svg','use'); // 兼容性:同时设置 href 与 xlink:href u.setAttributeNS('http://www.w3.org/1999/xlink','href','#dot'); u.setAttribute('href','#dot'); u.setAttribute('x', xx+OFF); u.setAttribute('y', yy+OFF); u.setAttribute('width', DOT); u.setAttribute('height', DOT); parent.appendChild(u); return u; }
for (let yy=PADY; yy<H-PADY; yy+=STEP){ for (let xx=PADX; xx<W-PADX; xx+=STEP){ const a = data[((yy|0)*W+(xx|0))*4 + 3]; if (a>128){ addDot(base, xx, yy); const u = addDot(glow, xx, yy); const gx = Math.round((xx-PADX)/STEP); const gy = Math.round((yy-PADY)/STEP); idx.set(key(gx,gy), { el: u, t: null }); } } }
// ===== 2) 触发亮起并安排延时熄灭(无渐变,瞬时开/关) ===== function flash(cell){ const rec = idx.get(cell); if (!rec) return; // 先亮起(无过渡) rec.el.style.opacity = '1'; // 续命:如果已有定时器,清掉再重设 if (rec.t) clearTimeout(rec.t); rec.t = setTimeout(()=>{ rec.el.style.opacity = '0'; // 瞬时熄灭 rec.t = null; }, DECAY_MS); }
// ===== 3) 指针移动:只激活附近小邻域的像素 ===== let raf = null; svg.addEventListener('pointermove', (evt)=>{ const pt = svg.createSVGPoint(); pt.x = (evt.clientX ?? (evt.touches && evt.touches[0].clientX)); pt.y = (evt.clientY ?? (evt.touches && evt.touches[0].clientY)); const loc = pt.matrixTransform(svg.getScreenCTM().inverse());
// 把实际坐标映射到网格坐标 const gx0 = Math.round((loc.x - PADX)/STEP); const gy0 = Math.round((loc.y - PADY)/STEP);
if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(()=>{ const r = RADIUS_CELLS; const r2 = r*r; for (let gy=gy0-r; gy<=gy0+r; gy++){ for (let gx=gx0-r; gx<=gx0+r; gx++){ if (USE_CIRCLE && ((gx-gx0)*(gx-gx0)+(gy-gy0)*(gy-gy0) > r2)) continue; flash(key(gx,gy)); } } }); }, {passive:true});</script>