一帧画面是怎么来的

UNIT 11引擎约 33 分钟 · 完整线

两千个小人是怎么用代码画出来的?

打开《珠江上河图》,拖到西关,把陶陶居放大六倍:楼上每扇窗里坐着一桌人,桌上的蒸笼一层层叠着,挑担的街坊正从门口走过。放到这么大,边缘还是利落的,不糊。整卷有 2097 个小人、1757 件不动的画件(一栋楼、一棵树、一团雾,各算一件),整个网站却只是一个 2 MB 的网页文件。这一课把这台机器拆开:一笔墨怎么变成屏幕上的像素,三万单位长的画怎么装进浏览器,推近镜头时的闪烁又是从哪一步来的。

长卷里的陶陶居放大六倍:两层茶楼,八扇窗里各坐一桌饮茶的人,桌上叠着蒸笼;楼下是招牌、灯笼、店铺,挑担的人从门前走过。
放大六倍的陶陶居。长卷公开站,镜头停在 x = 16660、放大 6 倍、白天、时间冻结在第 12 秒(2026-09-29 截图,1280 × 720)。

去真东西上看这一处 ↗网址里的 z 是放大倍数;改成 1,就是整卷铺满屏幕高度的样子。

11 · 1先画成一张大图,看哪儿裁哪儿,不行吗?

最顺手的做法,是像画真画那样,先把整卷画成一张图存下来,看哪儿裁哪儿。可屏幕上的图是一格一格的像素,拉大不会多出细节,只会把每一格拉成更大的色块。要让任何一处放大六倍都清楚,这张图一开始就得按六倍的精度画。

长卷的坐标是 3 万 × 1000 个单位,一个站着的小人约 30 单位高。整卷正好铺满屏幕高度,算放大 1 倍。这一课说的精度,就是一个单位在屏幕上占几个像素:在 1080 像素高的屏上,1 倍是每单位 1.08 像素,6 倍是 6.48 像素。

一块 1920 × 1080 的屏。要让整卷任何一处放大六倍都不糊,这张整卷大图要占多少内存?(每个像素存红、绿、蓝、透明 4 个字节)

  1. 约 50 MB,一张大照片的量级
  2. 约 500 MB
  3. 约 5 GB
  4. 约 50 GB

放大 6 倍:每单位 6.48 像素
30000 × 6.48 = 194,400 像素宽,1000 × 6.48 = 6,480 像素高
≈ 12.6 亿像素 × 4 字节 ≈ 50 亿字节 = 5.0 GB(1 GB = 10 亿字节)

这还只是一块普通屏。换成两倍屏,每个方向像素再翻一倍,内存乘 4。浏览器一个标签页拿不出这么多。第一条路撞墙:存不下。

第二条路:不存图,每一帧把屏幕上看得见的画件按当前精度现画一遍?一块 16 : 9 的屏,铺满 1000 单位高时横跨约 1800 单位;1757 件画件铺在三万单位上,平均每 100 单位约 6 件,一屏就是一百来件,每件几十到几百笔。可一帧只有约 16.7 毫秒(第 8 课的帧预算)。第二条路撞墙:画不过来。第 3 节的实验台会在你这台机器上当场量:现画一屏和拼一屏差多少。

长卷走的是第三条路:只存看得见的几小块,按需要的精度现画。先得看清一笔墨在代码里是什么。

11 · 2一笔墨,在代码里是什么?

长卷里的每一笔,存的不是像素,是几个点。画的时候交给 brush() 这个函数,它把这几个点变成一个填了墨的多边形。下面跑的就是它(src/kit/ink.js):红圈是记下来的点,蓝色细线围出的是最后填墨的多边形,中间一串蓝点是它算的时候用的中线(打开「显示骨架」才看得见这些)。

实验台 E-1一笔:几个点怎么变成一笔墨
来自材料
填墨用的是 ink.js 的 brush() 本身;磨圆、等距取点、前 22% 渐宽、后 35% 渐收,都写在这个函数里。
本实验的设定
纸是 160 × 72 单位,1 倍 = 每单位 4 像素;三种预设笔画是为演示挑的;蓝色轮廓按同一套步骤另算一遍画出来。
它能证明
一笔墨 = 几个点 + 一个函数;同一串点在任何精度下重算,边缘都利;同一个种子,飞白一模一样。
它不能证明
这就是毛笔。它太匀,真毛笔的提按、手腕的犹豫它模仿不了。

