FIELD / LUMA / SCRUBSYSTEM READY
HOW IT'S BUILT · 2026

OPENTIGER LAB — 解剖一座 ASCII 站点

00 / INDEX

解剖目录。

9 CHAPTERS / 9 LIVE DEMOS

tiger@opentiger:/lab$ ls -R ./chapters

01 / GRAMMAR

设计语言。

EVERY PIXEL IS EARNED, NOT FADED IN

这套页面只守一条总则:元素不是被轻轻放进视口,而是被搭建、输入或解密出来。点阵凝聚、乱码解码、打字机、擦入生长与节制的 glitch,是允许进入画面的五种方式。

[01] DOT-BUILD点阵构建半调点沿墨量层级凝聚,内容从场里长出来。
[02] SCRAMBLE乱码解码字符从噪声逐步回到原文,空白与方向都被保留。
[03] TYPE-IN打字机逐字写入并锁定行高,让终端节奏不扰动布局。
[04] WIPE擦入生长边框、代码块与图表用 clip-path 从一侧搭建。
[05] GLITCH失真变清只在需要强调解密语义时节制使用,不做装饰性抖动。
[BANNED]opacity 淡入 + y 上浮
[BANNED]hover 上浮加阴影
TEMPLATE MOTION ✗

FADE IN + MOVE Y.

EXHIBIT ONLY / 此动画在本站其余任何地方都不存在
CONSTRUCTION MOTION ✓

FRAME BUILT.

LIVE LOOP / 擦入与解码,每 3 秒重演一轮

BUILD → HOLD → CORRODE

02 / FIELD

半调场引擎。

INK DENSITY BEFORE DECORATION

画面不用透明渐变区分层级,而用“墨量”排序:文字遮罩最深,其次是密字符、疏字符,最浅才是半调点。十档双变体字符坡道按格位固定选取,所以场会变化,字形却不会无意义闪动。

指针把能量写进附近格子,点击则放出速度为 430px/s、寿命为 2s 的 ASCII 涟漪。能量按真实时间衰减;滚动掉帧时波前追赶,而不是暂停。

> cat halftone.js · L376-L377
      const RAMPS = [[".", ","], [":", ";"], ["-", "~"], ["=", "+"], ["*", "x"], ["%", "&"], ["#", "0"], ["@", "$"], ["▓", "#"], ["█", "█"]];
      const R_CHAR = 140, DECAY = .93, E_FLOOR = .14;

主页源码把字符坡道与三项能量参数关在工厂闭包里;本页仅把三项常量改成可写参数并暴露 setter,演示与生产引擎保持同一条渲染路径。

PARAMETER CONSOLE / MOVE + CLICK THE FIELD

> tune field.params ▮

> 拖动滑杆 · 在场里移动/点击

PARAMVALUEMEANING
CELL~14px按面积自适应的点距
R_CHAR / E_FLOOR140px / 0.14鼠标能量半径 / 圈内保底
DECAY0.93^(dt×60)按真实时间折算的能量衰减
RIPPLE430px/s · 2s真实时钟计龄的涟漪
WAVE±20%最浅半调点的双正弦呼吸

03 / LUMA

视频明度层。

THE FRAME IS DATA, NOT FOOTAGE

主页没有播放 mp4 画面。隐藏视频只充当解码器:每个视频帧先按场的宽高比 cover 居中裁切,再降采样到约 7k 个像素,逐格明度决定点径与字符档位。素材只需 320px 灰度,60s ≈ 400KB

lo / hi 裁黑白场,gamma 压住中灰杂讯,时间平滑抑制压缩抖动。视频未加载、自动播放被拦或半路出错时,场会沿 vFade 回到纯径向底场;hero 离屏还会暂停解码。

