返回博客

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

Lazybones Follow 0 2026年10月23日 前端开发 30 次阅读

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

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

诞生 · HEAD-01 打印舱

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

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

render(t) -> frame

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

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

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

一支 MV 的代码结构

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

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(),小人才会呼吸和摆动;不传,她就保持一个静止的姿态。侵蚀、裂纹、心、合十、伸手、挥手都是姿态参数,不是五官。

终演 · 应援灯海

镜头跟乐句,不跟鼓点

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

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

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

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

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)全在这里。

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

冷存储 · 观众一栏

导出:瓶颈不在编码器

同一个 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。硬件码流与驱动相关,这一点必须说清楚。

改完必须过三道审计

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

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。如果你只想看片,仓库里也提供了零依赖的本地服务器和导出脚本。

评论 (0)

暂无评论,来说点什么吧。

登录 后可以发表评论