canvas实现点阵字符

September 4, 2025 4 min read Author: Yu

目前一些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> 重建形状。

每个阶段都可以调整密度、颜色或形状,甚至用圆形点、随机抖动,制造不同视觉风格。

扩展

  1. 可视化采样过程
    在 Canvas 上动态显示哪些采样点命中文字区域。

  2. 点大小映射亮度
    用原图的亮度控制点的大小,做出「点描画」效果。

  3. 动画化点阵
    给点阵加上鼠标交互或物理运动(如靠近鼠标发光、分散、聚合)。

  4. 图片/艺术画像素化

应用场景

  • 字体像素艺术

  • 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>