视频帧320PX GRAYSCALE
逐格明度≈ 7K SAMPLES
点径与字符. : = # @
> cat halftone.js · L448-L463
      function vSample() {
        if (!vTone || vid.readyState < 2) return;
        const va = vid.videoWidth / vid.videoHeight, fa = W / H;
        let sw = vid.videoWidth, sh = vid.videoHeight;
        if (va > fa) sw = sh * fa; else sh = sw / fa;
        scx.drawImage(vid, (vid.videoWidth - sw) / 2, (vid.videoHeight - sh) / 2, sw, sh, 0, 0, cols, rows);
        const d = scx.getImageData(0, 0, cols, rows).data;
        const { lo, hi, gamma } = videoCfg;
        for (let idx = 0, p = 0; idx < cols * rows; idx++, p += 4) {
          const L = (d[p] * .299 + d[p + 1] * .587 + d[p + 2] * .114) / 255;
          let t = (L - lo) / (hi - lo); t = t < 0 ? 0 : t > 1 ? 1 : t;
          const tone = Math.pow(1 - t, gamma);
          vTone[idx] += (tone - vTone[idx]) * .6;
        }
        vReady = true;
      }

裁切发生在采样前:无论视频比例怎样变化,格子读到的始终是铺满场的中心画面。

> pipeline.sh
          ffmpeg -ss 起点秒 -t 时长秒 -i 原片.mp4 -an -vf "fps=24,scale=320:-2,hue=s=0,curves=all='0/0 0.25/0.10 0.75/0.92 1/1'" -c:v libx264 -crf 32 -preset veryslow -pix_fmt yuv420p -movflags +faststart media/新片名.mp4

换片只有两步:先把对比度烘进 320px 灰度素材,再改引擎调用的 src;部署时媒体文件单独同步。

04 / SCRUB

滚动解码 scrub。

GEOMETRY IS THE ONLY CLOCK

“进视口播一次”会在惯性滚动与回看时重播,也会让页底元素被最大滚动距离钳坏。这里直接读取元素几何位置:出现看下界,消失看上界,构建和腐蚀是两个独立分支。

短标题用三倍自身高度拉长解码行程;页底元素再按可达距离收紧,于是滚动条到底正好完成尾标。乱码刷新频率从静止时约 1.5 次/秒 随滚速提升到约 30 次/秒,完成或离屏后停止闪烁。

> cat scrub.js · GEOMETRY MAP
          u.setProg = () => {
            const r = triggerEl.getBoundingClientRect(), vh = innerHeight;
            if (r.top < 0) { u.out = true; u.prog = Math.min(1, Math.max(0, 1 + r.top / (r.height * .85))); }
            else {
              /* 文字元素矮 → 出现结束点抬高:底边升至距视口底 2×自身高度处播完(行程 3h);
                 按可达行程收紧,页底文字(尾标)到底时恰好播完 */
              const minTop = r.top + scrollY - (document.documentElement.scrollHeight - vh);
              const D = Math.max(1, Math.min(3 * r.height, vh * .9, vh - Math.max(0, minTop)));
              u.out = false; u.prog = Math.min(1, Math.max(0, (vh - r.top) / D));
            }
          };

映射只依赖 getBoundingClientRect(),不用 ScrollTrigger 的 progress 判断相位。

GEOMETRY SCRUB / DRAG THE RANGE

几何即进度。

> viewport.inspect() top: 230px prog: 0.05 state: DECODE APPEAR → LOWER BOUND
CORRODE → UPPER BOUND

05 / TYPE

字体工程。

ONE GRID, ONE VERIFIED GLYPH SET

ASCII 画看似只是一段文本,实际依赖每个格子同宽、每个制表与方块字符都有字形。安卓本地字体缺这批字形,Menlo Bold 也缺制表符;第三方子集甚至可能声明了 unicode-range,却没有真实 cmap。

所以站点自托管仅 8.9KB 的 TigerMonoArt 子集,只把普通终端文字留给系统等宽栈。中文栈最前还放了一张只接管全角标点的 CJKPunct 字体面,避免 Geist 把全角标点渲染成窄标点。

CASE-01 / GLYPH

安卓方块字散架

现象 逐字符回退,格宽不一。

修法 自托管 TigerMonoArt 子集,并验证 cmap。

CASE-02 / WEIGHT

Menlo Bold 缺制表

现象 Chrome 不跨字重回退,线框错位。

修法 所有 ASCII 画固定 font-weight: 400

CASE-03 / PUNCT

全角标点被抢