01 笔锋只是一条宽度曲线:前 22% 从起笔的宽度涨到最宽,最后 35% 收到收笔的宽度,就是实验台下方那条折线。02 磨圆用的是 Chaikin 切角:首尾两个点留着,中间每一段只取 1/4 和 3/4 处的两个点,折角就被削掉一块。磨圆以后,再沿着这条线按相等的间距重新取点,每个点往两边各推出半个笔宽,左右两边连起来,就是那个多边形。换成「一个折角」那一笔,拖着磨圆次数看最明显。

把 04 放大拖到 6 倍:左边是 1 倍时存成像素的图被拉大,边成了糊的色块;右边是同一串点在 6 倍下重算、重新填墨,边还是利的。存下来的是描述,不是像素。

用数学描述形状、要多大按多大现算的图,叫矢量图(vector);一格格存颜色的叫位图(bitmap),照片就是位图。浏览器的 Canvas 画布本身是一张位图,brush() 交给它的是一条路径的描述,填色那一刻才变成像素。长卷放大六倍能看清蒸笼,是在六倍下重画了一遍。

03 飞白让每一点的宽度随机抖一点。这里的随机数来自一个种子:随机数的起点,同一个种子按顺序抽出来的一串数永远一样。拨一拨种子你会发现:同一个种子,每次画出来一模一样。为什么非这样不可,下一节就明白。

这几个函数还有一个用处。长卷是 11 个并行的 AI 画师画的,五个画素材、六个各画一段。它们共用同一套 brush()、同一盒颜料(palette.js),外加一份 127 行的规范 STYLE.md:线宽多少、颜色怎么上、楼怎么斜着画(系数写明)、不许画方盒子、不画旗,每类素材还画了样板对照。拼起来像出自一只手,靠的是这些。

让规则去「长」

代码画的不只是笔画。广州片(Blender 做的 3D 城市片)里的榕树,枝干是算法「长」出来的,叫空间殖民(space colonization):先在树冠该有的范围里撒一把「吸引点」,每一步,离吸引点最近的枝头朝它们的平均方向长出一小段,枝头碰到的点就删掉,直到删完。枝条自然往空处长、互相让开,像真树争阳光。枝条多粗按管道模型:父枝的截面积约等于子枝截面积之和,也就是父枝半径的平方约等于各子枝半径的平方相加(广州片榕树的脚本把这个指数取成 2.35,比 2 略大)。另一种老办法叫 L 系统:用「一根枝换成一根枝加两个分叉」这样的改写规则,一代一代替换,再照着画出来。

随机也要有规矩。《拾光》的沙面第一轮按 8 种样式给楼随机分:相邻两栋撞成同一种的概率是 1/8,139 栋要是排成一排,平均会撞 17 对;加一条「相邻不能一样」的规则,撞车就是 0 C37。长卷的两千多个小人也是这样:每个人按时代给的比例,从自己的种子里挑衣服和颜色(figures.js),同一个种子永远是同一身。

11 · 3切成瓦片:只画一次,反复用

回到第 1 节的两堵墙:存不下,也画不过来。长卷把画面切成 512 × 512 像素的小方块,叫瓦片(tile)。每件画件自己报一个包住它的长方形,叫边界框。画一块瓦片,就是查出哪些画件的边界框和这块瓦片相交,每件按自己的点、自己的种子画一遍,画好存起来(缓存)。镜头挪动时,大部分瓦片直接从缓存拿来拼,只有新露出的边上要现画。

实验台 E-2瓦片:一段小长卷,拆开看
来自材料
树、小人、楼的画法是长卷的函数本身(trees.js、figures.js、arch-core.js、arch-parts.js);怎么查画件、怎么分档、怎么按框裁切,照 tiles.js 精简了抄过来。
本实验的设定
这段 900 × 300 单位的小长卷是为实验摆的;瓦片边长缩成 128 像素,只有一层远近;精度按网页排版的像素算(和屏幕密度无关),任何屏幕上画面都一样。毫秒数和线程计时是你这台机器当场量的,每次有起伏。
它能证明
缓存让平移几乎不用画;换档要整屏重画;种子不对、边界框不对,接缝出在瓦片的边上。
它不能证明
长卷本身的速度:它一屏上百件画件、五层远近,瓦片边长是这里的 4 倍,还有人物动画和昼夜。

拖 01 平移:「现画」的块数只在新露出一列时才跳,其余全从缓存拿。读数第三行是当场量的:这一小段(二十来件画件)现画一屏要几毫秒,拼一屏不到一毫秒。长卷一屏是它的好几倍。

