ascii-animation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

ASCII Animation

ASCII动画

Render motion entirely with text characters. ASCII output is extremely lightweight, distinctive, and works in browsers (
<pre>
/canvas), terminals (ANSI), and over any 3D scene (Three.js
AsciiEffect
).
完全使用文本字符渲染动态效果。ASCII输出极其轻量化、极具特色,可在浏览器(
<pre>
/canvas)、终端(ANSI)以及任何3D场景(Three.js
AsciiEffect
)中运行。

When 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:
    $@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'. 
    (reverse for dark-on-light).
Compute relative luminance from sRGB (perceptual):
js
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 0..1
Invert 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 - lum

Character 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
cols
characters wide, use
rows = Math.round(cols * (imgH / imgW) * fontAspect)
where
fontAspect ≈ 0.5
. Equivalently, when drawing to an offscreen canvas, set its height to
cols * aspect * 0.5
.
等宽字符单元格的高度大于宽度——大致为0.5宽:高。对正方形像素网格采样会导致图像垂直拉伸。解决方法是采样的行数少于列数:若目标字符宽度为
cols
,则使用
rows = Math.round(cols * (imgH / imgW) * fontAspect)
,其中
fontAspect ≈ 0.5
。或者,绘制到离屏canvas时,将其高度设置为
cols * aspect * 0.5

Web rendering:
<pre>
vs canvas

Web渲染:
<pre>
vs canvas

  • <pre>
    + textContent
    : simplest. One string with
    \n
    per row. Fine up to ~120×60 chars at 30fps. Set
    white-space: pre; font-family: monospace; line-height: 1;
    .
  • Canvas
    fillText
    : needed for per-character color, larger grids, or 60fps. Draw each char at
    x * cellW, y * cellH
    . Faster than thousands of DOM nodes.
Generative
<pre>
field (plasma):
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);
  • <pre>
    + textContent
    :最简单的方式。每行用
    \n
    分隔的单个字符串。在30fps下,最多支持约120×60字符。设置样式
    white-space: pre; font-family: monospace; line-height: 1;
  • Canvas
    fillText
    :需要为每个字符设置颜色、更大的网格或达到60fps时使用。在
    x * cellW, y * cellH
    位置绘制每个字符。比成千上万个DOM节点更快。
生成式
<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
cols × rows
, read pixels with
getImageContext().getImageData
, then map each pixel to a character. For video, repeat per frame from a
<video>
element.
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
.
将源内容绘制到尺寸为
cols × rows
的小型离屏canvas,使用
getImageContext().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.mjs

3D-to-ASCII with Three.js

使用Three.js实现3D转ASCII

AsciiEffect
wraps a renderer and renders any scene as ASCII into a DOM element. Render through the effect, not the renderer.
js
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.render
AsciiEffect
包装渲染器,并将任何场景以ASCII形式渲染到DOM元素中。通过该效果进行渲染,而非直接使用渲染器。
js
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.render

Terminal / 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
ESC + '2J'
to clear the whole screen,
ESC + 'H'
for home (cheaper per frame than clearing). Color with
\x1b[38;2;R;G;Bm
(truecolor) and reset with
\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[0m
重置颜色。

Deliver & verify (standalone HTML)

交付与验证(独立HTML文件)

Packaged helper (
scripts/
):
scripts/seek-shot.sh anim.html 0 1.5 3
freezes the
?t=N
harness and screenshots each moment;
scripts/contact-sheet.sh sheet.png frame-*.png
tiles them for one-glance review. See
scripts/README.md
.
For a self-contained web ASCII piece (generative field, image/video→ASCII,
AsciiEffect
scene) the deliverable is one HTML file that opens directly in a browser — the
<pre>
/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.)
Output contract:
  • One
    .html
    file: the render target, the brightness ramp, and the
    requestAnimationFrame
    loop in one inline
    <script>
    .
  • Drive frames from an injectable time (not
    Date.now()
    /
    performance.now()
    directly) and seed any randomness, so a frame is reproducible.
Seek harness — freeze the rAF loop on a deterministic frame.
?t=N
renders exactly one frame at simulated time
N
instead of looping, so a screenshot is reproducible. Feed
N
where the loop reads time, and fix the seed:
html
<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 (
?t=0
,
?t=1000
,
?t=2000
), 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:
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/ascii.html?t=1000" frame-mid.png
Before you finish:
  1. Opens standalone — no console errors, CDN (Three.js, if used) loads, monospace font applied.
  2. ?t=N
    renders one deterministic frame (injected time + fixed seed), no live loop.
  3. Screenshotted at 3 simulated times — matches the brief, no vertical stretch or wrong-invert.
  4. prefers-reduced-motion
    honored — stop/slow the rAF loop, show a static frame.
  5. 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、
AsciiEffect
场景),交付物应为可直接在浏览器中打开的单个HTML文件——包含
<pre>
/canvas、亮度渐变表和rAF循环(若使用Three.js则从CDN加载)。无需构建步骤。单个文件是最合适的交付形式;无需使用打包工具。(终端/CLI作品的验证方式不同——捕获标准输出或终端截图。)
输出规范:
  • 一个
    .html
    文件:包含渲染目标、亮度渐变表和内嵌在
    <script>
    中的
    requestAnimationFrame
    循环。
  • 可注入时间驱动帧(而非直接使用
    Date.now()
    /
    performance.now()
    ),并固定随机种子,确保帧可复现。
