一帧十六毫秒
游戏循环、固定时间步与复古渲染
一帧只有十六毫秒:街机、游戏循环与复古渲染
实时游戏的手感不取决于画面,而取决于一条短到几乎察觉不到的因果链:按下跳跃,下一瞬角色就起来;判断失误,屏幕立刻给惩罚。本文把这条链所在的 16.67 毫秒彻底拆开——先看一帧之内的三件事与可量的延迟,再沿 Pong、Breakout、俄罗斯方块走一遍游戏循环六十年的两条路线,最后钻进 CRT 扫描线与 Game Boy 四阶灰度的物理成因。限制在前,风格在后,这篇文章要讲清的正是限制如何被工程消化、又如何变成风格。
一帧的预算:步长、判定与手感
一帧里要干三件事
采样输入,推进模拟,画出结果。60Hz 的显示器给你 16.67ms,三件事都得塞进去。
新手最常见的写法是拿真实帧间隔当步长。
看着挺物理,其实是坑。 一抖,同一个跳跃在不同机器上的高度就不一样了。更麻烦的是碰撞按离散位置判定,某帧 特别大,高速物体直接从墙里穿过去。这叫隧穿。
另一个坑叫螺旋死亡。某帧卡了, 变大,碰撞计算更贵,帧更卡, 更大,标签页当场假死。
工程上的标准答案早就有,固定步长加累加器。
acc += realDeltawhile (acc >= FIXED_DT) { step(FIXED_DT); acc -= FIXED_DT }render(acc / FIXED_DT) // 用余数做插值模拟永远按 1/60 秒推进,渲染帧率随便变。卡顿那帧被拆成几小步补上,物理稳定,手感一致。
上面这个演示把两种策略摆在一起。左边固定步长,卡的那帧球只是慢了一拍。右边直接用真实帧间隔,球在卡的那帧瞬移,轨迹肉眼可见地断掉。
所以第一条技术事实是,逻辑帧率和渲染帧率是两件独立的东西。
手感其实可以量
手感听起来玄,拆开就是一条链路。硬件扫描,鼠标 1ms、手柄 4-8ms。浏览器事件队列,合成器会节流,可能等一两帧。你的逻辑帧最多再等 16.7ms。合成与显示在 vsync 再等 16.7ms。最后面板响应 5-15ms。加起来,一次点击到像素变化落在 50-120ms 之间。
这个数字人是能感知的。
链路里有两件事挺反直觉。
一是街机的手感优势,本质是硬件结构的副产品。八十年代街机主板直接驱动 CRT 电子束,压根没有帧缓冲排队这回事,输入到像素的延迟天然比浏览器低一个数量级。所谓街机味,一半来自这种物理上的即时性,跟设计水平关系不大。
二是人对延迟的容忍有阈值,过了阈值只能靠骗。格斗玩家大概能分辨 50ms 的差别,而人自己的动作有 100-200ms 的固有误差。所以好游戏会在判定上作弊。
土狼时间,角色踩空之后还允许起跳大约 100ms。玩家早掉下去了,游戏假装他还在边上。跳跃缓冲,落地前 100ms 内按下的跳跃排队,落地瞬间自动执行。宽容判定框,受击框比贴图小一圈,命中框比贴图大一圈。
这三样都不是引擎功能,是用状态机伪造人类的容错。想明白这一点就懂了,为什么像素级精准的游戏往往不好玩。它把机器的时间精度当成了人的精度。
难度曲线只有三个参数
街机合集一个入口塞了九种玩法,flappy、跳跃、青蛙过河、闪避、交通、飞鸟、无尽跑酷、地铁跑酷、平台跳跃。共享的设计真理只有一句,规则一句话讲完,难度靠节奏递增。
把这句话写开。以 flappy 类为例,一局能活多久取决于三个参数,管道缺口宽度 、前进速度 、管道间距 。单次穿过的成功率记成 ,连过 个管道还活着的概率是 ,指数衰减。一条式子能解释三个设计现象。
想拉长单局时长,把缺口调宽比加管道数量有效得多,因为 对 极其敏感。想制造就差一点的挫败感,把 拨到 0.9 附近,前十根管道通过率 35%,前二十根只剩 12%,玩家会反复咀嚼我明明可以。难度递增也不需要新机制,让 随时间慢慢往下掉就行。
工程手段实际只有三种,提速、加密、隐去信息。青蛙过河三样都用,车道速度往上涨,安全格被一点点挤掉,河里的木头开始变短。
这里有个常被忽略的细节。1978 年《太空入侵者》的难度曲线是敌人越少下移越快,那不是设计师的精心安排,是硬件预算的副产品。那块 8080 每帧只够移动一个敌人,敌人消灭得越多,一轮轮询越快,整队下移越频繁。街机时代很多经典难度曲线,本来是算力不足的产物,被玩家读成了设计。
碰撞和预算
判定是所有动作游戏的物理引擎,而它只在两种几何里选,轴对齐包围盒或者圆。AABB 是四次比较,圆是两次乘法加一次比较,便宜到每帧能扫几百个对象。代价是精度,斜着飞的子弹用 AABB 会擦到空气。于是有了扫掠检测,把这一帧的位移当成线段,跟目标做线段与矩形相交,专治高速物体的隧穿。
真正吃预算的是配对数。射击游戏的弹幕场景里,同屏 200 颗子弹、100 个敌人、1 个玩家,两两暴力检测是 ,约 4.5 万次判定一帧。JS 本身跑得动,加上粒子和渲染就悬了。标准优化是空间分区,屏幕切成网格,只测同格和邻格,复杂度从 掉到接近 。弹幕游戏那些看起来密密麻麻的画面,一半性能预算花在这。
还有一件事必须分开看,视觉和判定。贴图给人看,判定框给机器算,两者不必重合。受击框缩小 20%,难度就掉 20%,画面上一点看不出来。这也是所谓硬核游戏的共同秘密,硬在数值上,不在判定上。
限制怎么变成风格
Game Boy 滤镜做的事很朴素,把任意图片每个像素按亮度映射到 DMG 那块屏的四档绿灰阶上。原理是最近邻量化,算亮度,找四档里最近的一档替换,全程不做抖动。结果就是渐变色带被硬切成一块块纯色,细节被粗暴地平板化。
这正好是 1989 年的处境。160×144,2-bit 色深,没有背光,靠环境光反射,响应时间以百毫秒计。当时的美术没得选,只能在四档灰度里做减法,靠明度阶梯区分层次,靠轮廓线补形状,靠一像素的高光暗示材质。限制在前,风格在后,风格是限制的解。
上面这个 CRT 效果走的是同一件事的反面。扫描线、荧光余晖、屏幕曲面,这些复古感全部来自显示设备的物理缺陷。复古美学基本可以概括成一句话,把上一代硬件的误差,当成这一代的设计语言。
节奏游戏不认定时器
节奏游戏收了钢琴块、太鼓、节奏大师三款,它最能暴露时间处理上的错。
直觉写法是用 setInterval(1000 * 60 / bpm) 生成音符。这在浏览器里必然出问题,原因有三层。定时器最小精度约 4ms,规范还允许后台节流到 1s 以上。每次回调的误差会累积。音频输出有独立缓冲,跟 DOM 定时器不同步。BPM 120 时一个十六分音符间隔 125ms,10ms 抖动就足够让玩家听出卡。
正确做法是把音频时钟当唯一时基。以 AudioContext.currentTime 为准,提前若干毫秒 lookahead 把音符排进音频调度队列,视觉只是跟着音频时间轴投影出来的结果。本站的实现就是这么干的,按 60000 / bpm 换算单拍时长,把每个音符算成绝对时间点,再用振荡器在 处排程,判定时比较音符位置与判定线的距离。音频是主时钟,画面是从时钟,这个主从关系一反,就永远对不上拍。
顺手把三个用游戏量自己的工具说清楚。打地鼠测简单反应时,人类下限约 200ms,正常人 250-300ms。成绩受刷新率和输入设备影响,跨设备比没意义。打字测速用 WPM 的算法,五个字符算一个词,。本站实时更新 WPM,准确率按逐位比对算。注意净 WPM 要扣掉错误字符,很多测试标的是毛 WPM,会系统性高估。记忆翻牌测工作记忆,现在的估计是 4±1 个组块,超过之后必然靠复述策略,不是靠记住。
至于训练价值,街机合集练的是感知与动作之间闭环的速度,迁移面窄,见效快。节奏游戏练时间分辨率,对音乐、运动、语音节奏都有正迁移。而二维模拟走的是完全相反的路,它不要即时反馈,要的是确定性系统怎么长出不可预测。单摆的简谐、双摆的混沌、洛伦兹吸引子、N 体引力放在一起看,恰好构成动作游戏的反面。同一个 ,实时化之后变成手感,离线化之后变成混沌。
游戏循环六十年:从 Pong 到俄罗斯方块
上一章的「固定步长加累加器」不是新发明,而是一副循环骨架迭代六十年的结果。把任何实时游戏拆到只剩骨架,剩下的都是同一个结构:读输入、更新状态、画一帧,回到开头——1962 年实验室里的矢量显示游戏如此,今天浏览器里的 Canvas 游戏也如此,变化的只是每个环节的内部。
循环的骨架
循环的史前史
1958 年布鲁克海文实验室的示波器网球、1962 年 MIT 的 PDP-1 上诞生了真正意义上的软件游戏循环:Spacewar! 的代码围绕矢量显示器的刷新节奏运行,读控制台开关、算两条轨道、画两艘飞船,三段式的雏形已经齐了。1972 年的 Pong 把这套骨架搬进街机厅,成为大众见到的第一个「实时游戏循环」,它之后十年,循环的三段结构再没有本质变化。
游戏循环是「程序伺候一块周期性刷新的屏幕」这一根本约束的产物,屏幕刷新多少年,循环就有多少年。最古老的游戏循环是裸的 while(true):读输入、推进状态、绘制,能转多快转多快。街机时代没有操作系统,循环的节奏由硬件时钟与 CRT 的电子束扫描决定,渲染就是「在电子束扫过来的瞬间输出对应信号」。今天浏览器里的等价物是 requestAnimationFrame(rAF):它把你的回调挂在显示器刷新节奏上,60Hz 的屏幕每 16.7 毫秒回调一次,144Hz 的屏幕每 6.9 毫秒一次,回调里做完更新与绘制就交还控制权。相比定时器轮询,rAF 有两个决定性优势:与垂直同步对齐,不会画出「半帧」;页面不可见时自动停摆,后台标签页不烧 CPU。
循环的三个阶段各有纪律。读输入要么事件驱动(按键入队,更新时消费),要么轮询快照(每帧读当前按键状态)。更新负责推进全部游戏状态,输入延迟主要在这一段的排队方式上。渲染应当是纯函数:只读状态、不改状态,这样暂停、回放、换渲染后端才可能。三段里最容易写错的是更新,因为它牵出游戏编程最经典的难题:时间步。
可变时间步与固定时间步
隧穿与螺旋死亡上一章已经点名,可变时间步还有第三层隐患:dt 每帧不同,物理结果随帧率微变,同一个操作在 60Hz 与 144Hz 屏幕上可能走出略微不同的轨迹,竞速游戏与格斗游戏对此零容忍。
工程上的标准解法就是上一章那副骨架的完整版,即著名的「Fix Your Timestep」模式:模拟在固定时钟里跑,渲染在真实时钟里跑,累积器是两个时钟之间的汇率兑换所。物理以固定步长(比如 1/120 秒)推进,每帧把真实流逝时间累加进一个缓冲,缓冲够一个步长就推进一步、扣掉一步;渲染永远用最新状态,要求更平滑时对相邻两个物理状态做插值。
let acc = 0, last = performance.now();const STEP = 1 / 120;function frame(now) { acc += Math.min((now - last) / 1000, 0.25); // 上限防"死亡螺旋" last = now; while (acc >= STEP) { update(STEP); acc -= STEP; } render(acc / STEP); // 插值系数 requestAnimationFrame(frame);}Math.min 那一行值得单独解释。若某帧卡了 5 秒,累积器攒下 5 秒,循环会连跑六百次物理更新;机器更卡、攒得更多,恶性循环即「死亡螺旋」。给单帧补跑设上限,卡顿时宁可让游戏看起来慢放。
后台标签页是浏览器特有的暗坑。rAF 停摆、定时器被钳到每秒一次,切回来时累积器会收到一大块时间。有暂停语义的游戏直接在此暂停,是最体面的处理;没有暂停语义的,也至少要靠这个上限兜住。
Pong
1972 年的 Pong 严格说没有软件循环。它是一整块 TTL 数字电路,位置计数器直接对着电视光栅生成球与球拍的信号,所谓「游戏逻辑」是几个比较器:比分到 11 就清零。
但把它翻译成现代软件循环,恰好是教学价值最高的起点,因为它的状态少到可以用五个数字装下:两块板的纵坐标、球的位置与速度向量、双方比分。
更新规则每帧只有几行:球撞上下边界翻转垂直分量;撞上球拍翻转水平分量;出界得分并重发球。
Pong 留下的最重要设计遗产是反弹角由击中位置决定。球打在球拍中心反弹角小,打在边缘反弹角陡。一个线性映射,把「玩家控制力」白送了进来,不需要任何物理引擎。
再看 AI 对手的写法。让电脑板向球的纵坐标移动,但给它的移动速度设一个上限,难度就藏在这个上限里。低速跟不中快球,高速近乎无敌。
本站的 Pong 就是这个结构。经典模式用方向键(或 WASD、鼠标)控制球拍,对面的电脑板跟踪球的纵坐标但带着延迟与误差。AI 对战模式提供三档难度,档位对应三档 AI 板速上限。
极简循环的另一层好处是确定性。状态转换全由输入决定,没有随机数、没有对浮点误差敏感的链式物理。这类「能脑内穷举」的循环是理解一切更复杂循环的基准面。
电路时代还给软件留了一条暗线遗产:渲染节奏必须伺候扫描。
Pong 的信号生成严格锁定电视光栅,错过一行就是画面撕裂。今天 rAF 与垂直同步的对齐、游戏引擎里的 V-Sync 开关,本质上都是同一约束在现代管线里的回声。
把「更新与显示解耦,但更新节奏向显示节奏看齐」这句话放进 1972 年的机柜里,一样成立。
Breakout
1976 年的 Breakout(打砖块)在 Pong 的骨架上只加了一样东西:一整面砖墙。循环的复杂度却上了一个台阶。
砖块是典型的实体数组。每块砖有坐标、尺寸、存活标记,每帧的碰撞检测变成「球与每个存活砖块做一次 AABB 相交测试」,命中则翻转球速分量、标记死亡、加分。
状态一多,三件事随之出现。
其一是清版判定。存活计数归零即进入下一关,关卡切换顺带重置球位与速度。本站的打砖块里,球速随关卡线性提升,三条命的配置也是从街机原版一路传下来的惯例。
其二是穿透问题。球速快、砖块薄时,可变时间步的一步可能直接跨过砖块,碰撞测试两头都未命中,于是砖块「被穿过」。廉价解法是限制球速上限或把一帧拆成多个子步,昂贵解法是连续碰撞检测(对运动轨迹做扫掠)。像素游戏的工程取舍几乎都是前者。
其三是状态的归属。球、板、砖、命、关卡、分数,各自该由谁拥有、重开一局时谁重置谁保留。Breakout 的代码规模已经需要一点架构意识,但循环骨架仍是「读输入、更新、渲染」。
街机原版还有两处容易忽视的经济设计。
连击提速:球连续命中板与砖若干次后整体提速,把「苟着打」的路线堵死。
位置分差:越靠上的砖排分值越高,鼓励玩家冒风险往墙里打。
这已经是难度曲线的雏形:用分数诱使玩家主动走进更快的球速里。
三条命则是同一套经济学的外层。命是「试错预算」,把一次失误的代价从「游戏结束」平滑成「损失一条命」。玩家在预算耗尽前始终处于学习曲线的爬坡段。后面所有街机游戏的金币机制,都是这套预算系统的商业化翻版。
俄罗斯方块
1984 年,苏联科学院的帕基特诺夫在一台没有图形能力的 Elektronika 60 计算机上写出了俄罗斯方块。方块是用文本字符拼出来的,玩法却一出现就完整:七种四格骨牌落进十格宽的井,填满一行消一行。
与 Pong、Breakout 相反,这是一条全新的循环路线。游戏世界是离散网格,方块按格对齐,没有连续位移、没有惯性、没有穿越。碰撞就是「下一格是否已被占用」的查表。
循环的形态随之改变。重力下落不再是「每帧位移若干像素」,而是定时触发的状态迁移。当前方块从下落态进入锁定态,生成新方块,满行消除,重复。
逻辑节拍与渲染帧率就此解耦。方块一秒落一格,画面却按显示器刷新率渲染插值,两个时钟各跑各的。
这也是为什么本站的俄罗斯方块实现里,重力用的是定时器而非帧计数。每消除 10 行升一级,下落间隔按等级缩短并设了下限。把节拍放在时钟上,改难度就只是改一个间隔数字,与渲染帧率完全无关。
这个选择还有一个浏览器特有的副作用。后台标签页的定时器被钳到每秒一次,切走再切回来,方块的下落节奏反而是「自动慢放」的。
离散化还带来输入设计的新课题。
连续游戏里按住方向键是每帧轮询,俄罗斯方块里对应的是 DAS(延迟自动移位)。按下先移一格,按住超过一个初延后转为高速连移。没有它,把方块推到墙边要按二十几次。
2000 年代确立的「现代规范」补齐了其余的状态机设计。
锁定延迟:方块触底后给零点几秒缓冲再锁死,移动可有限次刷新。
软降与硬降:一键瞬间落底并锁定。
7-bag 随机器:七种方块洗成一袋依序发完再洗下一袋,杜绝连续来四个 S 形的绝望场面。
这些全是状态转移规则的修改,不动一行物理代码。离散游戏的「手感」几乎全部住在状态机里,而不是物理参数里。这与连续游戏形成漂亮的对照:Pong 调手感觉是调板速与反弹角,俄罗斯方块调手感是调时序参数。
插值为什么必要
固定时间步方案里,渲染插值那一步常被初学者省掉,省掉的表现是一种很特别的「微抖动」。
机理拆开看:物理每 8.33 毫秒(1/120 秒)走一步,显示器每 16.7 毫秒要一帧。两者的节奏像两只快慢不同的表,某帧渲染时物理可能刚走了两步,下一帧走了两步半,物体在屏幕上的位置便在「该在的地方」与「多走了半个物理步」之间跳。
60 帧每秒的微小跳跃,人眼读作「高频的抖」,高速运动的物体尤其明显。
渲染插值的解法就是把 acc / STEP 这个 0 到 1 之间的系数传给渲染,让它对物体的前后两个物理状态做线性混合。画出来的位置不是「最近一次物理的位置」,而是「按时间比例恰好应该出现的位置」。
代价是每个参与渲染的实体都要保留上一帧的旧状态,内存换平滑。
省掉插值的方案也能活:把物理步长调成与刷新率一致或整除,抖动自然消失。但多屏兼容立刻崩塌,所以严肃实现都选插值。
输入延迟
上一章量出了 50-120ms 的端到端延迟;「跟手」这个玄学指标的底线,就藏在这条链的每一环里。浏览器里一次按键要经过的链路是:键盘硬件扫描、操作系统键盘缓冲、浏览器主线程把事件派发给页面、页面把按键记入游戏输入状态、下一帧的 update 消费它、渲染进画布、最后合成器把新帧送上屏幕。全程七八段流水线,每段都可能是几毫秒到十几毫秒。
对一般游戏无感,对格斗与音游是生死线。
页面层面能控制的几件事:
事件与轮询的混用。文本输入用事件(不丢键),方向类输入轮询快照(按住不放的状态事件不给),多数游戏循环两者都用。
事件合并的存在。高频指针事件在两帧之间会被浏览器合并成一次投递,需要逐点轨迹时要用带合并事件读取的接口取回中间点。
先把输入记下来,消费放在 update 里。如果在事件回调里直接改游戏状态,就绕过了「更新是唯一状态入口」的纪律。暂停时按键会漏进游戏、回放也无从谈起。
网络对战游戏更把延迟管理推到极致。客户端预测与回滚同步(本地先走、确认后回改)是输入延迟工程化的天花板,那是另一篇文章的体量了。
浏览器里的两条路线归一
把三款游戏与本站实现的循环选型并排放着看,结论自然浮现。
Pong 与 Breakout 是连续物理,状态随时间连续演化,用 rAF 帧循环配合速度积分最自然。
俄罗斯方块是离散状态机,状态只在事件时刻跳变,用时钟驱动节拍、渲染另起一层最自然。
两条路线在代码里最终都会落回同一副骨架:固定步长的 update、只读状态的 render、rAF 或定时器作为心跳。区别只在 update 内部是积分还是查表。
工程上还有一层共享的外围。
Canvas 的物理像素适配:画布尺寸乘以设备像素比再缩放回去,否则高分屏上整个游戏发虚。
页面失焦即暂停。
移动端把触控区映射到输入状态。
这三件小事实实在在决定「能不能玩」,比任何算法都优先。
随机器与确定性
三款游戏里只有俄罗斯方块离不开随机数(7-bag),但随机数背后藏着循环设计的一个深层原则:随机性要收敛到确定性的种子上。
工程写法是自建一个轻量伪随机生成器(xorshift 这类十几行的算法),用当下时间戳做一次种子,之后所有「随机」都是种子的确定函数。
收益在两处。
其一是可复现。出 bug 时拿同一个种子重跑,万局同轨,调参和复现都不靠运气。
其二是可回放。只要录制「种子加全部输入」,整局游戏就能从头重演。直播回放、反作弊核查、联机同步(双方只传种子与输入,不传状态)用的都是这一招。
浏览器自带的随机函数用起来省事,但它拿不回种子,也就断掉了可复现性。
习惯上再补一刀防呆:从浮点取整映射到整数区间时避免直接取模(低位分布不均),用乘法缩放。floor(rand() * n) 而不是 rand() % n。
这些细节单独看都小,合起来决定了「这个游戏的循环是否可测试」。
移动端触控
浏览器游戏在手机上要能玩,循环之外还有三件必答题。
阻止默认手势。画布上滑动页面会跟着滚动,touch-action: none 一行声明把触控交给游戏自己处理。
虚拟输入区的布局。俄罗斯方块的左右移动、旋转、软降需要至少四个触区,拇指热区按单手握持摆放,并把点击目标做到 44 像素见方以上。触控事件同样要先记入输入状态、由 update 消费,与键盘一视同仁。两套输入汇入同一个状态层,才不会出现「触屏玩家绕过了 DAS」这类不对称。
性能档位。手机 GPU 对每帧全屏重绘敏感,静止场景跳过重绘、粒子数量按设备自适应,是把帧率稳住的常规手段。
移动端不是把桌面版缩小一圈,而是把输入层重新实现一遍。循环骨架照旧,输入 adapter 换新。
三道最小检验
自建循环,先跑通三个最小检验。
帧率无关性测试。把显示器从 60Hz 换到 144Hz,或在开发者工具里限制 CPU 数倍降速,同一组输入应当走出同一条轨迹。轨迹变了,说明有物理量还藏在「每帧」里而不是「每秒」里,逐个揪出来换算成时间单位。
暂停完整性。暂停后狂按方向键再恢复,游戏状态应当与暂停前逐位一致。它验证的是「输入只在 update 里消费」这条纪律。
切页回归。切走几分钟再回来,不穿墙、不瞬移、不双倍速。它验证的是累积器上限与失焦处理。
三道全过,循环骨架才算立住了。
复古信号的物理与像素渲染
循环管住的是时间,剩下的预算花在像素上。老式显示设备满身缺陷:扫描线把画面切成横纹,荧光粉拖出余晖,玻壳曲面把四角压暗,磁带把颜色撕出毛边——这些当年被工程师当成待修复问题的东西,在数字时代被反向供奉成美学,因为它们携带着模拟信号独有的材质感,而逐点发光的现代屏幕永远不会主动送来这一层。复刻它们不需要 WebGL 与着色器货架,Canvas 2D 加几层 DOM 就足够,真正的难点是搞清每种缺陷的物理成因,再把它翻译成对应的代码结构。
缺陷为什么成了美学
先弄清 CRT 到底在做什么。
电子枪发射电子束,偏转线圈用磁场把它从左到右、从上到下逐行扫过荧光粉涂层,这一过程叫光栅扫描。涂层上的荧光粉被电子轰击后受激发光,然后按自己的余晖曲线在几毫秒到几十毫秒内衰减。
严格地说,CRT 屏幕上任何时刻只有一条移动的亮线,其余画面全是荧光粉余晖撑起来的「逐渐衰减的记忆」。它是一台逐行播放的设备,而不是一个同时存在的平面。
彩色显像管还靠一块荫罩(shadow mask)或孔隙格栅(aperture grille)挡在电子束和涂层之间,保证束流只打中对应颜色的荧光点。
所以 CRT 的「像素」从来不是整齐的发光点阵,而是红绿蓝三组荧光粉的物理涂层。边界糊、亮度高时还会向邻域溢光。
再加上 50/60 Hz 的刷新频率、玻壳的弧面、玻璃边缘更厚导致的照度衰减,这就是老显示器的全部「病」:扫描线、余晖、闪烁、暗角、几何失真。
广播时代还叠了一层时间维度的模糊。模拟电视用隔行扫描,一帧拆成奇偶两场轮流送出,靠场频换全帧率,静态画面在余晖和视觉暂留里被脑补成完整一帧。
老画面看起来「润」,一半来自空间维度的荧光粉溢光,另一半就来自这种时域模糊。
今天的复刻滤镜大多只认真还原空间维度。扫描线、暗角、色差都好做,而余晖与隔行需要逐帧的运动估计,成本与收益完全不成比例,几乎所有网页实现(包括本文这些)都绕开了它。
这些缺陷之所以升格为美学,是因为八位、十六位时代的游戏美术本来就是围着它们设计的。
低分辨率下大面积的抖动棋盘格,在 CRT 上被光晕糊成平滑的过渡色。扫描线给画面提供了清晰的「行结构」,让 320×240 的内容看起来比实际更锐。荧光粉溢光天然充当抗锯齿,把色块边缘的台阶软化掉。
把同一张画面放到逐点显示的液晶屏上,抖动还原成噪点图案,锯齿原形毕露。缺陷消失了,当年的设计意图也跟着失效。
所以复古滤镜的本质不是「给画面加噪声」,而是给精确的数字显示补回一层光学上的不精确。
光学层叠加
复刻有两条根本路线,第一条叫光学层叠加:底层画面不动,把每种缺陷做成一层透明叠加。
站内的 CRT 滤镜是这条路线的完整标本。
它默认绘制一个演示场景:深绿底色上铺 22px 一格的网格、24 个按伪随机轨迹闪动的亮斑、一颗沿利萨如轨迹弹跳的小球,标题 FROSTI 和每半秒闪一次的 PRESS START。上传本地图片即可把任意画面替换进去。
这个演示画面不是随手画的。规则网格是最容易暴露扫描线吞行与几何变形的测试图,高对比弹球则检验闪烁在运动中的观感。老电视维修工拿棋盘格测试信号干的是同一件事。
画面之上是四个可以独立开关的叠加层,外加一个 10%–100% 的强度滑杆。
- 扫描线:一条 CSS 渐变。每 3 个 CSS 像素画 1px 高的暗线,不透明度 ( 是强度)。扫描线本质是空间上的方波采样,行距取 3px 是个经验平衡:密到人眼把暗线并成「行结构」,又疏到不打断画面内容。
- 闪烁:全屏白色层,交给名为
crt-flicker的 CSS 关键帧动画驱动透明度,振幅 。关键帧只有两帧、时间函数是steps(2)。不透明度在 两档之间硬切,闪烁是方波而不是呼吸。这类「纯表现」的效果跑在合成线程上,主线程一帧都不用碰,永远别用 JS 逐帧改样式。 - 暗角:径向渐变,中心 55% 区域全透明,向边缘过渡到 65% 黑。对应玻壳边缘玻璃更厚、光线入射角更大带来的照度衰减。摄影里的 cos⁴ 定律说的是同一件事。
- 曲面:容器加 28px 圆角,再叠一层
perspective(900px) rotateX(2deg)的 3D 变换和内阴影,用最小的成本近似外凸的玻壳。
强度滑杆同时驱动扫描线不透明度和闪烁振幅这两个派生量,源码里用响应式派生把它们挂在同一个状态上,保证拧动滑杆时两种缺陷「同涨同落」。语义上是一个「总损坏程度」。
工程上还有个细节值得注意。演示场景跑在 requestAnimationFrame 循环里逐帧重画,但四层叠加全部是静态的 CSS。开关任何一个效果都不会触发重绘,只是显隐一个 DOM 层。
所谓「效果开关零成本」不是修辞,是结构。
整条路线的工程要点因此只有一句:效果全部是叠加层,底层像素数据从未被修改,各层互不污染。
扫描线特效在这个路线上再多走一步,把荫罩也画了出来。
它由两层组成:扫描线层与 CRT 滤镜同构(),关键是 RGB 掩膜层。一条横向重复渐变,红、绿、蓝各占 1px 循环平铺,再以 的不透明度和 screen 混合模式叠上去。
这模拟的是彩色显像管的分色结构:三色荧光粉竖条排列(Trinitron 一派的孔隙格栅)。
两层各自的周期都是 3px,但一个沿纵向、一个沿横向,正交的节奏互不打架,叠出来才不会出现讨厌的干涉纹。
为什么混合模式必须是 screen?因为荧光粉是加色发光。screen 的公式是:
两个输入各自独立贡献亮度、只会更亮不会变黑。这正是光叠光。
若用 multiply,那是颜料减色系统的模型,画面会迅速发黑。若用普通 alpha 覆盖,掩膜干脆把底图盖死了。
一个混合模式的选择,背后是两种颜色系统的分野。
信号层重写
第二条路线叫信号层重写:效果不能靠叠加,必须逐像素改写画面。
VHS 特效是这条路线的代表,因为它复刻的缺陷都发生在「信号」里,而不是「光学」里。
VHS 是模拟磁带录像。磁头螺旋扫过带面,亮度信号直接记录、色度信号降频后挤在频谱低端(color under)。
重放时走带速度的微小抖动、磁头与磁迹的对位误差,都会变成肉眼可见的病。
组件把每种病映射成一个滑杆,默认值与药方如下:
| 病因 | 复刻手段 | 默认值 |
|---|---|---|
| 走带抖动、张力不均 | 每行水平位移随正弦波扭动 | 失真 0.5 |
| 色度重放对位误差 | 三通道水平错位采样 | 色差 4px |
| 磁头切换噪声 | 滚动的噪点横带 | 跟踪 0.6 |
| 信号弱、底噪大 | 加性灰度噪声 | 噪点 0.4 |
| 行结构 | 每 3 行 1px 暗线 | 扫描线 0.5 |
核心处理是一段逐像素循环。
源图(默认场景是渐变底上 26 个随机半透明圆,配一行 ▶ PLAY 字样,像暂停在录像带上的画面;也可上传图片)先存成一份 ImageData 底稿,然后每帧生成输出:
for (let y = 0; y < sh; y++) { // 走带扭动:行号做相位,时间项让波浪滚动 const warp = Math.round(Math.sin(y * 0.25 + time * 4) * distortion * 10); for (let x = 0; x < sw; x++) { const sx = ((x - warp) % sw + sw) % sw; const i = (y * sw + x) * 4; od[i] = sd[(y * sw + ((sx + shift) % sw)) * 4]; // R 右移 od[i + 1] = sd[(y * sw + sx) * 4 + 1]; // G 原位 od[i + 2] = sd[(y * sw + ((sx - shift) % sw)) * 4 + 2]; // B 左移 }}色差这一步就是把红通道向右采样 shift 像素、蓝通道向左采样、绿通道原地不动。色度与亮度重合不良造成的彩色镶边,被翻译成三通道的错位读取。
跟踪条则是一条高度为 的横带,位置按正弦在画面里上下滚动,带内每个像素叠加 ± 的噪声并给红通道加 30。
所以它是那道偏粉的、往上爬的亮痕,正是磁头切换噪声在老电视上的长相。
噪点给每个像素三通道加同一个随机偏移,同值保证出来的是灰度噪声而非彩色噪点。
两个实现细节。
其一,行位移后的循环取回用了 ((x - warp) % sw + sw) % sw 这种先加宽度再取模的写法。JS 的 % 对负数保留符号,直接取模会把行左端「卷」出负下标,加一次 sw 才能让画面真正首尾相接。
其二,噪点每帧都重新随机。空间上的噪声图案每 16ms 换一次,这正是模拟底噪「活」的来源。静态噪声图案一眼假,会动的才是信号。
性能账也要算。560px 宽的源图,每帧重算 O(W×H) 约二十万次循环,内层只有取模和数组读写,JavaScript 依然能稳住 60fps。
这个量级是逐像素路线的上限感。再高一个数量级就该换 WebGL 了。
顺带说,ImageData 背后是 Uint8ClampedArray,写入时自动钳到 0–255,省掉了所有手写 clamp。纯前端做图像处理,类型化数组是第一块基石(语言层面的铺垫见早年的 JavaScript 笔记)。
「重置」按钮也藏着一个教学点。它把五个滑杆拧回默认值:失真 0.5、色差 4px、扫描线 0.5、噪点 0.4、跟踪 0.6。这组默认值本身就是一份「典型 VHS 病历」。
而把所有滑杆拉到零,画面立刻恢复成干净的数字图像。逐像素路线里病与药一一对应,关掉每一种病,就还你一种干净。
雪花屏
电视雪花复刻的是最抽象的一种缺陷:没有信号。
天线收不到节目时,电视机的自动增益控制(AGC)会把放大器增益拉到最大,于是电阻热噪声、大气噪声、邻近设备的电磁泄漏统统被放大到可见。
雪花不是「某种图案」,而是被放大到可见的噪声底。
白噪声的数学定义是功率谱平坦,落到屏幕上就是每个像素独立同分布。
组件的实现却不是均匀分布。每帧每个像素独立掷一次两点混合分布:
- 以概率 取暗噪声:,接近黑但不是纯黑;
- 以概率 取亮脉冲:。
三通道同值,纯灰度。
强度滑杆(5%–100%,默认 60%)挪的正是亮脉冲概率。源码注释写得很直白:强度越高,亮噪点越多。
为什么不做均匀分布?因为均匀白噪声的观感是一层均匀的灰雾,而「雪花」的记忆点是大面积暗底上稀疏跳动的亮点,亮度直方图是双峰的。
这是所有噪声复刻的共同教训:分布形状决定观感,均匀分布几乎从来不是答案。
滑杆量程里还藏着一个讲究:强度下限是 5% 而不是 0。满屏纯黑不算「最弱的雪花」,那是电源键的职权,两种「没有内容」在物理上是两回事。
画面上还固定叠着三层:每 3 行 1px 的扫描线(不透明度 0.28)、每帧随机强度的白色闪烁层(,强度越高闪烁反而越弱,亮噪点本身就够闪了)、以及径向暗角。
电源开关关掉后屏幕落到 #080808 的近黑。老电视关屏是余晖未散的灰黑,不是 OLED 的绝对黑,这个细节也被照顾到了。
这层噪声在物理上有个精确的出处:电阻热噪声的均方根电压:
其中 是玻尔兹曼常数、 是绝对温度、 是阻值、 是带宽。
天线馈线里每一个这样的电子扰动,在 AGC 把增益拉满之后都会被放大成屏幕上的一次明暗跳变。
雪花屏是热力学第二定律画在玻璃上的签名,这句话在字面意义上成立。
顺带一提,它也因此当过历史上最便宜的硬件随机数发生器:热噪声就是现成的熵源。这个组件则反其道而行,用 Math.random 喂出一台「假电视」。
Game Boy 四阶灰度
1989 年的 Game Boy(DMG-01)只有 160×144 分辨率、四阶灰度、一块无背光的反射式液晶屏。
四色画光影,逼出了整套约束美学:抖动补过渡、粗轮廓补对比、负空间当高光。
Game Boy 滤镜复刻的正是这套约束下的调色板渲染。
流水线有四步。
第一步,上传或拖拽的图片等比缩到最大宽 220px(内置示例图恰好 220×160,和掌机分辨率一个量级。这一步顺便完成了降采样抗锯齿)。
第二步,逐像素算亮度:
这是 ITU-R BT.709 的高清电视权重,人眼对绿色的敏感度被编码进系数里。
第三步,在四档调色板的亮度上做最近邻量化。
四档颜色是 #0f380f、#306230、#8bac0f、#9bbc0f。这套经典的「墨绿到黄绿」配色,按上面的公式算出亮度约为 0.17、0.33、0.60、0.66。
注意它们并不均匀:最亮的两档只差 0.05,几乎贴在一起,高光区间实际被折叠成同一档。
这是复刻的忠实之处。DMG 的液晶响应本来就压高光,画面整体偏中暗调,套用后自带一股「口袋机」的闷感。
第四步,不做任何抖动,最近邻保留色块的硬边界。前后双画布对照展示,另附一个可开关的扫描线层(每 3 行 1px,不透明度 0.25)和底部四档色卡(连 hex 值一起标出)。
组件不只给你结果,还把调色板本身当展品,这正是「约束美学」教材该有的样子。
量化数学值得一笔。4 档 = 2 bpp,均匀亮度输入下最近邻量化的最大误差是相邻档距的一半、平均误差约为档距的四分之一。
想要更平滑只有两条路:加档位,或者抖动,即拿空间分辨率换亮度分辨率。
经典 GB 游戏走的是有序抖动:把一个 4×4 的 Bayer 阈值矩阵平铺到全屏,每个像素按自己位置上的阈值决定「该升一档还是该忍着」,远景看就是连续渐变。
这个组件刻意不做抖动,量化边界清晰可见,反而更「像素风」。约束被保留成了风格。
还有一处「将错就错」也值得记录。整套亮度计算与量化都在 sRGB(伽马编码后的)分量上直接进行。严格的图像学教科书会要求先做伽马展开、转成线性光再平均,最后重新编码。
但复古滤镜的参照物本身就是非线性响应的老液晶,把完整的线性化流程套上去反而显得「过于正确」。
工程判断里,知道哪里可以不精确,比处处精确更重要。
还有一个有趣的对照:同站的 ASCII 渲染器算亮度用的是 0.299/0.587/0.114。那是 BT.601 的标清电视权重。
两套都是国际标准近似,无所谓对错,只是「引用了哪个年代的电视」。同一站点里两个组件各用一套,恰好是一堂活的色度学选修课。
从灰度到字符
字符艺术比像素艺术血统更老。
行式打印机时代没有像素,打印机只会打字符,于是人们用字符的墨水密度拼出图像。
原理一句话:把每个字符当「超级像素」,字符坡度按墨水覆盖率单调排列。空格覆盖率 0%,句点略沾,@ 几乎涂满。
终端黑底白字的习惯下,亮区映射到 @、暗区落到空格。
ASCII 渲染器用的坡度就是这十个字符:「 .:-=+*#%@」。
它的流水线是灰度量化的又一个变体。
源画布固定 320px 宽(默认场景是径向渐变加五个黑圆两条白杠,或上传图片)。水平分辨率可在 40–160 列间调节(默认 90),单元格宽 = 320 ÷ 列数,单元格高 = 宽 × 2。
因为等宽字体字符的高约是宽的两倍,不乘这个系数画面会被纵向压扁。这是字符渲染最经典的坑。
默认 90 列落在 320px 宽的源上,每格约 3.6px 宽、7px 高。这个粒度下字符还认得出形体、画面还认得出结构,是「两端都认得出」的甜点位。
每个格子按步长 2 采样、求平均亮度(一个盒式滤波降采样),权重取 BT.601。
然后过一个密度参数(0.4–2.4,默认 1.2)做对比度拉伸:
压缩灰阶、让字符过渡更缓, 拉开对比、让明暗更决绝。
拉伸后钳到 [0, 255],按 查坡度表输出字符。
坡度表本身是手工排的。同一字符在不同字体里的墨水覆盖率并不相同,所以「 .:-=+*#%@」是感知密度的近似排序而非精确刻度。
但这个排序颠扑不破,因为在任何等宽字体里 @ 都比 . 密。
把流水线再抽象一层:每格求平均亮度其实是一次低通滤波,作用是压制降采样必然带来的混叠。不平均、直接抽样,高频细节会以伪纹的形式假回来。
结果是一段纯文本,装在等宽文本块里。复制带走的是字符而不是位图,这也是 ASCII 路线独有的「可复制性」。
Game Boy 4 档 = 2 bpp,ASCII 10 档 ≈ 3.3 bpp,8bit 灰度 256 档。
层级越少边界越硬,于是所有复古渲染都在同一条交换曲线上滑动:空间分辨率 ↔ 亮度分辨率。
把列数拉到 160,每个字符只剩 2px 宽的采样面,细节回来了、字符感却淡了。压到 40,只剩轮廓和体积。
这和 Game Boy 的四档灰度、和 CRT 的三色竖条,是同一条美学曲线的两端。