ascii-animation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseASCII Animation
ASCII动画
Render motion entirely with text characters. ASCII output is extremely lightweight, distinctive, and works in browsers (/canvas), terminals (ANSI), and over any 3D scene (Three.js ).
<pre>AsciiEffect完全使用文本字符渲染动态效果。ASCII输出极其轻量化、极具特色,可在浏览器(/canvas)、终端(ANSI)以及任何3D场景(Three.js )中运行。
<pre>AsciiEffectWhen to use
使用场景
- Build terminal/CLI intros, loaders, banners, or a retro/hacker aesthetic.
- Convert an image, video frame, or 3D scene into animated ASCII.
- Add an ASCII post-effect over an existing canvas/WebGL render.
- Create generative text fields (plasma, sine waves, noise, tunnels).
- 制作终端/CLI开场动画、加载器、横幅,或打造复古/黑客风格视觉效果。
- 将图像、视频帧或3D场景转换为动画ASCII。
- 在现有canvas/WebGL渲染结果上添加ASCII后效。
- 创建生成式文本字段(等离子效果、正弦波、噪点、隧道效果)。
Core concept: the brightness ramp
核心概念:亮度渐变表
Map luminance (0..1) to a character whose ink density matches. Order characters dark-to-light. Pick the index with .
Math.round(lum * (ramp.length - 1))Common ramps (dark to light):
- Short (10):
.:-=+*#%@ - Medium (16): ^",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$` truncated — see table below.
.'\ - Standard 70-level (Paul Bourke), best for photos:
(reverse for dark-on-light).
$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'.
Compute relative luminance from sRGB (perceptual):
js
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1Invert when drawing dark text on a light background: .
lum = 1 - lum将亮度值(0..1)映射到墨水密度匹配的字符。按从暗到亮的顺序排列字符。使用选取对应索引。
Math.round(lum * (ramp.length - 1))常用渐变表(从暗到亮):
- 短表(10个字符):
.:-=+*#%@ - 中表(16个字符):^",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$` (已截断——详见下方表格)。
.'\ - 标准70级渐变表(Paul Bourke),最适合照片:
(若要实现浅色背景深色文本,可反转顺序)。
$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'.
从sRGB计算相对亮度(感知性):
js
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1当在浅色背景上绘制深色文本时,反转亮度值:。
lum = 1 - lumCharacter cell aspect correction (the #1 gotcha)
字符单元格宽高比校正(最容易忽略的问题)
Monospace character cells are taller than wide — roughly 0.5 width:height. Sampling a square pixel grid produces a vertically stretched image. Correct by sampling fewer rows than columns: for a target of characters wide, use where . Equivalently, when drawing to an offscreen canvas, set its height to .
colsrows = Math.round(cols * (imgH / imgW) * fontAspect)fontAspect ≈ 0.5cols * aspect * 0.5等宽字符单元格的高度大于宽度——大致为0.5宽:高。对正方形像素网格采样会导致图像垂直拉伸。解决方法是采样的行数少于列数:若目标字符宽度为,则使用,其中。或者,绘制到离屏canvas时,将其高度设置为。
colsrows = Math.round(cols * (imgH / imgW) * fontAspect)fontAspect ≈ 0.5cols * aspect * 0.5Web rendering: <pre>
vs canvas
<pre>Web渲染:<pre>
vs canvas
<pre>- + textContent: simplest. One string with
<pre>per row. Fine up to ~120×60 chars at 30fps. Set\n.white-space: pre; font-family: monospace; line-height: 1; - Canvas : needed for per-character color, larger grids, or 60fps. Draw each char at
fillText. Faster than thousands of DOM nodes.x * cellW, y * cellH
Generative field (plasma):
<pre>js
const pre = document.querySelector('pre');
const COLS = 100, ROWS = 50, ramp = ' .:-=+*#%@';
function frame(t) {
let out = '';
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const v = Math.sin(x * 0.2 + t * 0.001)
+ Math.sin(y * 0.3 + t * 0.0013)
+ Math.sin((x + y) * 0.15 + t * 0.0007);
const lum = (v + 3) / 6; // normalize -3..3 to 0..1
out += ramp[Math.round(lum * (ramp.length - 1))];
}
out += '\n';
}
pre.textContent = out;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);- + textContent:最简单的方式。每行用
<pre>分隔的单个字符串。在30fps下,最多支持约120×60字符。设置样式\n。white-space: pre; font-family: monospace; line-height: 1; - Canvas :需要为每个字符设置颜色、更大的网格或达到60fps时使用。在
fillText位置绘制每个字符。比成千上万个DOM节点更快。x * cellW, y * cellH
生成式字段(等离子效果):
<pre>js
const pre = document.querySelector('pre');
const COLS = 100, ROWS = 50, ramp = ' .:-=+*#%@';
function frame(t) {
let out = '';
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const v = Math.sin(x * 0.2 + t * 0.001)
+ Math.sin(y * 0.3 + t * 0.0013)
+ Math.sin((x + y) * 0.15 + t * 0.0007);
const lum = (v + 3) / 6; // 将-3..3归一化到0..1
out += ramp[Math.round(lum * (ramp.length - 1))];
}
out += '\n';
}
pre.textContent = out;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Image / video to ASCII
图像/视频转ASCII
Draw the source to a small offscreen canvas at , read pixels with , then map each pixel to a character. For video, repeat per frame from a element.
cols × rowsgetImageContext().getImageData<video>js
function videoToAscii(video, cols = 120) {
const aspect = video.videoHeight / video.videoWidth;
const rows = Math.round(cols * aspect * 0.5); // cell aspect correction
const cv = document.createElement('canvas');
cv.width = cols; cv.height = rows;
const ctx = cv.getContext('2d', { willReadFrequently: true });
const ramp = ' .:-=+*#%@', pre = document.querySelector('pre');
(function tick() {
ctx.drawImage(video, 0, 0, cols, rows);
const { data } = ctx.getImageData(0, 0, cols, rows);
let out = '';
for (let i = 0; i < data.length; i += 4) {
const lum = (0.2126*data[i] + 0.7152*data[i+1] + 0.0722*data[i+2]) / 255;
out += ramp[Math.round(lum * (ramp.length - 1))];
if (((i / 4) + 1) % cols === 0) out += '\n';
}
pre.textContent = out;
requestAnimationFrame(tick);
})();
}For Node image→ASCII, see .
scripts/img-to-ascii.mjs将源内容绘制到尺寸为的小型离屏canvas,使用读取像素,然后将每个像素映射为字符。对于视频,从元素逐帧重复此过程。
cols × rowsgetImageContext().getImageData<video>js
function videoToAscii(video, cols = 120) {
const aspect = video.videoHeight / video.videoWidth;
const rows = Math.round(cols * aspect * 0.5); // 单元格宽高比校正
const cv = document.createElement('canvas');
cv.width = cols; cv.height = rows;
const ctx = cv.getContext('2d', { willReadFrequently: true });
const ramp = ' .:-=+*#%@', pre = document.querySelector('pre');
(function tick() {
ctx.drawImage(video, 0, 0, cols, rows);
const { data } = ctx.getImageData(0, 0, cols, rows);
let out = '';
for (let i = 0; i < data.length; i += 4) {
const lum = (0.2126*data[i] + 0.7152*data[i+1] + 0.0722*data[i+2]) / 255;
out += ramp[Math.round(lum * (ramp.length - 1))];
if (((i / 4) + 1) % cols === 0) out += '\n';
}
pre.textContent = out;
requestAnimationFrame(tick);
})();
}如需在Node环境中将图像转为ASCII,请查看。
scripts/img-to-ascii.mjs3D-to-ASCII with Three.js
使用Three.js实现3D转ASCII
AsciiEffectjs
import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
const effect = new AsciiEffect(renderer, ' .:-=+*#%@', { invert: true });
effect.setSize(innerWidth, innerHeight);
effect.domElement.style.color = '#0f0';
effect.domElement.style.backgroundColor = 'black';
document.body.appendChild(effect.domElement);
// in loop: effect.render(scene, camera); // NOT renderer.renderAsciiEffectjs
import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
const effect = new AsciiEffect(renderer, ' .:-=+*#%@', { invert: true });
effect.setSize(innerWidth, innerHeight);
effect.domElement.style.color = '#0f0';
effect.domElement.style.backgroundColor = 'black';
document.body.appendChild(effect.domElement);
// 循环中:effect.render(scene, camera); // 不是renderer.renderTerminal / CLI animation
终端/CLI动画
Loop with ANSI escape codes: hide the cursor, move to home, print the frame, throttle to 12–24 fps. Show the cursor again on exit.
js
const ESC = '\x1b[';
process.stdout.write(ESC + '?25l'); // hide cursor
function frame(t) {
process.stdout.write(ESC + 'H'); // cursor to top-left
// build and write rows...
}
const id = setInterval(() => frame(Date.now()), 1000 / 20);
process.on('SIGINT', () => {
clearInterval(id);
process.stdout.write(ESC + '?25h' + ESC + '2J'); // show cursor, clear
process.exit();
});Use to clear the whole screen, for home (cheaper per frame than clearing). Color with (truecolor) and reset with .
ESC + '2J'ESC + 'H'\x1b[38;2;R;G;Bm\x1b[0m使用ANSI转义码循环:隐藏光标,移动到起始位置,打印帧,将帧率限制在12–24 fps。退出时重新显示光标。
js
const ESC = '\x1b[';
process.stdout.write(ESC + '?25l'); // 隐藏光标
function frame(t) {
process.stdout.write(ESC + 'H'); // 光标移至左上角
// 构建并输出行内容...
}
const id = setInterval(() => frame(Date.now()), 1000 / 20);
process.on('SIGINT', () => {
clearInterval(id);
process.stdout.write(ESC + '?25h' + ESC + '2J'); // 显示光标,清屏
process.exit();
});使用清屏,回到起始位置(每帧操作比清屏更高效)。使用设置真彩色,重置颜色。
ESC + '2J'ESC + 'H'\x1b[38;2;R;G;Bm\x1b[0mDeliver & verify (standalone HTML)
交付与验证(独立HTML文件)
Packaged helper ():scripts/freezes thescripts/seek-shot.sh anim.html 0 1.5 3harness and screenshots each moment;?t=Ntiles them for one-glance review. Seescripts/contact-sheet.sh sheet.png frame-*.png.scripts/README.md
For a self-contained web ASCII piece (generative field, image/video→ASCII, scene) the deliverable is one HTML file that opens directly in a browser — the /canvas, the ramp, and the rAF loop inline (Three.js from CDN if used). No build step. One file is the right tier; don't reach for a bundler. (Terminal/CLI pieces verify differently — capture stdout or a screenshot of the terminal.)
AsciiEffect<pre>Output contract:
- One file: the render target, the brightness ramp, and the
.htmlloop in one inlinerequestAnimationFrame.<script> - Drive frames from an injectable time (not /
Date.now()directly) and seed any randomness, so a frame is reproducible.performance.now()
Seek harness — freeze the rAF loop on a deterministic frame. renders exactly one frame at simulated time instead of looping, so a screenshot is reproducible. Feed where the loop reads time, and fix the seed:
?t=NNNhtml
<script>
const t = new URLSearchParams(location.search).get("t");
// your frame(time){…} reads `time`, not Date.now(); RNG uses a fixed seed
if (t !== null) { frame(parseFloat(t)); } // render ONE frame, no rAF
else { (function loop(now){ frame(now); requestAnimationFrame(loop); })(0); }
window.__ready = true;
</script>Verify loop — render → freeze → screenshot → check: render at a few simulated times (, , ), screenshot each, and check fidelity (ramp reads dark→light correctly, motion evolves) plus artifacts (vertical stretch from missing cell-aspect correction, wrong invert on a light bg, clipped grid, FOUC before the monospace font loads — fonts settle the cell metrics, so wait). Any headless tool works:
?t=0?t=1000?t=2000bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/ascii.html?t=1000" frame-mid.pngBefore you finish:
- Opens standalone — no console errors, CDN (Three.js, if used) loads, monospace font applied.
- renders one deterministic frame (injected time + fixed seed), no live loop.
?t=N - Screenshotted at 3 simulated times — matches the brief, no vertical stretch or wrong-invert.
- honored — stop/slow the rAF loop, show a static frame.
prefers-reduced-motion - Easing is intentional — frame rate throttled on purpose (12–24fps for retro feel), ramp ordering deliberate.
打包助手(目录):scripts/会冻结scripts/seek-shot.sh anim.html 0 1.5 3测试工具并截取每个时刻的截图;?t=N将截图拼接成一张预览图。详情见scripts/contact-sheet.sh sheet.png frame-*.png。scripts/README.md
对于独立的网页ASCII作品(生成式字段、图像/视频转ASCII、场景),交付物应为可直接在浏览器中打开的单个HTML文件——包含/canvas、亮度渐变表和rAF循环(若使用Three.js则从CDN加载)。无需构建步骤。单个文件是最合适的交付形式;无需使用打包工具。(终端/CLI作品的验证方式不同——捕获标准输出或终端截图。)
AsciiEffect<pre>输出规范:
- 一个文件:包含渲染目标、亮度渐变表和内嵌在
.html中的<script>循环。requestAnimationFrame - 从可注入时间驱动帧(而非直接使用/
Date.now()),并固定随机种子,确保帧可复现。performance.now()
测试工具——在确定帧上冻结rAF循环。 会渲染模拟时间的恰好一帧,而非循环,因此截图可复现。将传入循环读取时间的地方,并固定种子:
?t=NNNhtml
<script>
const t = new URLSearchParams(location.search).get("t");
// 你的frame(time){…}函数读取`time`,而非Date.now(); 随机数生成器使用固定种子
if (t !== null) { frame(parseFloat(t)); } // 仅渲染一帧,不使用rAF
else { (function loop(now){ frame(now); requestAnimationFrame(loop); })(0); }
window.__ready = true;
</script>验证循环——渲染→冻结→截图→检查: 在几个模拟时间点(、、)渲染,截取每个时间点的截图,检查保真度(渐变表从暗到亮顺序正确,动态效果自然演进)以及** artifacts**(缺少单元格宽高比校正导致的垂直拉伸、浅色背景上反转错误、网格裁剪、等宽字体加载前的FOUC——字体决定单元格尺寸,需等待加载完成)。任何无头工具均可实现:
?t=0?t=1000?t=2000bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/ascii.html?t=1000" frame-mid.png完成前检查:
- 可独立打开——无控制台错误,CDN资源(若使用Three.js)加载正常,等宽字体已应用。
- 可渲染确定的一帧(注入时间+固定种子),无实时循环。
?t=N - 在3个模拟时间点截图——符合需求,无垂直拉伸或反转错误。
- 遵循设置——停止/减慢rAF循环,显示静态帧。
prefers-reduced-motion - 缓动效果符合预期——故意限制帧率(12–24fps以营造复古感),渐变表顺序经过精心设计。
Quick reference
快速参考
| Need | Approach |
|---|---|
| Simple generative field | |
| Photo fidelity | 70-level Bourke ramp, luminance from sRGB weights |
| Per-char color / 60fps | Canvas |
| Video | |
| 3D scene | Three.js |
| Terminal | ANSI |
| Aspect fix | |
| 需求 | 实现方案 |
|---|---|
| 简单生成式字段 | |
| 照片级保真度 | 70级Bourke渐变表,基于sRGB权重计算亮度 |
| 逐字符颜色 / 60fps | Canvas |
| 视频转ASCII | |
| 3D场景转ASCII | Three.js |
| 终端动画 | ANSI |
| 宽高比校正 | |
Reference files
参考文件
- — Full brightness ramp tables (10/70/extended), pixel sampling math and cell aspect correction,
references/rendering.mdvs canvas tradeoffs with code, ANSI terminal frame loop with truecolor, and complete<pre>wiring.AsciiEffect - — Runnable Node script that converts a PNG/JPG file to ASCII text, with
scripts/img-to-ascii.mjs,--cols, and--invertflags.--ramp
- — 完整的亮度渐变表(10/70/扩展)、像素采样数学和单元格宽高比校正、
references/rendering.md与canvas的权衡及代码示例、带真彩色的ANSI终端帧循环、完整的<pre>配置方法。AsciiEffect - — 可运行的Node脚本,将PNG/JPG文件转换为ASCII文本,支持
scripts/img-to-ascii.mjs、--cols和--invert参数。--ramp