测试工具——在确定帧上冻结rAF循环。
?t=N
会渲染模拟时间
N
的恰好一帧,而非循环,因此截图可复现。将
N
传入循环读取时间的地方,并固定种子:
html
<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>
验证循环——渲染→冻结→截图→检查: 在几个模拟时间点(
?t=0
?t=1000
?t=2000
)渲染,截取每个时间点的截图,检查保真度(渐变表从暗到亮顺序正确,动态效果自然演进)以及** artifacts**(缺少单元格宽高比校正导致的垂直拉伸、浅色背景上反转错误、网格裁剪、等宽字体加载前的FOUC——字体决定单元格尺寸,需等待加载完成)。任何无头工具均可实现:
bash
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/ascii.html?t=1000" frame-mid.png
完成前检查:
  1. 可独立打开——无控制台错误,CDN资源(若使用Three.js)加载正常,等宽字体已应用。
  2. ?t=N
    可渲染确定的一帧(注入时间+固定种子),无实时循环。
  3. 在3个模拟时间点截图——符合需求,无垂直拉伸或反转错误。
  4. 遵循
    prefers-reduced-motion
    设置——停止/减慢rAF循环,显示静态帧。
  5. 缓动效果符合预期——故意限制帧率(12–24fps以营造复古感),渐变表顺序经过精心设计。

Quick reference

快速参考

NeedApproach
Simple generative field
<pre>
+
textContent
, sine/noise → ramp
Photo fidelity70-level Bourke ramp, luminance from sRGB weights
Per-char color / 60fpsCanvas
fillText
per cell
Video
<video>
→ offscreen canvas →
getImageData
per frame
3D sceneThree.js
AsciiEffect.render(scene, camera)
TerminalANSI
\x1b[H
home + throttle 12–24fps, hide cursor
Aspect fix
rows = cols * imgAspect * 0.5
需求实现方案
简单生成式字段
<pre>
+
textContent
,正弦/噪点→渐变表
照片级保真度70级Bourke渐变表,基于sRGB权重计算亮度
逐字符颜色 / 60fpsCanvas
fillText
逐单元格绘制
视频转ASCII
<video>
→ 离屏canvas → 逐帧
getImageData
3D场景转ASCIIThree.js
AsciiEffect.render(scene, camera)
终端动画ANSI
\x1b[H
回到起始位置 + 限制12–24fps,隐藏光标
宽高比校正
rows = cols * imgAspect * 0.5

Reference files

参考文件

  • references/rendering.md
    — Full brightness ramp tables (10/70/extended), pixel sampling math and cell aspect correction,
    <pre>
    vs canvas tradeoffs with code, ANSI terminal frame loop with truecolor, and complete
    AsciiEffect
    wiring.
  • scripts/img-to-ascii.mjs
    — Runnable Node script that converts a PNG/JPG file to ASCII text, with
    --cols
    ,
    --invert
    , and
    --ramp
    flags.
  • references/rendering.md
    — 完整的亮度渐变表(10/70/扩展)、像素采样数学和单元格宽高比校正、
    <pre>
    与canvas的权衡及代码示例、带真彩色的ANSI终端帧循环、完整的
    AsciiEffect
    配置方法。
  • scripts/img-to-ascii.mjs
    — 可运行的Node脚本,将PNG/JPG文件转换为ASCII文本,支持
    --cols
    --invert
    --ramp
    参数。