现象 字符数据正确,视觉宽度却像半角。

修法 CJKPunct 用 unicode-range 抢回标点渲染。

> rebuild TigerMonoArt
pyftsubset DejaVuSansMono.ttf --unicodes="U+0020-007E,U+00A0,U+00B7,U+2013-2015,U+2500-25FF" --flavor=woff2 --layout-features='' --no-hinting --desubroutinize

裁剪集同时保留 Latin、制表、方块与几何字符。含制表符的画始终使用 --mono-art 且字重 400。

06 / PERF

性能:Safari 战记。

REMOVE THE SLOW PATH FIRST

Safari 的短板不是算法本身,而是数千次 canvas 调用与若干 WebKit 慢路径。优化把 DPR 压到 1,把 CELL 分母从 9500 → 7000、点数变化 −26%,同时收紧绘制批次与合成层边界。

[FIX-01]

所有半调点并入一条 Path,最后只执行一次 fill;字符按偶数字号分组,减少 ctx.font 切换。

[FIX-02]

Safari 半帧率渲染、DPR 压到 1,并把点距分母从 9500 调到 7000,点数变化 −26%。

[FIX-03]

噪点 SVG filter 关闭、mix-blend-mode 退回 normal、滚动内容移除 mask;避免大面积 fixed 滤镜层。

[FIX-04]

SVG scrub 把连续路径和离散标注分层;必须离散更新的文字按 1/24 量化并跳过相同值。

[FIX-05]

build() 遇到同尺寸直接跳过,移动端地址栏伸缩不再清空能量;真正重建也保留正在播放的涟漪。

> cat halftone.js · HOT PATH
        ctx.beginPath();   /* 所有点并入同一路径,最后一次 fill(Safari 关键优化) */

> 结论:新效果一律先过 html.safari 降载检查

07 / CHARTS

console 制图语言。

GRIDS, BLOCKS, MONO LABELS

日报没有引入通用图表库。格子、方块字符与等宽标注本来就是 console 语言的一部分,自绘让数据图和页面共用同一套视觉语法,也省去依赖,并让轴线、格子与点位能按“构建”逻辑进入画面。

格子条负责排名与量级,方块散点负责两维权衡与最优象限,方点折线负责时间趋势。三种图只共用一个 chart-head:左边是终端标题,右边是 FIG.xx ▮,其余结构跟着数据形态变化。

注:冲击与成本均为作者自评,科学性约等于在终端里画一只老虎。

BASE / STATIC

网格、刻度与图例保持静态,不进入逐帧更新。

ANIM / CONTINUOUS

轴线与路径独立合成,只承接连续 transform。

OVER / STEPS

点位与文字按离散步进出现,避免反复栅格化。

> cat ai-news/2026-07-20.html · svg-stack
    .svg-stack { position: relative; }
    .svg-stack .svg-anim, .svg-stack .svg-over { position: absolute; inset: 0; height: 100%; pointer-events: none; }
    .svg-stack .svg-anim { will-change: transform; transform: translateZ(0); }

源码把中层固定为独立合成层;构建进度绑定滚动几何,向上回看时沿同一时间轴逆转,不另起一次播放。

08 / MEDIA

卡片媒体系统。

IMAGE, FALLBACK, VIEWER, TAPE

一张信号卡的头图不是单一路径。数据先尝试 images[] 多图卡带,再尝试 image 单图;没有图片或加载失败,才由程序化纹理生成 fallback。无论从哪条路径到达,卡片尺寸与信息层级都不塌。

真实图片也要进入站内影调:灰度、轻微对比增强,再覆一层点阵。查看大图时,墨色马赛克先拼成背景,图片随后阶梯式显形;媒体因此不是“贴上去”,而是按同一套构建语言进入页面。

images[] 卡带MULTI / SCROLL-SNAP
image 单图SINGLE / LAZY
fallback 头图NO IMAGE / ERROR

PROGRAMMATIC FALLBACK / SELECT A MODE

ORIGINAL / 原图
VulcanBench 原始配图
SITE TONE / 站内影调
VulcanBench 站内影调配图

MOSAIC VIEWER / OPEN + CLOSE

