# 没有素材，也没有剪辑：用 WebGL2 逐帧生成一支初音未来 MV

> 作者: Lazybones | 日期: 2026-10-22

# 没有素材，也没有剪辑：用 WebGL2 逐帧生成一支初音未来 MV

> 这支片子叫《残像 / AFTERIMAGE》，把 cosMo@暴走P 的《初音ミクの消失》做成一支完全由代码生成的音乐短片。没有素材库，没有时间线 GUI，没有后期软件。每一帧都是浏览器里 WebGL2 + Canvas2D 现场算出来的，而且只是歌曲时间的函数：`render(t)`。

![诞生 · HEAD-01 打印舱](https://raw.githubusercontent.com/LiaoZiqi-GZFLS/MikuDisappear/master/docs/preview/birth.webp)

## 为什么把“可复现”放在第一位

用剪辑软件做 MV，时间线是隐藏状态：工程文件里有什么、渲染器怎么解释它、这一版和上一版差在哪，都不容易用机器验证。代码生成的思路反过来：把画面定义成一个纯函数。

```js
render(t) -> frame
```

`t` 是歌曲时间，返回值是这一帧。没有隐藏的“当前状态”，没有“上一次播放到哪”。同一个 `t`，今天和明天必须得到同一帧。这个约束带来三个直接好处：

1. 可以离线导出任意时间点、任意片段，不需要从头播放；
2. 改动之后可以对同一 `t` 做逐像素 diff，明确知道“爆炸半径”；
3. 预览和成片走同一份代码，看到的就是导出的。

这也意味着，创作过程不是“在时间线上拖素材”，而是“写一个随时间变化的程序”。镜头的推拉、歌词的点亮、粒子的运动、转场，最后都要落到某个 `f(t)` 上。

## 一支 MV 的代码结构

项目没有框架，页面本身零依赖，原生 WebGL2 + Canvas2D。核心文件可以按“数据 → 时间 → 画面”分成三层：

```text
tools/analyze.py      离线分析：节拍、频段包络、事件、真实波形、频谱、歌词打轴
data/analysis.json    240 BPM、0.25s 网格、逐拍重音、kick/snare/hat/vox 事件、章节
data/env.bin          7 条包络 @100fps
data/wave.bin         真实波形极值对 @200fps
data/spec.bin         64 频带频谱 @100fps
data/lyrics.json      LRC 解析 + 逐字时间对齐

src/engine.js         时间轴、场景生命周期、join 交接窗口、帧对象
src/timeline.js       剪辑表：每一章用哪一场、怎么进场、切点落在哪句
src/camera.js         导演层：分镜表 + 虚拟摄影机
src/scenes/*.js       18 个章节，各自一套视觉母题
src/figure.js         歌姬与听众：无脸剪影
src/gl.js             WebGL2 最小层：着色器、渲染目标、泛光、四种转场
src/gl3d.js           3D 布景层：一实例一次变换，带“空气”的距离衰减
src/kit.js            共用视觉词汇：仪表框、刻度尺、波形、频谱、字墙
```

`engine.js` 每帧组装一个帧对象：当前时间、音频包络、节拍、当前歌词、章节。场景只读这个对象，不自己持有时间。这样场景就可以被单独抽出来，用任意 `t` 渲染、截图、对比。

## 音乐不是“听感觉”，是测出来的

节拍不是估的。对全曲做圆统计拟合，8 个 32 秒窗口全部收敛到 **240.00 BPM / 0.25 秒网格**；歌词行起点与节拍网格的中位偏差是 **0.001 秒**。

`analysis.json` 里存的是逐拍重音、kick/snare/hat/vox 事件和章节边界；`wave.bin` 存真实波形极值，所以示波器画的是真音频；`spec.bin` 存 64 频带频谱，所以瀑布图不是装饰。歌词则被解析成逐字时间，每个字的点亮时间来自人声起音，而不是手动对轴。

这一步的意义在于：后面所有“画面跟着音乐走”的设计，都不需要靠肉眼对齐。场景可以从 `beatAt(frame, t)` 拿到当前句子的语义，再决定这一拍是让心消失、让葱出现，还是让镜子递归。

## 她永远没有脸

全片最重要的形式约束只有一条：**她永远没有脸**。

没有眼睛、鼻子、嘴，面部永远是一块暗面。她的身份完全由剪影承担：约身高 1.8 倍的双马尾、方形发饰、耳机麦横杆、领带和蓝色镶边、分离式长手套、分层短裙与过膝靴。左臂上有一个 `01`，只在足够大的景别里出现。她身上的光来自别处——屏幕、扫描光、观众席——她是被照亮的，不是自己发光的。

需要歌唱的时候，暗面里只亮起喉咙深处的一点光。声音有身体，但没有五官。

这个选择不是技术限制，而是叙事选择。它让“删除”这件事落在形状上，而不是一张脸上：在 `error` 一章里，她脱落成 0 与 1，被丢进回收站；观众看到的不是“一张脸消失了”，而是一个轮廓被拆掉。到了片尾字幕，观众本人被写进演职员表：`观众 — 你`。

运动也是歌曲时间的函数：4.2 秒一次呼吸、11 秒一次重心偏移。场景把 `t` 传给 `singer()`，小人才会呼吸和摆动；不传，她就保持一个静止的姿态。侵蚀、裂纹、心、合十、伸手、挥手都是姿态参数，不是五官。

![终演 · 应援灯海](https://raw.githubusercontent.com/LiaoZiqi-GZFLS/MikuDisappear/master/docs/preview/final.webp)

## 镜头跟乐句，不跟鼓点

240 BPM 下，如果每个鼓点都缩放，等于每秒四次频闪。这支片子的画面是给人**读**的：歌词、波形、频谱、计数器。所以镜头不跟节拍走，而是跟**乐句**走。

每个镜头只有一次缓慢、单调的推入、拉开或横移，横跨一整句话：整章位移控制在 45–90 像素，变焦不超过 7.5%，逐帧位移保持在个位数像素。方向只在换句、换气、换段的时候换。全片唯一的“快”是三次撞击：标题、两次紧急停止，各自只有一个 0.22 秒衰减的脉冲加一次抖动，随后立刻冻结。

`overdrive` 是唯一的例外：镜头跟着字墙横移，并在每句歌词开始时轻微重构构图（≤9 像素 / 0.7% 变焦），让剪辑和歌词一起呼吸，而不是和鼓点一起抽搐。

这套预算不是写在文档里就算数，而是有回归闸门：

```bash
node tools/camera-report.mjs --check
# 逐帧跳变超预算即返回非 0
```

## 18 章，几个具体的空间

片长大约 4 分 55 秒，分成 18 章。很多数字是写进代码的，不是画面上的装饰：

- `birth`：13.20 米高的 HEAD-01 打印舱，用 1.70 秒把她从头顶印到脚边，48 格记录写满；
- `compress`：96 米压缩舱，两台 36 米铸件合到只剩 5 厘米，24 个音符被逐个压灭，压力和码率同时从 320 掉到 32；
- `final`：446 只手把整章 21.6 秒的声音举成 235 列声纹；只有镜头停住的那个机位能读出来，横向偏出 0.50 米就散成 216 列折叠的人群；
- `lastwords`：343 m/s 的脉冲以 ×9 慢放扫过 59.208 米，32 排结构按真实距离依次应答；空场 RT60 3.20 秒，在 −31.99 dB 处仍在响；
- `afterimage`：400 份记录、399 个红叉，只有她那格是 `HELD`；歌停之后的 8.6 秒（60 fps）全在这里。

这些数字让场景有了可验证的尺度：镜头移动多少米、光锥张开多少、声波走多久，都能在代码里对上。它们不是“看起来差不多”，而是可以被探针读出来的。

![冷存储 · 观众一栏](https://raw.githubusercontent.com/LiaoZiqi-GZFLS/MikuDisappear/master/docs/preview/afterimage.webp)

## 导出：瓶颈不在编码器

同一个 `renderFrame(t)` 有两条出口：

| 管线 | 路径 | 实测（RTX 5070 Ti / 1080p30） |
|---|---|---|
| `webcodecs`（默认） | canvas → VideoFrame → 硬件编码器 → Annex-B → ffmpeg `-c:v copy` | 约 6 ms/帧、160+ fps |
| `png` | canvas → PNG → base64 → ffmpeg 解码 → x264/NVENC | 约 90 ms/帧、7 fps |

差距接近 17 倍。直觉会以为瓶颈在视频编码器，但实测不是：`png` 路径里 ffmpeg 只占 300 帧里的 1 秒，86 ms/帧都花在浏览器的 PNG 编码上。所以“给 ffmpeg 加 `-c:v h264_nvenc`”只快 15%，而把编码搬进页面快 17 倍。

这是一条很通用的性能经验：**先测时间花在哪，再决定优化谁。** 编码器不是默认瓶颈，浏览器里的像素回读才是。

两条管线的画面完全相同，差别只在“一帧怎么离开页面”。如果要求比特级可复现，用 `--pipeline png --encoder libx264`；如果要速度，用默认的 WebCodecs。硬件码流与驱动相关，这一点必须说清楚。

## 改完必须过三道审计

代码生成最大的风险不是“写不出来”，而是“改一处，坏一片”。所以每次集中改动之后，都要过三道审计：

```bash
node tools/semantics.mjs --quiet        # 94/94 行歌词都有画面映射
node tools/camera-report.mjs --check    # 运镜预算
node tools/shots.mjs --sheet 16 ...     # 抽样渲染，无浏览器错误
python tools/imgdiff.py out/qa-a out/qa-b  # 两次渲染逐像素差
```

`semantics.mjs` 会检查 94 行歌词是否都有语义 beat 和视觉母题，当前是 94/94、65 个母题、0 个未使用 beat。`camera-report` 检查位移、变焦、侧倾和逐帧跳变。`shots` 抽帧看渲染有没有报错。`imgdiff` 则回答“这次改动到底影响了哪些帧”。

这套审计把“完成”的定义从“我觉得可以了”变成“命令返回 0”。对创意编程来说，这一点尤其重要：画面是主观的，但回归必须是客观的。

## 版权边界

这类项目很容易被误解成“代码即全部”。实际上：

- 代码以 AGPL-3.0 发布；
- 歌曲、歌词、音频版权归原作者，仓库不包含 mp3/lrc；
- 部分参考图和 ASCII 数据授权未确认，不随仓库分发；
- 没有音频时页面仍可播放，画面走静音时钟。

也就是说，**代码是作品的一部分，音乐是另一部分。** 想复现画面，需要自备音频；想复用代码，需要遵守 AGPL。这个边界不模糊，反而让项目更容易被安全地分享。

## 小结

“没有素材，也没有剪辑”不等于没有工艺。工艺被挪到了三个地方：

1. **时间**：画面是 `render(t)`，所以可复现、可 diff、可离线导出；
2. **约束**：无脸、跟乐句不跟鼓点、镜头预算，都是主动选择的限制；
3. **审计**：歌词覆盖、运镜预算、抽帧、逐像素 diff，让改动有客观的回归标准。

这支片子最终是一段代码，也是一条时间轴。它证明了一件小事：当你把画面写成程序，创作的自由度不会变少，只是从“拖时间线”变成了“设计规则”。

完整代码、镜头表、制作日志和 4 张预览静帧在 [MikuDisappear](https://github.com/LiaoZiqi-GZFLS/MikuDisappear)。如果你只想看片，仓库里也提供了零依赖的本地服务器和导出脚本。