Behind the scenes  /  制作流程

FLATLAND

平 面 国  ·  A Romance of Many Dimensions

一部 100 秒的短片。没有摄像机,没有剪辑软件,也没有手工画过一帧—— 一个 HTML 页面在浏览器里,把一个点长成一条线、一条线展开成一个平面、 一个正方形升格成立方体、再撑开成第四维;无头浏览器把它变成 3000 张画面, 一段 Python 脚本为它谱曲。

100秒成片
3000帧渲染
0剪辑点
38.4秒渲染全片
5幕维度升格
≈2分钟全流程
Scroll ↓
01

流程总览

从一份 JSON 分镜表,到一段有声有色的短片,中间只有六个环节。 全片没有一个剪辑点——维度的一次次升格,是靠几何形变而不是切镜完成的。

剧本timeline.jscam.js
→
场景引擎main.jsworld.jshyper.jsmaterials.js
→
审片render.mjs stillssheet.py
→
逐帧渲染render.mjs6 workers
→
配乐audio.py
→
成片ffmpeg
↳timeline.js同时喂给场景引擎与audio.py —→score.wav↘ 混入 mp4
整条流水线的枢纽是一份 JSON:它同时被浏览器里的场景代码和 Python 配乐脚本读取, 所以画面和音乐永远对得上。这不是巧合,是结构。
02

六个环节

每一环只做一件事,并且能被单独检查和替换。

01 / SCRIPT

剧本

timeline.jscam.js

全片唯一的剧本。字幕、几何标签、维度计数器、遮幅开合、音效命中点,全部是「第几秒发生什么」。 相机也只是一张表:26 行关键帧,定义了一整个连续运镜。

维度计数器是全片的骨架——它把「点→线→面→体→四维」这条线钉死在时间轴上:

// timeline.js · 全片骨架
"dims": [[1.5,0], [10.6,1], [21.6,2], [57.4,3], [73.6,4], [84.2,3], [88.0,2], [90.6,1], [92.6,0]],
"bars": [[57.4, 60.4, "open"], [82.0, 86.0, "close"]],   // 遮幅:升维时打开,终章关闭
"hits": [[10.6,"soft"], [21.6,"soft"], [47.4,"soft"], [57.4,"big"], [73.6,"soft"]]

// cam.js · 一整部影片的运镜 = 26 行
export const CAM = track([
  [30.6, 0.45, 0.25, -3.0,   0.0, EYE, -18,  38],  // 贴地平视,EYE = 0.018
  [45.6, 0.30, EYE, -31.55,  0.10, EYE, -38,  40],  // 穿过正方形家的门
  [63.2, 3.60, 5.60, -27.30, 0.20, 0.90, -34.6, 40], // 向上,而非向北
]);
02 / ENGINE

场景引擎:一切是 t 的纯函数

main.jsworld.jshyper.jsmaterials.jsutil.js

render(t) 是全片唯一的入口:给定任意一秒,确定性地画出一帧。 没有 deltaTime,没有累积状态,没有物理迭代。这一条约束换来三件事,整条管线都建立在它上面。

所有运动都是闭式函数而非积分结果——连手持抖动都是正弦叠加,曲线旋转用解析积分 rampInt() 而非逐帧步进。一旦写成 pos += vel * dt,下面三条全部失效。

// main.js · 唯一的入口
window.render = function (t) {
  const cam = setCamera(t);
  scene.fog.density = FOG(t);
  S.geo = t < 88 ? 1 : 1 - ss(88, 93.5, t) * 0.97;   // 终章:几何让位给光
  ⋮
  updWorld(t, camera, S); updHyper(t, camera, S);
  overlay(t, cam); composer.render();
};

// hyper.js · 立方体沿第四轴撑开,用 4D 透视投到三维
let w = (i & 8 ? 0.5 : -0.5) * ext;          // 第 4 轴
[x, w] = [x*cw - w*sw, x*sw + w*cw];         // 4D 旋转
[z, w] = [z*cz - w*sz, z*sz + w*cz];
const p = K / (K - w);                        // ← 第四维的透视除法
x *= p; y *= p; z *= p;
03 / REVIEW

审片工具

render.mjs stillssheet.py?t=?from=

预览即成品。因为 render(t) 无状态,同一个页面既能实时播放, 也能只渲染某一帧调参,还能逐帧截图——三者用的是同一份代码,不存在"预览和成片不一致"。

sheet.py 把静帧拼成联系表,几秒钟看完整片节奏;改分镜后先跑它, 比渲整片快两个数量级。

