☰
18 个场景共用同一个确定性内核:视觉母题是怎么被组织成代码的
2026/10/16 11:27:58 网站建设 项目流程

18 个场景共用同一个确定性内核:视觉母题是怎么被组织成代码的

【免费下载链接】pdoom-videoCode-rendered music video for "I'm Upping My P(doom)"项目地址: https://gitcode.com/gh_mirrors/pd/pdoom-video

代码生成的视频有一个看似矛盾的目标:一部四分钟的 MV 里挤进了 18 个风格迥异的场景——从火光四溅的熔断线,到布满横格线的办公文档,再到矿石塔与赛博宇宙——但它们必须看起来属于同一个世界。观众不会逐帧比对,但任何色调漂移、任何"这个橙色好像不太对"的违和感,都会立刻拆掉整部片子的幻觉。

pdoom-video 用一套非常工程化的方式回答了这个问题:不是靠设计师反复调色,而是把母题(motif)本身——那颗贯穿全片的橙色"火花"、那张面无表情的"助手面具"、那套单笔画书写字体——以共享代码的形式组织进仓库,让 18 个场景从同一份源头上生长出来。本文以源码为线索,拆解这个"确定性内核"的三层组织方式:调色板硬编码与母题唯一映射、_motifs.ts共享模块与单笔画字体、以及 time 纯函数如何在保证一致性的同时保留每个场景的多样性。

一致性从配色开始:调色板硬编码与母题唯一映射

整部片子的配色约束写在 app/src/engine/palette.ts 开头的注释里:

// The whole video lives in a restrained palette: ink, bone, and one signal colour. // One rare accent (acid, the shrooms moment) — see docs/TREATMENT.md. export const HEX = { ink: '#0A0A0B', // background black (slightly warm) ink2: '#151517', // raised black (panels, paper-in-the-dark) graphite: '#5E5B57', ash: '#9C978F', bone: '#EEE9DF', // paper white, primary text signal: '#FF4D12',// hazard orange: the spark, the fuse, P(doom) ember: '#FF8A3D', // hotter, lighter orange for cores/highlights blood: '#C21D0B', // deep red-orange for shadows of signal acid: '#D8FF3C', // acid: only for the shrooms moment } as const;

这段代码是"母题唯一映射"的落点:signal这只橙色,是唯一被允许作为主信号色出现的高饱和颜色。文档在配色规矩里写得更直白——"Only signal/ember should exceed ~0.85 linear (i.e. glow)"(docs/TREATMENT.md)。于是"橙色"和"危险、火花、P(doom)、正在被唱响的那个词"之间形成了一对一映射:观众只要看到橙色光点,就知道它是什么;看到某个词亮成 signal 色,就知道它是当前演唱时刻。

关键在于这份映射不是靠"约定"维持的,而是靠类型和工具函数强制的。palette.ts把 HEX 同时导出成两种消费形式:

  • LIN:Object.fromEntries把每个 hex 经 sRGB→线性变换转成线性 RGB 三元组,供 GLSL uniform 使用(HDR 目标下 >0.85 会触发 bloom);
  • rgba(key, a):把颜色名变成 CSS rgba 字符串,供 Canvas2D 层使用。

也就是说,一个场景作者想给线条上色,写的是LIN.signal或rgba('signal', 0.8),而不是手敲#FF4D12或#ff4d12——颜色名变成了唯一的语义入口。想越界只能用acid这种被注释点名"only for the shrooms moment"的例外色,而这本身就是一条代码级的审阅规则。

母题即模块:_motifs.ts把"火花"和"面具"变成可复用函数

如果说调色板定义了"世界是什么颜色",那 app/src/scenes/_motifs.ts 就定义了"这个世界里反复出现的角色"。

火花:确定性粒子系统的范本

文件头部的注释点明了它的地位:

// Shared motifs used by several plates so they look identical everywhere: // - the SPARK: orange point with white-hot core, glow, and sputtering particles // - the MASK: the bland "assistant smile" (bone disc, two dots, one curve)

sparkParticles是整颗火花的心脏——一组从火花头溅出的短粒线。它的参数签名已经暗示了这套系统的设计哲学:粒子的出生完全由时间与种子决定,而不是由"上一帧的状态"决定:

export function sparkParticles(lb: LineBatch, t: number, headAt: (t: number) => P2 | null, o: { rate?: number | ((tb: number) => number); rateMax?: number; life?: number; speed?: number; gravity?: number; intensity?: number; seed?: number; width?: number } = {})