真的长卷还分五层远近:天空远山、远城、中景街市、近景、前景的树和雾,每层各切各的瓦片,先远后近一层层叠,会动的人和船插在对应的两层之间。靠先后顺序决定谁挡住谁,叫画家算法(painter's algorithm),画家也是先铺远山、再画近树。

再看 03 随机数。那棵大榕树横跨好几块瓦片,就会在每一块里各画一遍,每块只留下自己那一格;每一遍都得一模一样才拼得上。同一个种子、按同样的顺序抽,才保证一模一样。换成「每块瓦片各抽各的」,树叶和衣服就在瓦片边上错开:浏览器自带的 Math.random 不能指定种子,用它就是这个下场。长卷 rng.js 开头的注释写着:画件里的每一个随机选择,都必须来自交给它的那串带种子的随机数。

拖 02 推近。精度变高以后,瓦片要按更高的精度重画,可不能每挪一点就重画一套,所以分成档:

档 = 四舍五入( log₂(每单位像素数) × 每倍几档 )
这一档的瓦片按 2档 ÷ 每倍几档 像素/单位画

网页上精度每翻一倍只分 2 档,相邻两档差 21/2 ≈ 1.41 倍。两档之间,瓦片按最近的一档画好,再拉伸或压缩一点贴上去;离最近的一档最远是半档,也就是 21/4 ≈ 1.19 倍。读数里「第几档」一变,这一屏的瓦片就全部现画。档越细,精度越贴近屏幕,推拉时要重画的也越多。网页要跟手,选了粗的。

这些瓦片也不在主线程上画。线程是程序里一条能各干各的工作流水线;浏览器里响应你手指、拼画面的只有一条主线程,它一卡,拖动就卡。长卷另开几条后台线程(Web Worker),每条带一块离屏画布(OffscreenCanvas),各画各的瓦片,画好交回来拼;拼合、叠人物和灯光、运动模糊交给显卡。网页最多开 4 条,出视频时最多 8 条,都给系统留出 CPU 的两个核心(真正能同时算的单元)。实验台 05 的按钮会在你这台机器上真开 1、2、4、8 条线程画同一屏,比一比时间。

江雾为什么被切成了方块

六段画拼起来以后,二沙岛一带的江雾被切成了一块块直角方块 C12。引擎决定一块瓦片要画哪些画件,看的是每件画件自己报的边界框,从不检查它实际画到了哪儿。

一件江雾,实际画到的范围不变,只把它报的边界框缩小一圈(实验台 04,先别开「按边界框裁切」)。画面上会怎样?

  1. 江雾整片消失
  2. 江雾整体变淡
  3. 江雾在一些瓦片的边上被切出直角
  4. 不变:画了多大就显示多大

把 04 拖到 60% 左右:斜线标出的几块瓦片和缩小后的框不相交,引擎认定「这块不用画江雾」;隔壁那块相交,画了整片江雾,却只留得下自己那一格。江雾于是齐刷刷停在瓦片的边上:消失的地方是瓦片的边,所以是直的。

长卷修了三件事 C12:写个检查脚本(bboxcheck),把每件画件单独画一遍,找出画出了自己框的,把框改对;每个框再多留 12 个单位的余量,给笔画的抖动和光晕留地方;最后严格按框裁切,万一还有越界,也在每块瓦片里裁在同一条线上,不再跟着瓦片的格子走。打开 04 的「按边界框裁切」看裁切的效果;要让江雾完整,还是得把框拖回 100%。

11 · 4推近的时候,画面为什么会闪?

长卷宣传片第二版是一镜到底,交付说明里写着「闪烁:已修复并验证」:逐帧测整屏平均亮度,3075 帧没有一处跳变。第二天你看完问:「移动的时候,画面会闪烁。这是什么问题?是渲染过程中的问题,还是编码过程中的问题?」C14

下面用上面那台瓦片引擎推近 96 帧(每倍 2 档,和网页一样),每帧做两种检测:当时用的整帧平均亮度;以及把上一帧放大到和这一帧对齐、再逐个像素相减的帧差(相减后越亮,两帧差得越多)。

推近这 96 帧,两条逐帧曲线会是什么样?

  1. 平均亮度就能抓到闪烁:画面跳的地方它也跳
  2. 帧差每一帧都一样高:推近一直在变,每帧都在「闪」
  3. 平均亮度一路平滑;帧差只在少数几帧突然冒尖
实验台 E-3推近 96 帧,逐帧查闪烁
来自材料
瓦片引擎、分档公式、小楼的画法(granite() 画石面、manzhouWindow() 画满洲窗)来自长卷源码;两种检测是长卷当时用过的办法。
本实验的设定
镜头从每单位 1.3 像素推到 5.2 像素,每帧 256 × 144 像素;帧差 = 对齐后每个像素亮度差的平均(亮度满分 255)。第 03 个开关是本课加的对照,长卷的函数里没有这一步。
它能证明
平均亮度看不见局部的跳;帧差的尖峰落在换档那几帧;档分细了,小跳消失,颜色整排换的那一跳还在。
它不能证明
长卷视频里具体哪几帧、跳多大:真片子几千帧、上千件画件,还有人物动画和运动模糊。这里的数字不能和长卷的实测直接比。

把 02 逐帧看从第 41 帧拨到第 42 帧:帧差图整片亮起来,左边的窗户颜色全换了。两帧的精度只差约 1.5%,画出来却像两栋楼。这一跳有两个来源,都在换档那一刻。

小的那个是拉伸:第 41 帧要每单位 2.365 像素,瓦片却按第 2 档的 2.0 画,再拉大贴上,是这 96 帧里最糊的时候之一;第 42 帧要 2.399(比 2.365 多约 1.5%),瓦片换成第 3 档的 2.83 画,再压小贴上,是最利的时候之一。每次换档都这样跳一下,曲线上那几个小尖就是它。

大的那个是细节分支。长卷的很多函数会看「现在每个单位占几个像素」决定画多少细节,这就是一个分支:精度够了才多画一笔。石面函数 granite() 在每单位超过 2 个像素时才撒上石头的斑点,一个斑点从随机数里抽两个数(位置)。可随机数是一串,按顺序一个一个取;斑点和后面挑窗玻璃颜色用的是同一串。斑点一出现,这一串被多取了几百个,排在后面的每一个「挑什么颜色」拿到的都是另一个数。

长卷里同一处陶陶居的左右两张:右边只多推近约 2%,窗楣上一排菱形色块的颜色整排换了,窗边的格子花纹也多了出来。
公开站上的同一件事。陶陶居同一位置,窗口 1280 × 720(720 像素高,1 倍 = 每单位 0.72 像素),左边放大 3.27 倍、右边 3.34 倍。只多推近 2%,窗楣的颜色整排换了,窗边多出格子花纹。放大 3.20 倍、3.45 倍各截一张,颜色和同一档的那张一样。把陶陶居的画法单独拿出来按不同精度跑,窗玻璃颜色正好在每单位 2 个像素这条线上变:台基的 granite() 就在这条线上撒斑点,左边那一档按 2.0 画,右边那一档按 2.83 画。

现在把 01 档位换成每倍 32 档:这是长卷出视频的改法,相邻两档只差 21/32 ≈ 1.022 倍,而且同一帧里所有瓦片只用一档(网页上瓦片没画好时,会先借上下几档画好的顶着,一屏里可能混着好几档)C14。拉伸的小尖全平了,可第 31 帧还剩一个大尖:档分得再细,总有一档要跨过「每单位 2 个像素」那条线。最后打开 03,让斑点用它自己的一串随机数(rng.js 里现成有分出一串新随机数的 fork()),这个尖也没了。

长卷当时一起改了五处 C14:视频每倍 32 档、一帧一档;画里的人和船从每秒只更新 12–15 次改成每帧都画;运动模糊(一帧里取几个时刻平均,第 12 课)从固定 6 个时刻改成按镜头速度自适应、最多 24 个;帧率 30 提到 60;分享版的码率(每秒用多少数据存视频,越低越糊)从 2.2 Mbps 提到约 6 Mbps。同一段快速推拉,帧差的「最高那一帧」和「一般的帧」之比,从 1.33 降到 1.10(越接近 1 越平稳)。当时的判断是:主要在渲染,编码占一小份。

最后,把这一课和第 8 课接起来:

带走一个问题

网页上快速推近时,有的瓦片还没画好,引擎会先从上下几档借已经画好的像素把这一格顶上(render.js 里的 fallback),真的瓦片画完再换回来。按这一课的模型:推近到陶陶居每单位约 2.4 个像素、刚跨进第 3 档的那一刻,屏幕上可能出现什么样子的毛病?它会持续多久?

不看上面,写下这一课撞上的几堵墙,再展开对照
  1. 画成一张大图、放大就拉 → 位图拉大不长细节;按六倍精度存整卷,一块普通屏就要约 5 GB。
  2. 每帧把看得见的画件现画 → 一屏上百件,每帧都画不过来。
  3. 存描述、按需要的精度现画、切成瓦片缓存 → 平移几乎不用画;可一件画件要在好几块瓦片里各画一遍,随机数必须带种子。
  4. 瓦片按边界框决定画谁 → 画件画出了自己的框,隔壁瓦片漏画,切口是瓦片的直边。
  5. 档位分得粗 → 换档那一帧从最糊跳到最利;细节分支多抽了随机数,颜色整排换;平均亮度一个都抓不到。