# 浏览器里预览
index.html              → 循环播放整片
index.html?from=57      → 从第 57 秒开始循环
index.html?t=57.4       → 只渲染这一帧,静止(调参用)
index.html?capture=1    → 不自动播放,静候外部调用 window.render(t)

# 抓 27 张代表性静帧并拼成审片表
npm run stills && npm run sheet
04 / RENDER

无头渲染:无锁并行

render.mjsplaywright-core6 workers

因为帧与帧完全独立,6 个 Chrome 页面共享一个自增游标就够了,不需要任何锁或同步—— 靠的是 JS 单线程:next++ 不会重号。

每帧连等两个 requestAnimationFrame 再截图:第一次等 GPU 处理完, 第二次保证合成器已经把 canvas 交换进合成层。少了第二次,截到的可能是上一帧。

// render.mjs · 无锁游标
let next = f0;
await Promise.all(Array.from({length: workers}, async () => {
  const p = await newPage();
  while (next < f1) {
    const f = next++;                       // ← 唯一的同步原语
    await shot(p, f / fps, `frames/f${String(f).padStart(5,'0')}.jpg`);
  }
}));

// 每个 worker 的截图
await p.evaluate(t => { window.render(t); return new Promise(r =>
  requestAnimationFrame(() => requestAnimationFrame(r))); }, t);
await p.screenshot({ type: 'jpeg', quality: 95 });

// 3000/3000 frames · 38s · eta 0s
05 / SCORE

程序化配乐

audio.pynumpyscipyFFT 卷积混响

配乐同样是数据驱动的:audio.py 用正则把 timeline.js 读成 JSON,于是画面和声音共享同一条时间轴。10 段交叉淡化的和弦 pad、升维时的钟声、 贴地滑行时的风、四维段落刻意加大的失谐——全部由这份数据生成。

混响不做逐样本卷积,而是走 FFT:噪声脉冲响应与信号在频域相乘,快几个数量级。

# audio.py · 与画面共用同一份剧本
TL = json.loads(re.search(r"window\.TL\s*=\s*(\{.*\});",
                          open("timeline.js").read(), re.S).group(1))

# 第四维度的拍频:失谐量更大
detune = 1.003 if a == 73.4 else 1.0012

# FFT 卷积混响
out[ch] += np.fft.irfft(np.fft.rfft(wet[ch], size) * np.fft.rfft(ir, size), size)[:N] * 0.6
06 / MASTER

合成成片

ffmpegH.264CRF 21

最后一步没有魔法:3000 张 jpg 与 19 MB 的 wav 交给 ffmpeg,编码出 100 秒的正片。

整套流水线的好处是——改动任意一秒的剧本数据,重跑「配乐 + 渲染」两个脚本, 就能得到一支新的片子。没有需要重新剪辑的素材,也没有会走样的版本。

ffmpeg -framerate 30 -i frames/f%05d.jpg -i score.wav \
       -c:v libx264 -pix_fmt yuv420p -crf 21 -preset medium \
       -c:a aac -b:a 192k -shortest flatland.mp4
03

关键时刻

下面是影片本体的实时渲染——不是截图,是拖动时当场由 render(t) 求值出来的画面。 拖到哪一秒,它就现算哪一帧。

实时渲染需要 http 环境 · 请在本地服务下打开
t = 13.0s
渲染方式 —
13.0s 线 国 · 一维 点分裂成线。整个世界只有前进和后退。
拖动 / 点击刻度 / ← → 切换
04

实测数据

以下全部为实机测量,非估算。测试机 Apple M2 Pro / macOS / Chrome ANGLE Metal。

指标实测
整片 3000 帧渲染(6 workers)38.4 s  ≈78 fps 聚合
单帧 · 热渲染52 – 69 ms
单帧 · 冷启动(编译全部着色器 + 建世界)2 125 ms
配乐合成 audio.py5.5 s
全流程:渲染 + 配乐 + 封装≈ 2 分钟
中间帧体积(3000 张 JPEG)1.2 GB  ≈400 KB/帧
成片编码(CRF 21)≈150 MB  12 Mbps
音轨可复现性SHA-256 一致  8d2d2222…e974
引擎代码量1 251 行  12 个源文件

并发度不是越多越好

300 帧切片,同一段画面,只改 worker 数:

2 workers  9.7 s 4 workers  11.3 s 6 workers  7.8 s ← 默认 8 workers  8.4 s 12 workers  18.5 s

瓶颈不在 GPU——同一段画面,无论亮部还是极暗,耗时几乎相同。 限制因素是每帧两次 requestAnimationFrame 的节流(60 Hz 下约 33 ms 下限) 与 JPEG 截图编码,两者都在 CPU 侧。 12 路并发反而慢 2.4 倍:超额订阅的典型代价。