MOSAIC: inset −2px + tile outline 1px;查看器支持滚轮/双指缩放与拖拽平移,并先锁定标签与按钮宽度再做文字解码。

VulcanBench 配图
VULCANBENCH
Qwen3 配图
QWEN3
DeepSeek Office 配图
DEEPSEEK
1 / 3
02 / FIELD

墨量排序,能量写进格子,涟漪按真实时间衰减。

HEAT █████
04 / SCRUB

元素几何就是进度;构建与腐蚀沿同一路径往返。

HEAT ████░
07 / CHARTS

格子、方块与 mono 标注,把数据也写成 console。

HEAT ████░
09 / CURSOR

四角独立探色,输出纯黑或纯白,拒绝合成反色。

HEAT █████

左侧卡带使用 scroll-snap 与实时计数;右侧轨道复制两份后循环 transform,悬停暂停,触屏与 reduced-motion 下退为原生横向滚动。

09 / CURSOR

光标系统。

FOUR CORNERS, ONE VIEWFINDER

原生指针被接管后,四个角标组成一套取景器语言:空闲时合成小框与中点,链接上放大并旋转 45°,按钮、图片和滑杆上则飞出框住目标。锁定中心保留极轻的视差,但滑杆拖动直接追随把手。

颜色不用 blend 反相。四个角标各自用 elementsFromPoint() 找脚下第一个不透明背景并计算明度;图片则从 32×32 缓存画布读取真实像素。因此取景器跨过深浅交界时,可以同时出现黑角与白角。

IDLE / 空闲

四角收成小框,中点紧跟实际指针。

LINK / 放大

链接态放大并旋转 45°,不夺走文字形态。

LOCK / 吸附

四角飞向目标外沿,中心吸附目标或滑杆把手。

CURSOR TARGET RANGE / MOVE ACROSS THE PANEL

深色假图目标 LINK / SCALE
HALF BLACK / HALF WHITE · 把取景器停在中线
> cursor.stateMODE IDLE C1 #000 C2 #000 C3 #000 C4 #000PROBE / 90MS
OUTPUT / PURE B/W

触屏、减少动态或动画库不可用时,靶场隐藏;系统归还原生指针,并保留所有目标的正常点击/拖动能力。

> cat index.html · L700-L730 · imgLum / probeColor
        const imgLum = (img, x, y) => {
          try {
            let cx = imgCache.get(img.currentSrc);
            if (!cx) {
              const cv = document.createElement("canvas");
              cv.width = 32; cv.height = 32;
              cx = cv.getContext("2d", { willReadFrequently: true });
              cx.drawImage(img, 0, 0, 32, 32);
              imgCache.set(img.currentSrc, cx);
            }
            const r = img.getBoundingClientRect();
            const px = Math.min(31, Math.max(0, ((x - r.left) / r.width * 32) | 0));
            const py = Math.min(31, Math.max(0, ((y - r.top) / r.height * 32) | 0));
            const d = cx.getImageData(px, py, 1, 1).data;
            if (d[3] < 128) return null;
            return (.2126 * d[0] + .7152 * d[1] + .0722 * d[2]) / 255;
          } catch (e) { return null; }
        };
        const probeColor = (x, y) => {
          for (const el of document.elementsFromPoint(x, y)) {
            if (el.tagName === "IMG" && el.complete && el.naturalWidth) {
              const lum = imgLum(el, x, y);
              if (lum !== null) return lum < .45 ? "#fff" : "#000";
              continue;
            }
            const m = getComputedStyle(el).backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
            if (!m || (m[4] !== undefined && parseFloat(m[4]) < .5)) continue;
            return (.2126 * m[1] + .7152 * m[2] + .0722 * m[3]) / 255 < .45 ? "#fff" : "#000";
          }
          return "#000";
        };

来源是主页真实实现:图片采样失败时返回 null,探测继续沿背景色链向下找,不把跨域异常变成光标异常。

ENABLE WHEN: (pointer: fine) + min-width: 761px + prefers-reduced-motion: no-preference + html.anim。任一条件不满足,原生指针原样归还。

OPEN
LAB
THE PAGE ITSELF IS THE DEMO