实现上它用的是"出生时刻枚举":n0 = floor((t - life) * rate)到n1 = floor(t * rate),每个整数n对应一个在tb = n / rate时刻出生的粒子,速度、寿命、轨迹全部由hash(n, seed...)导出。这意味着同样一个t,无论在浏览器预览里还是在离线导出里、无论子帧以什么顺序求值,得到的粒子集合都完全一致——这正是 docs/ENGINE.md 里"确定性渲染"规则的直接体现。

文件里还有一处堪称教科书级的细节:注释解释了为什么随时间变化的发射率必须以"出生时刻的函数"而不是"当前时刻的函数"传入——

A rate that changes over time is a function of the birth time with its maximumrateMax: particles are then born on the constant rateMax clock and thinned by hash, so each one keeps its identity whatever t it is drawn at (a rate read at the current t would re-time them all from one motion-blur sub-frame to the next).

如果发射率在"当前时刻"读取,那么自适应运动模糊采样出的每个子帧都会重新计算全部粒子的出生时刻,粒子会在子帧之间"换人",画面变成噪声。而在恒定rateMax时钟上出生、再由哈希按rateAt(tb)抽稀,粒子身份在时间上保持稳定,运动模糊才能收敛成一条连续的拖尾。docs/ENGINE.md的采样章节也专门提到了这一约定:"A spark emitter whose rate varies over time passes the rate as a function of the birth time"。

sparkHead则用三层同心段(halo→ember→白热核心)加四条随帧抖动的射线,定义了火花头这个"白热核心 + 橙色光晕"的观感;它还特意为 Canvas2D 场景保留了sparkHead2D径向渐变版本。

面具:一份几何、三种语言

_motifs.ts里更有意思的是"面具"(MASK)——那张由骨色圆盘、两只点状眼和一条弧线微笑构成的"助手式微笑"。它被定义成一份权威几何常量 + 三种渲染入口:

export const MASK = { eyeX: 0.30, eyeY: -0.16, eyeR: 0.075, smileCY: -0.08, smileR: 0.5, smileA0: 25°…155°, smileW: 0.06 };
  • drawMask2D:Canvas2D 绘制,供 2D 层使用;
  • GLSL_MASK:一段模板字符串 GLSL,提供sdMaskInk(p)有向距离函数,供 3D/全屏 shader 使用;
  • 而MASK常量本身则直接内插进 GLSL 源码(模板字符串里拼入MASK.eyeX等数值),保证 CPU 端几何与 GPU 端 SDF 永远同源。

同一张脸,在 Canvas2D 场景、GLSL 场景里长得分毫不差。这就是"母题被组织成代码"的最直观形态:它不是一张贴图,而是一份被多次实例化的几何定义。

单笔画字体:让文字能被"写出来"

另一个贯穿全片的共享资产是 app/src/engine/stroke.ts——单笔画(engraving/plotter)字体系统。它与普通字体最大的区别是:字形不是填充轮廓,而是折线笔画,因此可以被"用笔写出来"。strokeText把字符串排版成{strokes, charRange, total, …},其中每段笔画都带累积弧长:

export function strokeText(text, fontName = 'script', size = 100, tracking = 0, kern = !SCRIPTS.has(fontName)): StrokeText export function drawStrokeText(c, st, len): { x, y, angle } | null // 画前 len px,返回笔尖位置 export function writtenLength(st, charTimes, t): number // 把书写进度同步到演唱时间

三件套拼出了"火花边写歌词边烧过去"的全部逻辑:writtenLength把每个字符的书写区间映射到对应单词的演唱区间,drawStrokeText按长度绘制并返回笔尖位置——而这个笔尖位置,正是sparkHead/sparkParticles的headAt回调。文字书写与粒子发射被同一个函数串了起来。

这套字体甚至自带光学 kerning:字体没有 kerning 表,对于To、Yo、AV这类会留洞的字偶,系统把字形压成左右剖面,计算两字最近距离并补偿字距(pairKern、approach)。连字体缺陷都用确定性算法补上了。

场景是参数化的:同一个母题,18 种姿态

有了共享的母题函数,场景之间的"差异"就变成了参数与编排的差异,而不是"重画一遍"的差异。这是"一致性 + 多样性"能够同时成立的根本原因。

以火花为例,它的调用点遍布几乎所有场景(app/src/scenes/_motifs.ts 的引用者):fuse.ts 里火花沿熔断线前进并随节拍喷溅,hook.ts 里用 Canvas2D 版的sparkHead2D让 P(doom) 数字落地时炸开一颗火花,loss.ts 里火花从深渊坠落,ascent.ts、leftturn.ts、loom.ts、ilya.ts 各以自己的headAt轨迹与{rate, speed, seed, intensity}参数组合出不同的火花性格。

最典型的"一模块多场景"是 hook.ts:同一份Hook类靠params.n在时间线上被实例化四次(I'M / UPPING / MY / P(DOOM)),每次的进出场、爆发方式都不同,但底色、字重、那颗火花完全同源。而在 bureau.ts 中,你能同时看到两个母题的参数化使用:strokeText('We', 'script')签名与strokeText('obsolete', 'hscript')手写体被writtenLen按演唱时间逐笔写出;而那颗橙色火花则出现在"obsolete"被橙色记号笔划掉、文档撕裂落下的段落里。

每个场景类继承 app/src/engine/scene.ts 的Scene基类,只实现render(f: Frame, out),而Frame里携带t、lt、p、beat、beatPhase、a(音频特征快照)等一整套派生量——场景作者几乎不需要访问全局状态。

time 纯函数:确定性内核的最后一块拼图

共享母题解决"长得一样",而 time 纯函数解决"每次渲染都一样"。这是把母题工程化之后、保证"预览即导出"的机制层。

Scene基类的注释写得非常明确:"Everything must be a deterministic function of time"。工程约束在 app/src/engine/util.ts 里有全套配套工具:hash(...)无状态哈希、mulberry32(seed)种子随机数、noise1/2/3、frameIdx(t)(60fps 帧号,用于逐帧抖动但跨运动模糊快门保持恒定)。docs/ENGINE.md的规则是硬性的:"Never useMath.random(),Date.now()orperformance.now()for visuals."

但这不意味着所有场景都必须"逐帧重算"。Scene基类提供了两种状态的出路:

  • 默认场景完全无状态,输出只是f.t的函数,因此可以被自适应运动模糊采样器以任意顺序、任意数量地求值子帧;
  • 需要模拟状态的场景(粒子反馈、物理积分)声明stateful = true,在reset()里复位、用f.dt积分,引擎会在 seek 后快进补齐——代价是这类场景只能用固定--samples导出。

更极致的做法是在init()里预计算整个时间轴。fuse.ts的注释提供了一个很有代表性的例子:回归线被当作吉他弦,由歌手真实音高推动振动,但"Integrated once at init, so frames stay a pure function of t"——物理模拟在初始化时一次性积分成Float32Array,渲染时只是查表插值。同理,bureau 的时间轴重映射、hook 的滚动数字,都是把"状态演化"提前固化成纯函数查询。

最后,时间线本身也是确定性的:app/src/timeline.ts 用cut()把每个镜头切换锚定到歌词行的首词、再吸附到audio.json的节拍网格("Cut on the last beat at/before the first word"),输出的是一张纯数据的时间表。18 个场景的先后、重叠、后处理覆盖,全部由这份数据决定,而非任何运行时的随机决策。

小结:一致性是设计出来的,不是调出来的

回顾这颗确定性内核的三层组织,能看到一条清晰的递进:调色板用类型系统锁死语义映射 → 母题用共享模块消灭重复实现 → time 纯函数让一切可复现。三者合起来,18 个场景就像同一棵树的 18 根枝条——同一份根,不同的姿态。

这也解释了为什么这类"代码渲染"的视频能被工程化地协作开发:母题文件在 docs/ENGINE.md 里被标注为"Read-only for scene agents; ask the lead for changes",各场景文件则是可独立并行编写的模块。当"风格一致性"不再依赖审美直觉的反复对齐,而是沉淀为一份被全员共享、被编译器检查的代码资产时,一部 4K 音乐视频的复杂度,就从"美术品的不可控"变成了"工程系统的可控"。

正如 app/src/scenes/_motifs.ts 文件头那句话:"Shared motifs used by several plates so they look identical everywhere"——母题被组织成代码的意义,正是让"everywhere"成为可验证的事实,而不是一句空话。

【免费下载链接】pdoom-videoCode-rendered music video for "I'm Upping My P(doom)"项目地址: https://gitcode.com/gh_mirrors/pd/pdoom-video

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询