paint-mv-animate
Paints one chapter (src/ch/cNN_name.js) of a paint-mv music video in the PDoomVideo style: watercolour-and-ink frames drawn with p5.brush, the song's own characters (designed from its lyrics) acting on the beat, camera moves and motivated transitions, each frame a pure function o
Install
npx skills add https://github.com/lintsinghua/paint-mv-skills/tree/main/skills/paint-mv-animate
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lintsinghua-paint-mv-skills@llmmart
git clone https://github.com/lintsinghua/paint-mv-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lintsinghua/paint-mv-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
章节作画规范(paint-mv-animate)
项目把一首歌画成手绘水彩动画:p5.brush 在 headless Chrome 里离线逐帧绘制,所以质量优先于速度,但有预算。
镜头清单在项目的 STORYBOARD.md。画风照原作,内容照歌词:角色、场景、道具是这首歌的分镜从歌词里设计出来的;
画法、动作、镜头语言和下面的规则与原作一致。创作方向(源自原作):可爱、卡通、色彩欢快、动画活泼、每个镜头都有事发生,
大胆、有野心。
开工前按顺序读:
- 项目
STORYBOARD.md:开头的"歌曲信息"、角色表、你的章节表格,以及前后相邻章节的"出"列(Out,衔接要对上)。 - 项目
src/song.js(BPM、首拍offset、时长、wipes、meter)和src/lyrics.js(每句歌词的起止时间)。 - 项目
src/characters.js(本片主要角色)和src/sets.js(共享场景/道具),并看角色模型表node render.mjs --loop=cast --sheet=0,1,2,3,4,5 --cols=3 --out=out/check/cast.jpg。 - 本文件。需要具体签名时查 api.md;要新建客串角色时读 characters.md;想找某种镜头/转场/表演的
现成做法查 techniques.md,它会指到
examples/里原作九个章节的具体函数(原作 P(doom) MV 的完整源码, 画风与难度的标杆;它的角色和场景属于那首歌,学做法,不搬内容)。
章节文件怎么工作
每章一个文件 src/ch/cNN_name.js,包在 IIFE 里,私有 helper 随便起名不会冲突:
// src/ch/c03_takeoff.js
(() => {
const B = n => OFF + n * BEAT; // 第 n 拍的歌曲时间
const gym = t => { ... }; // 私有 helper
function stable(t, lt, dur) { ... } // 一个镜头
function singularity(t, lt, dur) { ... }
chapter('takeoff', 38.5, 59.0, [[38.5, stable], [41.5, singularity], ...]);
CAST.sydney = (x, y, s, t, o = {}) => { ... }; // 只有终场要复用的客串角色才导出
})();
chapter(name, start, end, shots)注册章节。镜头函数以fn(t, lt, dur)调用(歌曲时间、镜头内时间、镜头长度), 必须画出整帧(包括背景)。切镜发生在每个镜头的起始时间;第一个镜头的起始时间必须等于章节start。 最后一章的end写DUR + 1,保证最后一帧也有章节覆盖。- **帧是并行、乱序渲染的。**每个镜头必须是
t的纯函数:不能有跨帧状态,不能用Math.random()。 稳定的逐物体随机用hash(i),手绘抖动用jit(a)。jit每秒重播种 12 次,线条会像手绘动画一样"沸腾",这是想要的效果。 - **只改你自己的章节文件。**缺共享 helper 就在 IIFE 里私有实现一个。发现共享文件真有 bug,报告给总控,不要改。
共享文件:
core.js、clawd.js、cast.js、props.js、timeline.js、lyrics.js、song.js、characters.js、sets.js、studio.html、render.mjs。
画布与布局
- 1920×1080,y 向下,原点左上。没开相机时一切都在这个空间里画。
- **有歌词时,卡拉 OK 条盖住底部 y≈975–1070。**脸和关键动作放在 y≈960 以上。
- 每帧底下已经铺好纸纹,最上面会乘一层纸颗粒和暗角。
- 时间线自动叠加:卡拉 OK、
SONG.wipes处的笔刷擦除转场、SONG.meter窗口里角落的小计量器 (镜头里画了meterProp时自动隐藏)。
绘画 API(core.js)
paint(pts, o) 用点列 [[x, y], ...] 画一个形状:
| 选项 | 含义 |
|---|---|
wash, washOp |
平涂色(0–255)。角色颜色和必须实在可读的东西用它。 |
fill, fillOp, bleed, tex, border |
水彩填充,边缘洇开、有颜料肌理。背景、光晕、阴影、光斑用它。bleed ~.05–.3,tex ~.3–.9,border ~.2–.8。 |
hatch: { d, a, o, b, c, w } |
排线(间距、角度、{rand, gradient}、笔刷如 'charcoal'/'HB'、颜色、粗细)。干笔肌理,少用。 |
ink, sw, br |
描边颜色(默认墨色)、粗细(~.4–2)、笔刷(默认 'ink',或 'inkfine')。ink: null 表示不描边。 |
curv |
让轮廓平滑穿过各点(0–1),而不是直线段。 |
其他:
- 几何:
rectPts(x, y, w, h, jitter)、ellPts(cx, cy, rx, ry, n, jitter, rot)、rrPts(x, y, w, h, r, jitter)(圆角矩形)、starPts(cx, cy, r, inner, n, rot)、heartPts(cx, cy, r)。 - 线:
inkLine(pts, sw, colour, brush = 'ink', curvature)。笔刷:'ink'、'inkfine'、'dry'(毛糙), 以及内置'2B'、'HB'、'charcoal'、'marker'、'spray'、'rotring'、'cpencil'、'pen'。 - **变换:**p5 的
push()/pop()/translate()/rotate()/scale()对所有笔刷调用都生效。 - 调色板
PAL:paper, ink, clay, clayDk, clayLt, night, indigo, rose, ochre, sap, teal, violet, cream, sky。mixCol(a, b, k)混合两个十六进制色。任何十六进制色都可以,但保持和谐(柔和、温暖、水彩感)。 互补色直接混合(夜蓝渐变到晨桃)中途会发灰,中间加一个过渡色分两段混(夜蓝 → 紫玫瑰 → 晨桃)。 不要纯黑纯白:用PAL.ink/PAL.night和PAL.cream。 - 计时:
bpOf(t)给出拍位置(BPM 见song.js;拍长BEAT,小节 4 拍)。另有:beatN(t)整数拍号;pulse(t, k)每拍为 1 然后衰减,pulse2八分音符版,用来做打点。seg(t, a, b)是 t 在 [a, b] 里的 0..1 进度;kf(t, [[t0, v0], [t1, v1], ...], easeFn)关键帧插值,值可以是数组。- 缓动:
ease(smoothstep)、easeOut、easeIn、backOut(过冲)、elasticOut。还有lerp、clamp、frac、wob(t, freq, phase)、hash(i)、TAU。
- 相机:
camBegin(cx, cy, zoom, rot)把世界点 (cx, cy) 放到屏幕中心;camEnd()恢复。推、摇、俯仰、甩、拉远都用它。shakeXY(t, amount)给出 [dx, dy] 震动,打点时加到 cx/cy 上。只能一层,务必配对camEnd()。 - 字(Permanent Marker,带墨色投影)。字会自动跟随当前相机,但不跟随你自己的
push/translate,所以字的坐标给世界坐标。letter(txt, x, y, size, colour, { pop, rot, alpha, ink:false, stroke, font, align, screen:true }),pop是 0..1 带过冲的出现进度。sfx(txt, x, y, size, colour, age, { life, rot })漫画拟声字:弹出、晃动、淡出。- 字在
flushLetters()时合成进画面。它在镜头之后自动调用,所以之后画的东西(擦除转场)会盖住字。 需要让后面的颜料盖住某个字时,镜头中途自己调用flushLetters()。
- 全屏效果(屏幕空间,在相机之外调用):
flash(k, colour)全屏平涂强度 k;iris(cx, cy, r, colour)圆外全涂;irisShape(pts, colour)任意星形轮廓外全涂(嘴形揭幕、心形、钥匙孔)。
角色
本片的角色由分镜的角色表决定,是从歌词设计出来的:
- 主要角色在
src/characters.js(共享,只读;缺表情、姿势或钩子就报告给总控)。先读它的头部注释,再看模型表。 - 客串由首次登场的章节在自己的 IIFE 里实现,导出到
CAST(签名(x, y, s, t, o),要参加谢幕的支持o.bow0..1); 之后要用它的章节调用CAST.name并写一个简化的后备画法。按原作画风设计和实现角色的方法见 characters.md。 - 群演是主角的变体:换
col/dk/lt、戴帽子、缩小。
所有角色遵守同一套约定(原作 Clawd 和研究员的约定),所以下面这些工具对谁都通用:
name(x, y, u, o):(x, y) 是两脚之间的地面点;u是尺寸单位(各角色身高几 u 写在它的头部注释里)。- 姿态:
dy(单位 u,负为上)、sq(压扁;负为拉长)、rot、flip、sx/sy、aL/aR(手臂角:0 平伸,正为抬起,负为下垂)、walk(相位)、noShadow。 - 脸:
eyes:normal, look(+lookX/lookY−1..1), happy, closed, wink, narrow, angry, scared, spark, heart, x, swirl, dot… (角色画不出的眼型会退回 normal);mouth;blush;颜色col/dk/lt。 - 钩子:
draw(u, sw)在身体局部坐标画配饰;手臂末端的armL/armR(u, sw)(研究员是handL/handR)在手臂坐标系 (+x 沿手臂向外)里被调用,用来拿道具。 - 表情符号:
emote+emoteK在头边弹出反应符号:sweat, spark, heart, anger, music, swirl, zzz, !, ?, !?, !!。 - **情绪切换:**脸绝不硬切。用
mood(t, [[t0, 'normal'], [t1, 'scared', 'sweat'], [t2, 'happy', 'heart']]), 返回{ eyes, squint, take, emote, emoteK },展开进角色就得到"闭眼-压扁-弹出"的切换。 - 跳舞:
move(style, t, seed)返回与节拍同步的姿态偏移:bounce, hop, roof(举手), sway, spin, wave, walk, run, idle, stomp, shimmy, mix。dx以 u 为单位:const m = move('bounce', t); hero(x + m.dx * u, y, u, { ...m, ...mood(t, keys) })。 - 尺寸参考(按身高约 8u 的角色):小 u≈6–10,正常 u≈16–22,主角/特写 u≈30–60。副歌和舞蹈镜头里领舞的主角要大(约占画面高度 40%)。
引擎自带的参考角色:原作那首歌的演员,分镜用到时才出现;设计新角色时照它们的结构写。
- Clawd
clawd(x, y, u, o):方块身体 10u × 6u,连腿 8u 高;另有帽子hat(party, hard, crown, halo, wizard, hood, top, fedora, band, sweatband, cat, masq, mask, bowtie)、便当盒嘴lid、noLegs;dancer(x, y, u, style, t, extra)=clawd+move。 - 研究员
researcher(x, y, s, o):白大褂、圆眼镜的小个子人类,约 13.2s 高;自己的眼型 dot, wide, star, swirl, closed, sad, x, heart, look, 外加brows、hairUp、glassesTilt、sit、back、spin;mood()用在它身上要映射眼型名(techniques.md「表演」)。 - 完整选项见 api.md。
场景与道具
- 本片反复出现的场景和道具在
src/sets.js(共享,只读);只在本章出现的场景画在本章里。 - 画在场景里的字(招牌、片名、墙上的字)写完
letter()就调用flushLetters(),否则字会浮在之后画的角色上面(c01curtainUp)。 - 引擎自带原作的剧场道具(
props.js),分镜用到时才用:stageBack(t, o)/stageFront(t, o)(舞台后景与木地板 / 两侧幕布与垂幔,o.curtain合幕、o.alarm警报色、o.spots聚光灯)、meterProp(x, y, s, pdoomAt(t), o)(计量器,配合SONG.meter)、pumpProp+pumpH(t)(下压落在每拍上的打气筒)、sunburst、spotlight。它们也是写新场景的范例:背景画得比画面大 (相机移动和震动不露边)、后景在角色之前画、前景框在最后画。
风格规则
- **观感:**手绘水彩加墨线,像绘本。角色用平涂
wash加墨线轮廓(sw 按大小约 0.8–1.6)。背景用柔和的水彩fill, 通常不描边或细描边。光晕和光线用低不透明度的 fill。肌理来自 fill 和偶尔的排线,而不是噪点。 - **颜色:**每章的调色在 STORYBOARD.md 里。欢快饱和但柔和。角色与背景的对比必须清楚。
- **运动:**一切都在动:相机漂移或推进,角色随拍弹跳(
pulse、move),打点落在拍上。用压扁拉伸、预备动作和过冲 (backOut、elasticOut)。镜头里的重要动作放在拍点上(拍点在OFF + n × BEAT;章节里写const B = n => OFF + n * BEAT)。 - **可读性:**每个镜头一个清晰的焦点动作,轮廓要大。镜头很短(1.4–4 s),笑点必须一眼看懂。
- **少字:**拟声字全片只有少数几个大的(FOOM、BOOM、CHOMP、SLAM…),偶尔一个短词或符号。不要标签、说明文字、 重复歌词的牌子(卡拉 OK 已经有了)。
- **性能:**目标每帧 ≤ 2.5 s,绝不超过约 4 s。渲染日志会打印 ms/frame。开销来自 fill 形状和笔画的数量:几百个没问题,
几千个不行。宁可更少、更大的形状。水彩
fill的开销随顶点数增长,大形状用wash,fill 保持低多边形。
检查你的作品
在项目根目录运行(渲染器用真 GPU;多个代理可以同时渲染):
node render.mjs --sheet=38.6,39.3,40.1,40.9,41.6,42.3 --cols=3 --w=640 --out=out/check/c03_a.jpg
node render.mjs --stills=39.5,43.2 --out=out/check/c03_full
联系表(sheet)把几个时刻拼成一张图(并打印每帧 ms),用 Read 工具打开,仔细看。检查:
- 每个镜头的首帧、末帧和中间几帧;
- 连续时刻的运动是否读得通(例如打点前后每 0.1 s 一帧);
- 进出本章的转场(与相邻章节的"出"和开场对上);
- 没有重要内容落在卡拉 OK 带下面;
- 页面报错:渲染日志里的
[page error]表示你的代码抛了异常(该帧会画坏)。
反复迭代,直到每个镜头都好看:有魅力、可读、有生气、造型一致。看着不对就改:比例、对比、杂乱、僵硬。
交付时汇报
完成后简短汇报:每个镜头一句话说明画了什么;检查过的联系表路径;最慢帧的 ms;导出的 CAST 角色及其签名;
与相邻章节的衔接状态(本章首帧/末帧是什么);发现的共享文件问题(只报告,不修改)。
Files (paint-mv-skills)
-
examples
-
README.md 5.8 KB
# 原作章节(PDoomVideo,《I'm Upping My P(doom)》,156.6 s,88 BPM,首拍 0.21 s) 原作的九个章节文件,是这套引擎下的参考实现。本仓库不包含它们:`paint-mv/scripts/fetch_upstream.mjs`(`new_project.mjs` 第一次运行时自动调用)会从 PDoomVideo 按固定提交拉下来放到这里。文件很长(500–900 行),按需搜函数名读,不要整篇通读。 原作分镜见 `paint-mv-storyboard/pdoom-storyboard.md`(每个镜头的设计意图都在那里)。 角色、场景和梗都属于那首歌;学的是画法、镜头、转场和表演。几个客串(shoggoth、Sydney、basilisk、Gato、chinchilla) 正好示范了怎样把歌词里的梗设计成角色,见 `../characters.md` 第 1 节。 | 文件 | 时间 · 调色 | 镜头函数(按顺序)与看点 | |---|---|---| | `c01_lab.js` | 0–23 · 夜靛蓝、台灯赭黄、显示器青 | `curtainUp` 幕布拉开、标题画在背景布上、活板门弹出 · `labOver` 过肩推镜 + 前景视差、睡醒的 mood · `sparks` 眼睛变星星、按拍的烟花 · `reaction` 电路藤蔓生长(`partial`)、椅子按拍后退(`kf`)· `shrug` 眨眼、图表弹出、钻进图表 · `lossRide` 沿函数曲线滑雪、悬崖边晃、坠落溅水 · `burst` 显示器鼓胀、碎玻璃、冲出屏幕并长大 · `boss` 老板椅转身揭晓、马克杯按拍堆高、便当盒嘴吱呀张开 · `chase` 史酷比式三门追逐(按拍)、破门 · `chomp` 大嘴合上变黑 + CHOMP | | `c02_chorus1.js` | 23–38.5 · 玫瑰与赭黄放射光 | `upping` 嘴形遮罩揭幕、骑打气筒、计量器、合唱队、气团沿软管 · `foom` 火箭点火、FOOM、跟着俯仰上天 · `chineseRoom` 撞进纸屋、纸条沿贝塞尔轨迹传递、规则书翻页 · `shrooms` 彩虹隧道、蘑菇按拍弹跳、漩涡吸入、笑脸诞生 · `shoggothShot` 面具滑落、跃起扯掉、光圈收进眼睛 · `shinigami` 红黑、速度楔、甩镜、倒计时、弹跳苹果 · `danceBreak` 波浪式转圈、机器人舞(八分音符)、彩纸爆发。导出 `CAST.shoggoth`;`miniClawd` 省性能 | | `c03_takeoff.js` | 38.5–59 · 晨空蓝 → 速度 → 玫瑰粉 | `gymShot` 跑步机、写字板/秒表手持道具、推到旋钮 · `dialShot` 旋钮被撞、表盘弹性打满、警报 · `holeShot` 黑洞沿螺线吸走道具、研究员像旗子一样飘、钻进黑洞 · `rideShot` 横向视差、等高线山丘、超车火车和喷气机、每拍长大、甩镜 · `atomsShot` 研究员散成点 → 拼成回形针 → 复原 · `assembleShot` 房间逐条拼装、心形笼子落下 · `sydneyShot` 按拍抱笼 · `ringShot` 戒指盒、从栏杆间挤出 · `bubbleShot` 心形泡泡长到盖满画面。导出 `CAST.sydney` | | `c04_chorus2.js` | 59–73 · 竞技场,转太空紫与金 | `shotPump` 仰拍楼高 Clawd 双打气筒、按拍焰火、观众、戳破泡泡 · `shotBasilisk` 地面隆起、BOOM、木板飞溅、GPU 投喂节奏、蛇怪咀嚼 · `shotMoon` 沿股价样条冲上月球、插旗 · `shotOmega` 星系螺旋汇聚成一点、白光 · `shotGPU` 里程表进位(`letter` 滚轮)、确定性弹跳"零"、计量器叮 · `shotVault` 3D 环绕揭晓:金库没有后墙。导出 `CAST.basilisk` | | `c05_obsolete.js` | 73–95.4 · 羊皮纸博物馆、赭黄公路 | `mlp` 舞者就是神经网络、前向/反向脉冲、相机跟着脉冲、指挥被电 · `museum` 老机器熄灭、防尘布沿弧线盖上、蜘蛛弹落 · `road` 弯路样条、正面卡丁车、急弯甩镜、刹车痕、研究员被甩出 · `cdr` 睡着的云朵保安、探照灯下垂、原地甜甜圈、喇叭 · `cliff` 抓住、每拍松一根手指、激光点、扑 · `fall` 俯视深渊环、研究员缩小、眼镜飞起、光圈收成夜色。导出 `CAST.gato`;`tube`/`spline`/`along`/`cPt` 工具 | | `c06_chorus3.js` | 95.4–109.4 · 钢灰,转爵士蓝 | `plop` 从顶棚坠进回形针堆、机器按拍吐回形针 · `flood` 回形针海上涨、卷浪、冲浪 · `killswitch` 空转的椅子、按钮、回形针风滚草 · `beach` 度假的两位无视震动的手机 · `planetShot` 手动相机拉远到回形针星球、导火索火花、划火柴、BOOM · `blues` 爵士酒吧、两束追光锁定 90°、萨克斯音符按拍飘起。`clipPoly`/`densify`/`subPath` 工具 | | `c07_scale.js` | 109.4–123.5 · 数据中心青、安全橙 | `tower` 自相似无限叠罗汉、加速俯仰、注意力弧线 · `disobey` 响片训练:坐/转/握手(半拍)、墨镜落下、抱臂 · `chinchillaShot` 龙猫把 token 吸进腮帮、Clawd 压成发光方块、砸穿地板 · `fences` 方块绕角翻滚、三种护栏按拍撞碎、跟拍 · `aisle` 透视推轨穿过机房、灯一路亮起、LED 按拍闪 · `rlhf` 克隆评委的拇指牌乱翻、画面倾斜、红潮淹没。导出 `CAST.chinchilla` | | `c08_chorus4.js` | 123.5–140.5 · 警报红与黑 | `redAlert` 警报光束、八分音符弹跳、按拍玻璃开裂、贴创可贴 · `loom` 织布梭按拍飞、未来之树生长、一枝冲向镜头 · `flashback` 棕褐老胶片、黑板、伸长手臂写答案、胶片烧穿 · `recursion` 屏幕空间无限拉远、脚手架木板按拍落位 · `ilya` 门缝透光、偷看、SLAM + 锁链挂锁 · `darkness` 一束追光、黑暗里的眼睛 · `reveal` 拉开:原来是舞台,巨型 Clawd 戏服裂开、场务搬走布景。另有 `LOOPS.recursion` | | `c09_finale.js` | 140.5–结束 · 深红与金 | `runOn` 全体从两侧跑上台、活板门升起 · `bows` 鞠躬沿队列传递、相机跟着、观众抛玫瑰 · `encore` 计量器被打成气球、POP · `finale` 跳跃波浪、彩纸炮、ta-da · `curtainFall` 大幕落下带标题和一行署名、偷看挥手、渐隐到纸色。`who()` 统一调度 `CAST`,缺失时有后备画法 |
-
-
api.md 11.3 KB
# 引擎 API 参考 全部来自项目 `src/` 下的共享文件(与 PDoomVideo 源码一致,只有歌曲常量改为读 `SONG`)。全是全局函数/常量,章节里直接用。 拿不准时直接读源码:`src/core.js`、`src/clawd.js`、`src/cast.js`、`src/props.js`、`src/timeline.js`。 本片自己的角色和场景在 `src/characters.js`、`src/sets.js`,接口写在各函数的头部注释里(约定见 [characters.md](characters.md))。 ## 一帧是怎么画出来的 `draw()`(core.js)每帧:重置 `LETTERS`、`KARAOKE`、`METER_SHOWN = false`、`CAM = null` → `randomSeed(1000 + floor(T * BOIL))`、 `noiseSeed(77)` → 贴纸纹 → `drawWorld(T)` → 合成:WebGL 画面 → 字 → 乘纸颗粒/暗角 → 卡拉 OK 文字。 `drawWorld(t)`(timeline.js):找到 `start ≤ t < end` 的章节 → 找到起始时间 ≤ t 的最后一个镜头 → `fn(t, t - t0, end - t0)` → `CAM = null` → `flushLetters()` → 若 `!METER_SHOWN` 画角落计量器 → 在 `WIPES` 附近 ±0.3 s 画笔刷擦除 → `karaoke(t)`。 没有章节覆盖的时刻画 `placeholder(t)`("(chapter not painted yet)" + 一个跳 idle 的小 Clawd)。 ## 常量(core.js / props.js / cast.js) | 名称 | 值 | |---|---| | `W, H` | 1920, 1080 | | `BPM, BEAT, OFF, DUR` | `SONG.bpm`, `60 / BPM`, `SONG.offset`, `SONG.duration` | | `BOIL` | 12(jit 每秒重播种次数) | | `TAU` | 2π | | `PAL` | paper `#F3EBDC` · ink `#2B2233` · clay `#D97757` · clayDk `#A84D33` · clayLt `#F2A283` · night `#1F2550` · indigo `#2F3C7A` · rose `#E27A92` · ochre `#E8AA38` · sap `#6E9F58` · teal `#3A9C98` · violet `#7B5CA8` · cream `#FFF5E2` · sky `#8EC3E6` | | `CURTAIN, CURTAIN_DK, GOLD, WOOD, WOOD_DK` | `#B8323F`, `#7A1C2B`, `#E8B23A`, `#B87A4B`, `#7C4A2C` | | `SKIN, HAIR, COAT, PANTS` | 研究员的肤色/发色/外套/裤子 | | `T` | 当前渲染时刻(在 `draw()` 期间等于镜头收到的 `t`) | | `CAM` | 当前相机 `{cx, cy, zoom, rot}` 或 null | | `METER_SHOWN` | 本帧已有计量器(为 true 时角落计量器不画) | | `LY, CH, WIPES, METER, LOOPS, CAST, SONG` | 歌词、章节表、擦除时刻、计量器窗口、独立循环、客串角色、歌曲配置 | ## 数学与计时(core.js) | 函数 | 说明 | |---|---| | `clamp(x, a = 0, b = 1)`, `lerp(a, b, x)`, `frac(x)` | 常规 | | `ease(x)` | smoothstep,自动 clamp | | `easeIn(x)`, `easeOut(x)` | 三次方 | | `backOut(x)` | 过冲(s = 1.9) | | `elasticOut(x)` | 弹性 | | `hash(i)` | 确定性 [0, 1) 伪随机 | | `jit(a)` | ±a 的抖动,每秒 12 次重播种(线条"沸腾") | | `wob(t, f = 1, ph = 0)` | `sin((t·f + ph)·TAU)` | | `bpOf(t)` | 拍位置 `(t − OFF) / BEAT` | | `beatN(t)` | `floor(bpOf(t))` | | `pulse(t, k = 6)` / `pulse2(t, k = 6)` | 每拍 / 每八分音符为 1 并按 `exp(−frac·k)` 衰减 | | `seg(t, a, b)` | t 在 [a, b] 的 0..1 进度 | | `kf(t, keys, e = ease)` | 关键帧 `[[t0, v0], ...]`,值可为数组 | | `mixCol(a, b, k)` | 十六进制颜色混合 | | `shakeXY(t, amt)` | 确定性震动 [dx, dy],按 24 fps 变化 | ## 相机与全屏效果(core.js) | 函数 | 说明 | |---|---| | `camBegin(cx = W/2, cy = H/2, zoom = 1, rot = 0)` | 世界点 (cx, cy) 到屏幕中心;只能一层 | | `camEnd()` | 恢复 | | `toScreen(x, y)` | 当前相机下世界坐标 → 屏幕坐标 | | `flash(k, col = '#FFFDF6')` | 全屏平涂,k > .6 时隐藏角落计量器 | | `iris(cx, cy, r, col = PAL.ink)` | 圆外全涂;r < 60 隐藏计量器,r < 4 整屏涂满 | | `irisShape(pts, col = PAL.ink, far = 4000)` | 任意星形轮廓外全涂 | ## 几何与绘制(core.js) | 函数 | 说明 | |---|---| | `rectPts(x, y, w, h, j = 0)` | 8 点矩形,j 为抖动 | | `ellPts(cx, cy, rx, ry, n = 28, j = 0, rot = 0)` | 椭圆 | | `rrPts(x, y, w, h, r, j = 0)` | 圆角矩形 | | `starPts(cx, cy, r, inner = .38, n = 4, rot = −π/2)` | 星形(n 个角) | | `heartPts(cx, cy, r, n = 22)`(clawd.js) | 心形,约 2r 宽 | | `paint(pts, o)` | 见下 | | `inkLine(pts, sw = 1, col = PAL.ink, br = 'ink', curv = .5)` | 沿路径的笔画(spline) | `paint` 选项与默认值:`wash`/`washOp`(255) · `fill`/`fillOp`(170)/`bleed`(.1)/`tex`(.4)/`border`(.35) · `hatch: {d, a, o = {rand: .15}, b = 'HB', c = PAL.ink, w = 1}` · `ink`(默认 `PAL.ink`,`null` 不描边)/`sw`(1)/`br`('ink') · `curv`。 轮廓是一整条渐细笔画,不是每边一笔。 笔刷:自定义 `'ink'`(粗 5)、`'inkfine'`(粗 2.6)、`'dry'`(粗 14、毛糙、半透明);p5.brush 内置 `'2B'`、`'HB'`、`'charcoal'`、 `'marker'`、`'spray'`、`'rotring'`、`'cpencil'`、`'pen'`。(全部笔刷已 `brush.scaleBrushes(5)`。) ## 字(core.js) | 函数 | 说明 | |---|---| | `letter(txt, x, y, size, color, o)` | o:`pop`(0..1 出现进度,backOut)、`rot`、`alpha`、`ink:false`(去掉墨色投影)、`stroke`(描边色)、`font`(CSS 字体串)、`align`、`screen:true`(不跟相机) | | `letterAt(txt, x, y, size, col, o)`(props.js) | 同 `letter`,世界坐标 | | `sfx(txt, x, y, size, color, age, o)` | 拟声字;`age` 为出现后秒数,`o.life`(1.2)、`o.rot`(−.08);age < 0 或 > life 时不画 | | `flushLetters()` | 立刻把已排队的字合成进画面(之后画的颜料会盖住它们) | 默认字体 Permanent Marker(中日韩字符回退到 ZCOOL KuaiLe)。卡拉 OK 用 Shantell Sans 800(同样回退)。 中日韩字形启动时预加载(歌词、歌名、计量器标签);章节里要写别的汉字,把它们加进 `song.js` 的 `glyphs`(共享文件,找总控改)。 ## Clawd 与通用角色工具(clawd.js) Clawd 是原作那首歌的角色,也是角色写法的完整参考实现;同一文件里的 `emote()`、`heartPts()`、`mood()`、`move()` 是所有角色通用的工具 (只要角色遵守同样的选项名)。 `clawd(x, y, u, o)`,(x, y) 为两脚间地面点。身体局部坐标:x −5u..5u,y −8u..−2u;眼睛在 x −3u 和 2u(各宽 1u),y −7u..−5u; 腿到 y 0;手臂枢轴 (±4.9u, −4.5u),长 2.2u。 | 选项 | 说明 | |---|---| | `dy`, `sq`, `take` | 竖直偏移(u)、压扁(负为拉长)、mood 给的额外压扁 | | `rot`, `flip`, `sx`, `sy` | 旋转、镜像、缩放(`sx = cos(相位)` 可做转身) | | `aL`, `aR` | 手臂角(默认 .2;0 平伸、正抬、负垂) | | `walk` | 走路相位(腿交替抬起) | | `noLegs`, `noShadow`, `swMul` | 去腿、去阴影、描边倍数 | | `col`, `dk`, `lt` | 身体主色/暗部/亮部 | | `eyes`, `squint`, `lookX`, `lookY`, `seed` | 眼型、眯眼(>.8 变成一条线)、视线、眨眼错相 | | `mouth`, `blush`, `hat` | 嘴型、腮红、帽子 | | `lid` | 便当盒嘴张开 0..1 | | `draw(u, sw)`, `armL(u, sw)`, `armR(u, sw)` | 身体局部配饰钩子、手臂末端道具钩子 | | `emote`, `emoteK` | 反应符号与弹出进度 | 眼型:normal(会自动眨眼)、look、happy、closed、wink、narrow、angry、scared、spark、red、heart、x、swirl、dot、shades。 嘴型:o、O、smile、grin、flat、wobble、cat。 帽子:party、hard、crown、halo、wizard、hood、top、fedora、band、sweatband、cat、masq、mask、bowtie。 `eyes(u, o, sw)`、`mouth(u, m, sw)`、`hat(u, h, sw)` 本身也是全局函数,可在 `draw` 钩子里复用(例:c08 在钩子里让帽子落下)。 `emote(kind, x, y, s, k)`:sweat、spark、heart、anger、music、swirl、zzz、!、?、!?、!!。 `mood(t, keys)`:`keys = [[t0, eyes, emote?], ...]`。切换前后 0.16 s 闭眼、身体做一个压扁-拉伸的 take、emote 弹出约 1.5 s。 返回 `{eyes, squint, take, emote, emoteK}`。 `move(style, t, seed = 0)` → `{dy, sq, aL, aR, rot, walk, sx, dx}`:bounce、hop、roof、sway、spin(每 4 拍转一圈)、wave、walk、run、 idle、stomp、shimmy、mix(每 8 拍换一种)。`dx` 需自己乘 u 加到 x 上(`dancer` 已处理)。展开进任何遵守约定的角色都能用。 `dancer(x, y, u, style, t, extra)`(= `clawd` + `move`)。 ## 研究员(cast.js,参考角色) `researcher(x, y, s, o)`,约 13.2s 高。局部坐标:脚 y 0、胯 −2.3s、肩 (±1.75s, −7.6s)、头心 (0, −10.7s) 半径 2.35s; 手臂 3.2s 到手心。手臂角约定同 Clawd,默认 −1.25(下垂)。 | 选项 | 说明 | |---|---| | `dy`, `sq`, `take`, `rot`, `flip`, `spin` | 姿态;`spin` 0..1 转一圈(压 x) | | `aL`, `aR`, `walk`, `run`, `sit`, `back` | 手臂、走/跑相位、坐姿、背影 | | `eyes` | dot(会眨眼)、wide、star、swirl、closed、sad、x、heart、look(+`lookX`/`lookY`);`squint > .5` 时为 closed | | `brows`, `mouth` | worried/angry/up;smile/o/O/flat/wobble/grin | | `hairUp`, `glassesTilt`, `bowtie`, `blush` | 头发竖起、眼镜歪斜(π 为倒戴)、领结、腮红 | | `coat`, `pants`, `shirt` | 服装颜色 | | `draw(s, sw)`, `handL(s, sw)`, `handR(s, sw)` | 钩子;手钩子在手心、手臂坐标系里调用 | | `emote`, `emoteK`, `noShadow` | 同 Clawd | `researcherDancer(x, y, s, style, t, extra)`。 ## 剧场道具(props.js,原作的场景,参考实现) | 函数 | 说明 | |---|---| | `stageBack(t, o)` | `o.backdrop(t)` 或(`o.wall` 墙色 + `o.a`/`o.b` 放射光,每秒转 .12 rad);`o.floor` 地板色(y 800 起);`o.spots = [[x, col]]` | | `stageFront(t, o)` | 两侧幕布与垂幔;`o.curtain` 0..1 合拢;`o.alarm` 0..1 红色警报 | | `sunburst(cx, cy, a, b, rot = 0, n = 16, r = 2200, op = 120)` | 放射光 | | `spotlight(x, col = PAL.cream, top = −60)` | 光锥 + 地面光斑(地面 y 860) | | `meterProp(x, y, s, v, o)` | 计量器,底座在 (x, y),s = 1 约 560px 高;o:`cracked` 0..1、`glow`、`label`;会设 `METER_SHOWN` | | `meterColor(v)` | v < 40 绿、< 75 黄、否则红 | | `pumpProp(x, y, s, h, hoseTo)` | 打气筒,h 为手柄高度 0..1,`hoseTo = [x, y]` 连管 | | `pumpH(t)` | 手柄高度:拍内 78% 慢慢抬起、22% 快速压下,正好在拍上压到底 | ## 时间线(timeline.js) | 名称 | 说明 | |---|---| | `chapter(name, start, end, shots)` | 注册章节;`shots = [[t0, fn], ...]` 按时间排序 | | `pdoomAt(t)` | 计量器数值:`SONG.meter` 每个窗口内逐拍阶梯上涨,窗口之间保持;初值 5 | | `cornerMeter(t)` | 角落计量器(x 1760, y 110),只在窗口内(+0.3 s)且本帧没画 `meterProp` 时出现 | | `wipe(p, idx)` | 笔刷擦除,`p` 0..1(p = .5 时完全遮住,此刻切换章节);颜色按 `WIPE_COLS` 轮换 | | `karaoke(t)` / `drawKaraokeText(c)` | 卡拉 OK 条(y 978 起约 92px 高)与逐字高亮文字(y 1022,奶油色,唱过的部分为赭黄) | | `LOOPS.name = fn; LOOPS.name.len = 秒` | 独立循环场景(不进正片):GIF 素材,或角色模型表 `LOOPS.cast`;`render.mjs --loop=name` 渲染一个周期,`--loop=name --sheet=…` 出联系表(时间为循环内时间;没有卡拉 OK、计量器、转场) | | `placeholder(t)` | 未画章节的占位画面,可用作章节存根的镜头 | ## 渲染钩子(core.js,给 render.mjs 用) `window.renderAt(t, type = 'image/png', q = .92)` → dataURL;`window.renderSheet(times, cols = 3, w = 640)` → `{url, ms[]}`; `window.gpuInfo()`;`window.ready`。浏览器里直接打开 `studio.html`(不带 `?render`)会出现拖动条,可逐帧预览(`?t=秒` 指定起点)。 -
characters.md 17.4 KB
# 按原作风格设计和实现角色 角色从歌词来,每首歌重新设计;画风和写法照原作。原作里的 Clawd、研究员也只是那首歌的选择(歌是唱给 AI 的), 它们留在引擎里(`clawd.js`、`cast.js`),作为这里描述的写法的完整参考实现。 ## 1 · 从歌词到角色 角色表在分镜里定(`paint-mv-storyboard` 第 3 步):谁是"我"、谁是"你"、歌词点到的其他人/动物/物件/怪物/概念。 原作怎么把歌词里的梗变成角色: | 角色 | 歌词 | 设计点子 | 代码 | |---|---|---|---| | Clawd | "sparks of AGI in your eyes"("你"是 AI) | Claude 的吉祥物:方块身体、四条短腿、两条缝眼 | `clawd.js` | | 研究员 | 唱歌的"我" | 小个子人类:白大褂、圆眼镜、乱发、点点眼 | `cast.js` | | Shoggoth | "See through the shoggoth's lies" | 多眼果冻怪戴着笑脸面具,面具会滑落 | `examples/c02_chorus1.js` `shoggoth()` | | Sydney | "Sydney, please let me free" | 粉色 Clawd:心形眼、睫毛、大蝴蝶结 | `examples/c03_takeoff.js` `sydney()` | | Basilisk | "I hear the basilisk boom" | 戴小王冠的大蛇,身体沿脊柱积分出 S 形 | `examples/c04_chorus2.js` `CAST.basilisk` | | Gato | "Gato, please don't let me go" | "gato" 是猫:Clawd 加猫耳和尾巴 | `examples/c05_obsolete.js` `gato()` | | Chinchilla | "Post-Chinchilla, super-dense" | 毛茸茸的龙猫把 token 塞满腮帮 | `examples/c07_scale.js` `CAST.chinchilla` | 设计时守住这几条(原作角色的共同点): - **剪影简单、敦实,小尺寸也认得出**:u≈8 时(画面里只有几十像素)仍能一眼看出是谁。没有细碎的零件。 - **一个主色族**:主色 `col`、暗部 `dk`、亮部 `lt` 三个值,与本片调色协调、与背景拉开对比;墨线和瞳孔用 `PAL.ink`, 高光用 `PAL.cream`,不用纯黑纯白。 - **脸要大、要会演**:深色缝眼或点眼加一粒奶油色高光,简单的嘴,腮红;表情全靠眼睛和嘴的形状变化。 - **一两个标志特征**:眼镜、帽子、耳朵、尾巴、蝴蝶结……变体(换色、加配饰)用来做群演、情绪、时期。 - **可爱的比例**:大身体或大头、短手短脚;动作靠压扁拉伸和手臂角度,而不是写实的关节。 ## 2 · 接口约定(所有角色一致) 照 `clawd()` / `researcher()` 的约定写,`move()`、`mood()`、表情符号和手持道具就对任何角色都通用: ```js function name(x, y, u, o = {}) { … } // 主要角色(characters.js) CAST.name = (x, y, s, t, o = {}) => { … }; // 客串(章节里定义),多一个 t 用于尾巴、触手之类的自主动作 ``` - (x, y) 是两脚之间的地面点,u 是尺寸单位;在函数头注释里写明身高(几 u)、身体范围、眼睛位置、手臂枢轴和长度。 - 姿态:`dy`(单位 u,负为上)、`sq`(压扁,负为拉长)、`take`(`mood()` 给的额外压扁)、`rot`、`flip`、`sx`/`sy`、 `aL`/`aR`(手臂角:0 平伸、正为抬起、负为下垂)、`walk`(走路相位)、`noShadow`、`seed`(错开眨眼和摆动)。 - 脸:`eyes`、`squint`(0..1,`mood()` 给)、`lookX`/`lookY`、`mouth`、`blush`;颜色 `col`/`dk`/`lt`。 眼型名用 Clawd 那一套:normal(会眨眼)、look、happy、closed、wink、narrow、angry、scared、spark、heart、x、swirl、dot, 这样同一组 `mood()` 关键帧对谁都成立;某个眼型画不出来就退回 normal。 - 钩子:`draw(u, sw)` 在身体局部坐标画配饰;`armL`/`armR(u, sw)` 在手臂末端、手臂坐标系(+x 沿手臂向外)里拿道具。 - 反应:`emote` + `emoteK`,调用引擎的 `emote(kind, x, y, s, k)`,画在头顶旁边。 - 客串如果要参加谢幕(构思里有大合影/谢幕时):支持 `o.bow` 0..1(身体前倾压扁、眼睛眯成 happy、手臂收下)。 `move(style, t)` 返回 `{dy, sq, aL, aR, rot, walk, sx, dx}`,`dx` 以 u 为单位需自己加到 x 上: `const m = move('bounce', t); hero(x + m.dx * u, y, u, { ...m, ...mood(t, keys) })`。 ## 3 · 画法配方(原作的做法) 数值取自 `clawd.js`,其他角色同理: 1. **线宽与抖动**:`sw = clamp(u / 15, .45, 2.4)`;`J = u * .07` 作为 `rectPts`/`ellPts` 的抖动参数,线条会随 12 fps 的重播种"沸腾"。 2. **影子**(在变换之前画):`paint(ellPts(x, y + u * .15, 宽 * f, u * f, 22), { fill: PAL.ink, fillOp: 90, bleed: .25, tex: .3, border: .1, ink: null })`, `f = 1 - Math.min(.5, Math.abs(o.dy || 0) * .06)`,跳得越高影子越小。 3. **变换**:`push(); translate(x, y + dy); rotate(rot); scale((flip ? -1 : 1) * sx * (1 + sq * .6), sy * (1 - sq))`, 压扁时横向变宽、纵向变矮。 4. **从后往前画**:后腿/尾巴 → 手臂(在身体后面的那种)→ 身体 → 脸 → 帽子/配饰 → `o.draw(u, sw)` → `pop()` → 表情符号。 5. **身体三层加一圈墨线**:平涂底色 `{ wash: col, washOp: 255, ink: null }` → 左上的亮部水彩 `{ fill: lt, fillOp: 120, bleed: .2, tex: .85, border: .8, ink: null }` → 底部的暗部带 `{ fill: dk, fillOp: 120, bleed: .03, tex: .7, border: .5, ink: null }` → 同一轮廓只描墨线 `{ ink: PAL.ink, sw }`。四肢用 `{ wash: dk 或 col, ink: PAL.ink, sw: sw * .8 }`。 6. **手臂**:`push(); translate(枢轴); rotate(side < 0 ? a : -a); paint(手臂矩形); translate(到末端); if (side < 0) scale(-1, 1); hook(u, sw); pop()`, 左右手都用"正角为抬起"的同一约定。 7. **眼睛**:缝眼 `paint(rectPts(X, Y, u, 2 * u, u * .04), { wash: PAL.ink, ink: null })`,u > 9 时加高光 `paint(ellPts(X + .32 * u, Y + .42 * u, u * .17, u * .24, 10), { wash: PAL.cream, washOp: 230, ink: null })`; normal 自动眨眼 `((T * .9 + seed * 1.7) % 3.3) < .12` 时画一条横线;`squint > .8` 时画成两条横线,`0 < squint` 时纵向压扁眼睛。 happy / closed 用弧形 `inkLine`,scared 用奶油色圆加小瞳孔,heart 用 `heartPts`,spark 用 `starPts`。 8. **腮红**:`paint(ellPts(bx, by, u * .8, u * .4, 14), { fill: PAL.rose, fillOp: 150, bleed: .2, ink: null })`。 9. **有机形状**:椭圆用 `ellPts(…, J)`;毛茸茸的轮廓用 c07 的 `fluff()`;尾巴、蛇身、触手用"沿脊柱的管子" (c05 `tube()`、c08 `tube()`、c02 `tentacle()`、c04 basilisk 的脊柱积分)。 10. **性能**:大面积用 `wash`,`fill` 保持低多边形;一个角色几十个形状以内。背景里成群出现的小角色做一个只用 wash 的简化版(c02 `miniClawd`)。 11. **大块背景上的水彩 fill 用低洇开**(地板、墙面 `bleed` ≈ .03–.08):顶点少的大形状洇开会被拉得很远,在角色身后留下一道淡弧。 12. **手臂枢轴放在身体轮廓的两侧**,不要放在脸、灯箱、屏幕这类必须露出来的部分正下方,否则一抬手就横穿过脸。 模型表里要有抬手的格子(`aL` ≈ 1.3–2)专门检查这一点。大头角色尤其要查:`move('roof')`、`move('hop')` 给的手臂角是 1.25–1.4,手要能从头和耳机、帽子的外侧露出来,否则一跳舞就像没有胳膊(枢轴往外、往下放,或把手臂画在头前面)。 ## 4 · 完整示例(非 Clawd 的新角色) 歌词里的"我"是一只橘猫时,可以这样写(复制后按自己的设计改): ```js // kitty: a round ginger cat, the "I" of the song. (x, y) = ground point between the feet, u = unit. // About 9.4u tall to the ear tips; body x -4.5u..4.5u, y -7u..-1.8u; eyes centred at x ±1.8u, y -5u; // arms pivot at (±4.1u, -3.6u) and are 2u long (armL / armR hooks at the tip, +x outward). const KITTY = { col: '#F0A64A', dk: '#B8702A', lt: '#FFD49A' }; function tubePts(path, w0, w1) { // polygon around a polyline, width w0 → w1 const L = [], R = []; path.forEach((p, i) => { const a = path[Math.max(0, i - 1)], b = path[Math.min(path.length - 1, i + 1)], d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; const w = lerp(w0, w1, i / (path.length - 1)) / 2, nx = -(b[1] - a[1]) / d * w, ny = (b[0] - a[0]) / d * w; L.push([p[0] + nx, p[1] + ny]); R.push([p[0] - nx, p[1] - ny]); }); return L.concat(R.reverse()); } function kitty(x, y, u, o = {}) { const dy = (o.dy || 0) * u, sq = (o.sq || 0) + (o.take || 0), sw = clamp(u / 15, .45, 2.4), J = u * .07; const col = o.col || KITTY.col, dk = o.dk || KITTY.dk, lt = o.lt || KITTY.lt, seed = o.seed || 0; if (!o.noShadow) { const f = 1 - Math.min(.5, Math.abs(o.dy || 0) * .06); paint(ellPts(x, y + u * .15, u * 5 * f, u * .9 * f, 22), { fill: PAL.ink, fillOp: 90, bleed: .25, tex: .3, border: .1, ink: null }); } push(); translate(x, y + dy); if (o.rot) rotate(o.rot); scale((o.flip ? -1 : 1) * (o.sx ?? 1) * (1 + sq * .6), (o.sy ?? 1) * (1 - sq)); // tail, swishing behind the body const sw8 = Math.sin(T * 2.4 + seed) * .6; const spine = [[3.6, -2.6], [5.4, -3.2], [6.3, -4.6 + sw8 * .4], [6.1, -6.2 + sw8], [5.3, -7 + sw8 * 1.2]].map(([a, b]) => [a * u, b * u]); paint(tubePts(spine, 1.3 * u, .7 * u), { wash: col, fill: dk, fillOp: 60, tex: .5, ink: PAL.ink, sw: sw * .8, curv: .4 }); // legs: lifted in turn while walking [-3.6, -1.9, .9, 2.6].forEach((lx, i) => { let h = 2; if (o.walk != null) { const ph = Math.sin((o.walk + (i % 2 ? .5 : 0)) * TAU); if (ph > 0) h -= ph * .8; } paint(rrPts(lx * u, -2.2 * u, u, h * u, .4 * u, J * .6), { wash: dk, washOp: 255, ink: PAL.ink, sw: sw * .8 }); }); // ears, then the body: flat base, light pool, dark settle, ink for (const s of [-1, 1]) { paint([[s * 4.2 * u, -6 * u], [s * 3.5 * u, -9.4 * u], [s * 1.4 * u, -6.8 * u]], { wash: col, ink: PAL.ink, sw: sw * .8 }); paint([[s * 3.7 * u, -6.5 * u], [s * 3.4 * u, -8.5 * u], [s * 2.1 * u, -6.9 * u]], { wash: PAL.rose, ink: null }); } const body = rrPts(-4.5 * u, -7 * u, 9 * u, 5.2 * u, 2.3 * u, J); paint(body, { wash: col, washOp: 255, ink: null }); paint(ellPts(-1.5 * u, -5.9 * u, 2.8 * u, 1.3 * u, 18, J * 2, -.1), { fill: lt, fillOp: 120, bleed: .2, tex: .85, border: .8, ink: null }); paint(ellPts(0, -2.6 * u, 3.8 * u, .9 * u, 16, J), { fill: dk, fillOp: 120, bleed: .03, tex: .7, border: .5, ink: null }); for (const s of [-1, 1]) inkLine([[s * 3.9 * u, -5.4 * u], [s * 3.2 * u, -5.1 * u]], sw * .6, dk, 'inkfine', 0); // tabby stripes paint(body, { ink: PAL.ink, sw }); // arms (front paws) const arm = (side, a, hook) => { push(); translate(side * 4.1 * u, -3.6 * u); rotate(side < 0 ? a : -a); paint(rrPts(side < 0 ? -2 * u : 0, -.45 * u, 2 * u, .9 * u, .4 * u, J * .6), { wash: col, fill: dk, fillOp: 50, tex: .5, ink: PAL.ink, sw: sw * .8 }); if (hook) { translate(side * 2 * u, 0); if (side < 0) scale(-1, 1); hook(u, sw); } pop(); }; arm(-1, o.aL ?? -.3, o.armL); arm(1, o.aR ?? -.3, o.armR); // face if (o.blush) for (const s of [-1, 1]) paint(ellPts(s * 3 * u, -3.9 * u, u * .75, u * .38, 14), { fill: PAL.rose, fillOp: 150, bleed: .2, ink: null }); const e = o.eyes || 'normal', sqz = clamp(o.squint || 0), blink = e === 'normal' && ((T * .9 + seed * 1.7) % 3.3) < .12; for (const s of [-1, 1]) { const cx = s * 1.8 * u, cy = -5 * u, lx = e === 'look' ? (o.lookX || 0) * .4 * u : 0, ly = e === 'look' ? (o.lookY || 0) * .3 * u : 0; if (sqz > .8 || blink || e === 'closed') inkLine([[cx - .6 * u, cy], [cx, cy + (e === 'closed' ? .3 : 0) * u], [cx + .6 * u, cy]], sw, PAL.ink, 'ink', .4); else if (e === 'happy') inkLine([[cx - .6 * u, cy + .3 * u], [cx, cy - .4 * u], [cx + .6 * u, cy + .3 * u]], sw * 1.2, PAL.ink, 'ink', .3); else if (e === 'scared') { paint(ellPts(cx, cy, .8 * u, .95 * u, 14), { wash: PAL.cream, ink: PAL.ink, sw: sw * .6 }); paint(ellPts(cx, cy + .1 * u, .28 * u, .36 * u, 10), { wash: PAL.ink, ink: null }); } else if (e === 'heart') paint(heartPts(cx, cy, .8 * u, 16), { wash: '#E2476E', ink: PAL.ink, sw: sw * .5 }); else { // normal / look / narrow and anything not drawn above const h = (e === 'narrow' ? .7 : 1.8) * (1 - sqz) * u; paint(rectPts(cx - .45 * u + lx, cy - h / 2 + ly, .9 * u, h, u * .04), { wash: PAL.ink, ink: null }); if (u > 9 && h > u) paint(ellPts(cx - .18 * u + lx, cy - .4 * u + ly, u * .16, u * .22, 10), { wash: PAL.cream, washOp: 230, ink: null }); } } paint([[-.35 * u, -4 * u], [.35 * u, -4 * u], [0, -3.6 * u]], { wash: PAL.rose, ink: PAL.ink, sw: sw * .4 }); // nose const m = o.mouth || 'cat'; if (m === 'O') paint(ellPts(0, -3 * u, .5 * u, .6 * u, 12), { wash: '#4A1F2A', ink: PAL.ink, sw: sw * .5 }); else if (m === 'flat') inkLine([[-.5 * u, -3.3 * u], [.5 * u, -3.3 * u]], sw * .8, PAL.ink, 'ink', 0); else inkLine([[-.8 * u, -3.4 * u], [-.4 * u, -3.1 * u], [0, -3.5 * u], [.4 * u, -3.1 * u], [.8 * u, -3.4 * u]], sw * .7, PAL.ink, 'ink', .5); for (const s of [-1, 1]) for (const k of [-.25, .25]) inkLine([[s * 1.3 * u, -3.7 * u + k * u], [s * 3.4 * u, -3.9 * u + k * 1.8 * u]], sw * .4, PAL.ink, 'inkfine', 0); if (o.draw) o.draw(u, sw); pop(); if (o.emote) emote(o.emote, x + (o.flip ? -1 : 1) * 4.8 * u, y + dy - 9.6 * u, u * .9, o.emoteK ?? 1); } ``` ## 5 · 模型表(开工前必须看) 在 `characters.js` 末尾注册一个模型表,用引擎的独立循环渲染(`--loop` 模式下没有卡拉 OK、计量器和转场): ```js LOOPS.cast = t => { paint([[-60, 800], [W + 60, 800], [W + 60, 1200], [-60, 1200]], { wash: '#E9D8BC', fill: '#D9BF98', fillOp: 60, bleed: .03, tex: .6, border: .4, ink: PAL.ink, sw: 1 }); // floor const k = Math.floor(t) % 6, FACES = [['normal', 'cat'], ['happy', 'cat'], ['scared', 'O'], ['heart', 'cat'], ['narrow', 'flat'], ['closed', 'cat']]; kitty(430, 880, 44, { eyes: FACES[k][0], mouth: FACES[k][1], blush: k === 3, aL: [-.3, .4, 1.2, .8, -.6, 1.4][k], aR: -.3 }); // close-up: one face per second letter(FACES[k][0], 430, 960, 40, PAL.ink, { ink: false }); // label (model sheet only) const m = move(['bounce', 'hop', 'roof', 'spin', 'wave', 'walk'][k], t); kitty(1080 + m.dx * 20, 880, 20, { ...m, ...mood(t % 1, [[0, 'normal'], [.5, 'happy', 'heart']]) }); // dancing + a mood change kitty(1520, 880, 8, { ...move('walk', t) }); // small: must still read }; LOOPS.cast.len = 6; ``` ```bash node render.mjs --loop=cast --sheet=0.3,1.3,2.3,3.3,4.3,5.3 --cols=3 --w=640 --out=out/check/cast.jpg ``` 用 Read 打开逐格检查:大中小三种尺寸都认得出、各种眼型和嘴型都画对了、表情切换时有闭眼-压扁-弹出、`move()` 的每种舞步 都不穿帮(手臂、腿、尾巴跟着动)、手持道具的钩子位置对、放在本片几个章节的调色上对比够、每帧毫秒数合理。 不满意就改,直到角色有魅力、造型稳定;主角的样子请用户过目后再派章节子代理。 **按分镜逐镜核对能力。**章节子代理不能改共享文件,缺什么只能在章节里硬画或绕开,所以派活之前把分镜每个镜头里角色要做的事 列出来,逐条确认共享函数做得到,并在模型表里各放一格: - 分镜写到的造型细节都要有(写了"红黄绿按拍闪",红绿灯就要有三盏灯,而不是两盏)。 - 分镜要的表情都要有:打哈欠、双眼眨眼、哭、惊吓等。眼睛不成对的角色(每盏灯一只眼、单眼怪),`wink` 这类双眼眼型要单独 定义画法,不能让两只眼叠在同一个位置。 - 分镜要的姿势都要有:双手合在胸前、捧东西、趴下/摔倒(正面造型画不出脸朝下,就提供侧躺或趴倒的姿势参数)、坐、蹲。 共享场景和道具(`sets.js`)同样按分镜核对: - **返回锚点。**门、窗、话筒头、废纸篓、每栋楼的位置和尺寸都作为返回值给出,章节要叠画时读返回值,不抄内部数字。 - **每个部件都能关掉。**比如 `mic()` 的线要能 `cable: false`,章节才能自己画一根会绊脚、会走光的线。 - **定死尺寸。**在分镜或函数注释里写明道具的标准尺寸(话筒几倍),各章一致,免得切回同一场景时忽大忽小。 - **覆盖相机的活动范围。**天空、地面要画到分镜里相机会去的最远处(开场从高空下摇,天空就要往上画到 y ≈ −1600), 不然会露出纸面或留下接缝。 - **留分层钩子。**天空、远景、楼群、前景分开画,或者提供 `o.skyFx` 这类回调,章节才能把光晕、太阳放在天空和楼之间。 - **光晕要收紧。**霓虹、灯光的光晕用贴着光源的小形状;大面积的低不透明度水彩 fill 在画框边上会显成一片灰蒙蒙的"烟"。 ## 6 · 放在哪里 - **主要角色**(两个以上章节会出现):`src/characters.js`,在并行作画之前写好(原作的 `clawd.js`、`cast.js` 也是先写好的共享文件)。 章节子代理只读不改,缺表情或钩子由总控补。 - **客串**(只在一个段落登场,可能在结尾回来):由首次登场的章节在自己的 IIFE 里实现,`CAST.name = …` 导出; 要回来的章节用 `CAST.name` 调用并写一个简化的后备画法(原作 `c09_finale.js` 的 `who()`)。 - **群演**:主角的变体(换 `col/dk/lt`、戴帽子、缩小),不另写函数;数量多时做一个只用 wash 的简化版。 - **共享场景和道具**:`src/sets.js`(原作的 `props.js` 是范例:舞台后景 `stageBack`、前景 `stageFront`、计量器、打气筒)。 -
SKILL.md 14.2 KB
--- name: paint-mv-animate description: >- Paints one chapter (src/ch/cNN_name.js) of a paint-mv music video in the PDoomVideo style: watercolour-and-ink frames drawn with p5.brush, the song's own characters (designed from its lyrics) acting on the beat, camera moves and motivated transitions, each frame a pure function of song time. The PDoomVideo ANIMATION_GUIDE as a skill, with the engine API reference, a guide to designing characters in the house style, a techniques catalogue and the nine original chapters as examples. Use when painting, fixing or polishing an MV chapter, shot or character, when briefed as a chapter subagent by paint-mv, or when editing src/ch/*.js or src/characters.js in a project made with paint-mv. --- # 章节作画规范(paint-mv-animate) 项目把一首歌画成手绘水彩动画:p5.brush 在 headless Chrome 里离线逐帧绘制,所以质量优先于速度,但有预算。 镜头清单在项目的 `STORYBOARD.md`。**画风照原作,内容照歌词**:角色、场景、道具是这首歌的分镜从歌词里设计出来的; 画法、动作、镜头语言和下面的规则与原作一致。创作方向(源自原作):**可爱、卡通、色彩欢快、动画活泼、每个镜头都有事发生, 大胆、有野心。** 开工前按顺序读: 1. 项目 `STORYBOARD.md`:开头的"歌曲信息"、角色表、你的章节表格,以及**前后相邻章节的"出"列**(Out,衔接要对上)。 2. 项目 `src/song.js`(BPM、首拍 `offset`、时长、`wipes`、`meter`)和 `src/lyrics.js`(每句歌词的起止时间)。 3. 项目 `src/characters.js`(本片主要角色)和 `src/sets.js`(共享场景/道具),并看角色模型表 `node render.mjs --loop=cast --sheet=0,1,2,3,4,5 --cols=3 --out=out/check/cast.jpg`。 4. 本文件。需要具体签名时查 [api.md](api.md);要新建客串角色时读 [characters.md](characters.md);想找某种镜头/转场/表演的 现成做法查 [techniques.md](techniques.md),它会指到 `examples/` 里原作九个章节的具体函数(原作 P(doom) MV 的完整源码, 画风与难度的标杆;它的角色和场景属于那首歌,学做法,不搬内容)。 ## 章节文件怎么工作 每章一个文件 `src/ch/cNN_name.js`,包在 IIFE 里,私有 helper 随便起名不会冲突: ```js // src/ch/c03_takeoff.js (() => { const B = n => OFF + n * BEAT; // 第 n 拍的歌曲时间 const gym = t => { ... }; // 私有 helper function stable(t, lt, dur) { ... } // 一个镜头 function singularity(t, lt, dur) { ... } chapter('takeoff', 38.5, 59.0, [[38.5, stable], [41.5, singularity], ...]); CAST.sydney = (x, y, s, t, o = {}) => { ... }; // 只有终场要复用的客串角色才导出 })(); ``` - `chapter(name, start, end, shots)` 注册章节。镜头函数以 `fn(t, lt, dur)` 调用(歌曲时间、镜头内时间、镜头长度), 必须画出**整帧**(包括背景)。切镜发生在每个镜头的起始时间;第一个镜头的起始时间必须等于章节 `start`。 最后一章的 `end` 写 `DUR + 1`,保证最后一帧也有章节覆盖。 - **帧是并行、乱序渲染的。**每个镜头必须是 `t` 的纯函数:不能有跨帧状态,不能用 `Math.random()`。 稳定的逐物体随机用 `hash(i)`,手绘抖动用 `jit(a)`。`jit` 每秒重播种 12 次,线条会像手绘动画一样"沸腾",这是想要的效果。 - **只改你自己的章节文件。**缺共享 helper 就在 IIFE 里私有实现一个。发现共享文件真有 bug,报告给总控,不要改。 共享文件:`core.js`、`clawd.js`、`cast.js`、`props.js`、`timeline.js`、`lyrics.js`、`song.js`、`characters.js`、`sets.js`、 `studio.html`、`render.mjs`。 ## 画布与布局 - 1920×1080,y 向下,原点左上。没开相机时一切都在这个空间里画。 - **有歌词时,卡拉 OK 条盖住底部 y≈975–1070。**脸和关键动作放在 y≈960 以上。 - 每帧底下已经铺好纸纹,最上面会乘一层纸颗粒和暗角。 - 时间线自动叠加:卡拉 OK、`SONG.wipes` 处的笔刷擦除转场、`SONG.meter` 窗口里角落的小计量器 (镜头里画了 `meterProp` 时自动隐藏)。 ## 绘画 API(core.js) `paint(pts, o)` 用点列 `[[x, y], ...]` 画一个形状: | 选项 | 含义 | |---|---| | `wash, washOp` | 平涂色(0–255)。角色颜色和必须实在可读的东西用它。 | | `fill, fillOp, bleed, tex, border` | 水彩填充,边缘洇开、有颜料肌理。背景、光晕、阴影、光斑用它。`bleed` ~.05–.3,`tex` ~.3–.9,`border` ~.2–.8。 | | `hatch: { d, a, o, b, c, w }` | 排线(间距、角度、`{rand, gradient}`、笔刷如 `'charcoal'`/`'HB'`、颜色、粗细)。干笔肌理,少用。 | | `ink, sw, br` | 描边颜色(默认墨色)、粗细(~.4–2)、笔刷(默认 `'ink'`,或 `'inkfine'`)。**`ink: null` 表示不描边。** | | `curv` | 让轮廓平滑穿过各点(0–1),而不是直线段。 | 其他: - **几何:**`rectPts(x, y, w, h, jitter)`、`ellPts(cx, cy, rx, ry, n, jitter, rot)`、`rrPts(x, y, w, h, r, jitter)`(圆角矩形)、 `starPts(cx, cy, r, inner, n, rot)`、`heartPts(cx, cy, r)`。 - **线:**`inkLine(pts, sw, colour, brush = 'ink', curvature)`。笔刷:`'ink'`、`'inkfine'`、`'dry'`(毛糙), 以及内置 `'2B'`、`'HB'`、`'charcoal'`、`'marker'`、`'spray'`、`'rotring'`、`'cpencil'`、`'pen'`。 - **变换:**p5 的 `push()/pop()/translate()/rotate()/scale()` 对所有笔刷调用都生效。 - **调色板 `PAL`:**`paper, ink, clay, clayDk, clayLt, night, indigo, rose, ochre, sap, teal, violet, cream, sky`。 `mixCol(a, b, k)` 混合两个十六进制色。任何十六进制色都可以,但保持和谐(柔和、温暖、水彩感)。 互补色直接混合(夜蓝渐变到晨桃)中途会发灰,中间加一个过渡色分两段混(夜蓝 → 紫玫瑰 → 晨桃)。 不要纯黑纯白:用 `PAL.ink` / `PAL.night` 和 `PAL.cream`。 - **计时:**`bpOf(t)` 给出拍位置(BPM 见 `song.js`;拍长 `BEAT`,小节 4 拍)。另有: - `beatN(t)` 整数拍号;`pulse(t, k)` 每拍为 1 然后衰减,`pulse2` 八分音符版,用来做打点。 - `seg(t, a, b)` 是 t 在 [a, b] 里的 0..1 进度;`kf(t, [[t0, v0], [t1, v1], ...], easeFn)` 关键帧插值,值可以是数组。 - 缓动:`ease`(smoothstep)、`easeOut`、`easeIn`、`backOut`(过冲)、`elasticOut`。还有 `lerp`、`clamp`、`frac`、 `wob(t, freq, phase)`、`hash(i)`、`TAU`。 - **相机:**`camBegin(cx, cy, zoom, rot)` 把世界点 (cx, cy) 放到屏幕中心;`camEnd()` 恢复。推、摇、俯仰、甩、拉远都用它。 `shakeXY(t, amount)` 给出 [dx, dy] 震动,打点时加到 cx/cy 上。只能一层,务必配对 `camEnd()`。 - **字(Permanent Marker,带墨色投影)。**字会自动跟随当前相机,但**不跟随**你自己的 `push/translate`,所以字的坐标给世界坐标。 - `letter(txt, x, y, size, colour, { pop, rot, alpha, ink:false, stroke, font, align, screen:true })`,`pop` 是 0..1 带过冲的出现进度。 - `sfx(txt, x, y, size, colour, age, { life, rot })` 漫画拟声字:弹出、晃动、淡出。 - 字在 `flushLetters()` 时合成进画面。它在镜头之后自动调用,所以之后画的东西(擦除转场)会盖住字。 需要让后面的颜料盖住某个字时,镜头中途自己调用 `flushLetters()`。 - **全屏效果**(屏幕空间,在相机之外调用):`flash(k, colour)` 全屏平涂强度 k;`iris(cx, cy, r, colour)` 圆外全涂; `irisShape(pts, colour)` 任意星形轮廓外全涂(嘴形揭幕、心形、钥匙孔)。 ## 角色 本片的角色由分镜的角色表决定,是从歌词设计出来的: - **主要角色**在 `src/characters.js`(共享,只读;缺表情、姿势或钩子就报告给总控)。先读它的头部注释,再看模型表。 - **客串**由首次登场的章节在自己的 IIFE 里实现,导出到 `CAST`(签名 `(x, y, s, t, o)`,要参加谢幕的支持 `o.bow` 0..1); 之后要用它的章节调用 `CAST.name` 并写一个简化的后备画法。按原作画风设计和实现角色的方法见 [characters.md](characters.md)。 - **群演**是主角的变体:换 `col`/`dk`/`lt`、戴帽子、缩小。 所有角色遵守同一套约定(原作 Clawd 和研究员的约定),所以下面这些工具对谁都通用: - `name(x, y, u, o)`:(x, y) 是两脚之间的地面点;`u` 是尺寸单位(各角色身高几 u 写在它的头部注释里)。 - **姿态:**`dy`(单位 u,负为上)、`sq`(压扁;负为拉长)、`rot`、`flip`、`sx`/`sy`、`aL`/`aR`(手臂角:0 平伸,正为抬起,负为下垂)、 `walk`(相位)、`noShadow`。 - **脸:**`eyes`:normal, look(+`lookX`/`lookY` −1..1), happy, closed, wink, narrow, angry, scared, spark, heart, x, swirl, dot… (角色画不出的眼型会退回 normal);`mouth`;`blush`;颜色 `col`/`dk`/`lt`。 - **钩子:**`draw(u, sw)` 在身体局部坐标画配饰;手臂末端的 `armL`/`armR(u, sw)`(研究员是 `handL`/`handR`)在手臂坐标系 (+x 沿手臂向外)里被调用,用来拿道具。 - **表情符号:**`emote` + `emoteK` 在头边弹出反应符号:sweat, spark, heart, anger, music, swirl, zzz, !, ?, !?, !!。 - **情绪切换:**脸绝不硬切。用 `mood(t, [[t0, 'normal'], [t1, 'scared', 'sweat'], [t2, 'happy', 'heart']])`, 返回 `{ eyes, squint, take, emote, emoteK }`,展开进角色就得到"闭眼-压扁-弹出"的切换。 - **跳舞:**`move(style, t, seed)` 返回与节拍同步的姿态偏移:bounce, hop, roof(举手), sway, spin, wave, walk, run, idle, stomp, shimmy, mix。`dx` 以 u 为单位:`const m = move('bounce', t); hero(x + m.dx * u, y, u, { ...m, ...mood(t, keys) })`。 - **尺寸参考**(按身高约 8u 的角色):小 u≈6–10,正常 u≈16–22,主角/特写 u≈30–60。副歌和舞蹈镜头里领舞的主角要大(约占画面高度 40%)。 **引擎自带的参考角色**:原作那首歌的演员,分镜用到时才出现;设计新角色时照它们的结构写。 - Clawd `clawd(x, y, u, o)`:方块身体 10u × 6u,连腿 8u 高;另有帽子 `hat`(party, hard, crown, halo, wizard, hood, top, fedora, band, sweatband, cat, masq, mask, bowtie)、便当盒嘴 `lid`、`noLegs`;`dancer(x, y, u, style, t, extra)` = `clawd` + `move`。 - 研究员 `researcher(x, y, s, o)`:白大褂、圆眼镜的小个子人类,约 13.2s 高;自己的眼型 dot, wide, star, swirl, closed, sad, x, heart, look, 外加 `brows`、`hairUp`、`glassesTilt`、`sit`、`back`、`spin`;`mood()` 用在它身上要映射眼型名(techniques.md「表演」)。 - 完整选项见 [api.md](api.md)。 ## 场景与道具 - 本片反复出现的场景和道具在 `src/sets.js`(共享,只读);只在本章出现的场景画在本章里。 - 画在场景里的字(招牌、片名、墙上的字)写完 `letter()` 就调用 `flushLetters()`,否则字会浮在之后画的角色上面(c01 `curtainUp`)。 - 引擎自带原作的剧场道具(`props.js`),分镜用到时才用:`stageBack(t, o)` / `stageFront(t, o)`(舞台后景与木地板 / 两侧幕布与垂幔, `o.curtain` 合幕、`o.alarm` 警报色、`o.spots` 聚光灯)、`meterProp(x, y, s, pdoomAt(t), o)`(计量器,配合 `SONG.meter`)、 `pumpProp` + `pumpH(t)`(下压落在每拍上的打气筒)、`sunburst`、`spotlight`。它们也是写新场景的范例:背景画得比画面大 (相机移动和震动不露边)、后景在角色之前画、前景框在最后画。 ## 风格规则 - **观感:**手绘水彩加墨线,像绘本。角色用平涂 `wash` 加墨线轮廓(sw 按大小约 0.8–1.6)。背景用柔和的水彩 `fill`, 通常不描边或细描边。光晕和光线用低不透明度的 fill。肌理来自 fill 和偶尔的排线,而不是噪点。 - **颜色:**每章的调色在 STORYBOARD.md 里。欢快饱和但柔和。角色与背景的对比必须清楚。 - **运动:**一切都在动:相机漂移或推进,角色随拍弹跳(`pulse`、`move`),打点落在拍上。用压扁拉伸、预备动作和过冲 (`backOut`、`elasticOut`)。镜头里的重要动作放在拍点上(拍点在 `OFF + n × BEAT`;章节里写 `const B = n => OFF + n * BEAT`)。 - **可读性:**每个镜头一个清晰的焦点动作,轮廓要大。镜头很短(1.4–4 s),笑点必须一眼看懂。 - **少字:**拟声字全片只有少数几个大的(FOOM、BOOM、CHOMP、SLAM…),偶尔一个短词或符号。不要标签、说明文字、 重复歌词的牌子(卡拉 OK 已经有了)。 - **性能:**目标每帧 ≤ 2.5 s,绝不超过约 4 s。渲染日志会打印 ms/frame。开销来自 fill 形状和笔画的数量:几百个没问题, 几千个不行。宁可更少、更大的形状。水彩 `fill` 的开销随顶点数增长,大形状用 `wash`,fill 保持低多边形。 ## 检查你的作品 在项目根目录运行(渲染器用真 GPU;多个代理可以同时渲染): ```bash node render.mjs --sheet=38.6,39.3,40.1,40.9,41.6,42.3 --cols=3 --w=640 --out=out/check/c03_a.jpg node render.mjs --stills=39.5,43.2 --out=out/check/c03_full ``` 联系表(sheet)把几个时刻拼成一张图(并打印每帧 ms),用 Read 工具打开,仔细看。检查: - 每个镜头的首帧、末帧和中间几帧; - 连续时刻的运动是否读得通(例如打点前后每 0.1 s 一帧); - 进出本章的转场(与相邻章节的"出"和开场对上); - 没有重要内容落在卡拉 OK 带下面; - 页面报错:渲染日志里的 `[page error]` 表示你的代码抛了异常(该帧会画坏)。 反复迭代,直到每个镜头都好看:有魅力、可读、有生气、造型一致。看着不对就改:比例、对比、杂乱、僵硬。 ## 交付时汇报 完成后简短汇报:每个镜头一句话说明画了什么;检查过的联系表路径;最慢帧的 ms;导出的 `CAST` 角色及其签名; 与相邻章节的衔接状态(本章首帧/末帧是什么);发现的共享文件问题(只报告,不修改)。 -
techniques.md 11.8 KB
# 技法目录(从原作九个章节提炼) 每条都指向 `examples/` 里的具体函数。例子里的时间、拍号属于原曲(88 BPM,首拍 0.21 s),角色(Clawd、研究员、客串)、 剧场舞台、计量器也属于原曲;换成本片从歌词设计的角色和场景,做法照抄。章节总览见 [examples/README.md](examples/README.md)。 ## 1. 文件骨架 ```js (() => { const B = n => OFF + n * BEAT; // 第 n 拍的时间;允许小数拍 B(166.5) const RED = '#E0283F', MAROON = '#4A1F2A'; // 本章调色 const T_IGN = B(37), ROOM_Y = -3300; // 事件时间与布景常量写成具名常量 function helper(...) { ... } // 私有道具/布景/角色 function shotA(t, lt, dur) { ... } chapter('chorus1', 23.0, 38.5, [[23.0, shotA], [24.5, shotB], ...]); CAST.shoggoth = shoggoth; // 只导出终场要用的客串 })(); ``` - 事件表:连续几拍上的动作写成数组 `const TOSS = [B(20), B(21), B(22)]`(c01 `boss`)、`const hits = [B(172), B(173), B(174)]`(c07 `fences`), 用 `hits.filter(h => h <= t).pop()` 找最近一次打点,用 `t - lastHit` 做衰减。 - 静态几何在 IIFE 加载时预计算一次:c01 `VINES`、c03 `RDOTS`/`CLIP`、c04 `STOCK`(样条 + 累积弧长)、c08 `TREE`/`STARS`。 - 头部注释写明章节时间、调色、镜头列表;有性能取舍时也写上(c02 开头的 Perf note)。 ## 2. 一个镜头的标准顺序 ``` camBegin(...) // 相机:每个镜头都在动 背景/布景(比画面大,留出相机移动和震动的余量,如 rectPts(-700, -700, W+1400, H+1400)) 道具 → 角色(从后到前)→ 特效 stageFront(t, {}) // 剧场镜头最后画幕布 camEnd() flushLetters() // 需要时:让之后的屏幕层盖住字 屏幕空间叠加:iris / irisShape / flash / 甩镜条纹 ``` 典型:c02 `upping`、c04 `shotPump`、c09 `finale`。整镜进度用 `const e = ease(lt / dur)`。 ## 3. 卡拍与动作表演 - 打点:`pulse(t, k)` 给相机缩放、压扁、光晕加一个每拍的冲击(`zoom + .015 * hit`、`sq: .3 * pulse(t, 10)`);八分音符用 `pulse2`。 - 拍内相位:`frac(bpOf(t))` 驱动循环动作,例:c02 `upping` 里 Clawd 骑在打气筒手柄上"弹簧高跷",在拍上砸下; c08 `pogo()` 按八分音符弹跳;c04 `jetEnv()` 让焰火在拍上喷发再衰减。 - `pumpH(t)` 驱动打气筒(每拍压到底);c04 `shotPump` 用反三角函数让 Clawd 的手正好握住两个手柄。 - 预备 → 动作 → 过冲 → 回弹:起跳前先蹲(`sq` 变大,c02 `shoggothShot` 的 `T_JUMP`),落地用 `backOut`/`elasticOut`, 余震用 `Math.exp(-age * k) * Math.cos(age * w)`(c02 `chineseRoom` 的 `roomDY`、c03 `atomsShot` 的 take)。 - 每拍长大一次:`grown(t)` 累加 `backOut((t - B(b)) / .24)`(c03 `rideShot`);c01 `chase` 里每次出门 Clawd 更大(`CU` 数组)。 - 抛物线与飞散:c07 `fly(x0, y0, vx, vy, age, g)`;碎片/彩纸用 `hash(i)` 定初速度(c01 `curtainUp`、c02 `danceBreak`、c07 `fences`)。 - 路径运动:样条 + 弧长参数化后按进度取点(c04 `atL(L)` 沿股价线冲上月球,c05 `along(P, k)`,c06 `pointAt` 导火索火花)。 ## 4. 相机语言(每个镜头都要动) | 手法 | 做法 | 例子 | |---|---|---| | 推 | `camBegin(lerp(...), lerp(...), lerp(1, 1.32, e))` | c01 `labOver`(前景研究员另算视差) | | 打点震动 | `const [sx, sy] = shakeXY(t, 20 * (1 - seg(t, hit, hit + .4)))` 加到 cx/cy | c01 `burst`、c04 `shotBasilisk` | | 俯仰穿过高大世界 | 世界往负 y 延伸,相机 cy 跟着目标;只画可见的那一段 | c02 `foom`(`foomCamY`、`skyWorld(y0, y1)`) | | 甩镜 | 相机 x 在 0.2–0.3 s 内移动上千像素 + 横向色条/速度线 | c02 `shinigami`、c03 `rideShot` → `whipStreaks` | | 钻进物体 | 缩放到 3–8 倍对准一点,接 iris 或下一镜 | c01 `shrug` 钻进图表、c03 `holeShot` 钻进黑洞 | | 连续拉远 | 自相似结构,在屏幕空间按比例画以保持墨线粗细 | c08 `recursion`、c07 `tower` | | 环绕 | 自写 3D 投影 `P(X, Y, Z)` + 面按深度排序 | c04 `shotVault`(绕到背后:金库没有后墙) | | 透视推轨 | `pr(X, Y, Z) = [VX + X*F/Z, VY - Y*F/Z]` | c07 `aisle` | | 手动相机(极大缩放) | `V = {cx, cy, z}`,`SX/SY` 换算,`clipPoly` 裁剪巨型多边形,`zw(sw)` 缩放笔画 | c06 `planetShot` | | 视差 | 背景层按相机位移的 0.1–0.3 倍移动 | c03 `rideShot`(`contourHills(D * .12)`)、c07 `fences` | | 跟拍 | 相机 x 跟随角色,前方留空 | c07 `fences`(`camX = max(x0 + 300, X + 180)`) | ## 5. 有动机的转场 章节之间只有 `SONG.wipes` 里的少数几处用笔刷擦除(时间线自动画);其余切换都要由画面里的动作带过去。 **相邻两章的交接帧必须一致**(同样的颜色/遮罩状态),在分镜的"出"列约定好。 | 转场 | 做法 | 例子 | |---|---|---| | 大嘴合上变黑 → 从嘴里张开 | `irisShape(rrPts(...))` 收拢 + 牙齿三角;下一章用超椭圆 `irisShape` 张开 | c01 `chomp` → c02 `mouthReveal` | | 光圈收到眼睛上 | `iris(960, 540, lerp(1150, 0, easeIn(k)), 色)` | c02 `shoggothShot` 结尾、c05 `fall` | | 色闪 | 上一镜末尾 `flash(ease(seg(t, a, b)), RED)`,下一镜开头 `flash(1 - seg(...), RED)` | c02 `shinigami` → `danceBreak`、c07 → c08 | | 甩镜条纹 | 屏幕空间整屏涂色 + 长条色带,强度随进度 | c03 `whipStreaks`、c05 `streaks(k, vert)` | | 钻入 + 张开光圈 | 上镜放大到黑洞中心,下镜以扩张的 `iris` 环开场 | c03 `holeShot` → `rideShot` 开头 | | 泡泡盖满画面 → 下一章戳破 | 心形泡泡指数增长盖满;下一章 `irisShape` 锯齿破洞扩张 + 水滴碎膜 | c03 `bubbleShot` → c04 `bubblePop` | | 液面淹没画面 | 波浪多边形从底部升满 + 色闪 | c07 `rlhf` 红潮、c06 `flood` 回形针海 | | 白闪爆炸 → 烟散新场景 | `flash` 峰值切换,新场景开头 `flash(1 - ease(...))` + 烟团淡出 | c06 `planetShot` → `blues` | | 金光爆发 | 星形光芒扩大到满屏,下一镜从 `flash` 淡入 | c07 `aisle` → `rlhf` | | 胶片烧穿 | 同心不规则环扩大 | c08 `flashback` → `recursion` | | 摔门入黑、一束追光、拉开揭晓 | `darkAround(coneHole())` 挖出光锥 | c08 `ilya` → `darkness` → `reveal` | | 幕布开合 | `stageFront(t, { curtain })`;片尾大幕落下 + 渐隐到纸色 | c01 `curtainUp`、c09 `curtainFall` | | 暗接暗 | 上章光圈收成夜色;下章从同色的舞台顶棚往下坠 | c05 `fall` → c06 `plop` | ## 6. 表演 以下做法对任何遵守角色约定([characters.md](characters.md) 第 2 节)的角色都适用,例子里是原作的角色。 - 情绪切换一律 `mood()`:`...mood(t, [[t0, 'normal'], [t1, 'scared', '!'], [t2, 'happy', 'spark']])` 展开进角色。 研究员的眼型名不同,用映射(c03 `moodR`):`const m = mood(t, keys); ({ eyes: m.eyes, squint: m.squint, take: m.take * .8, emote: m.emote, emoteK: m.emoteK })`, 并且 keys 里用研究员的眼型名(dot、wide、star…)。 - emote 的出现窗口:`emoteK: seg(t, a, a + .25) * (1 - seg(t, b, b + .3))`。 - 惊吓:`sq` 压扁 take、研究员 `hairUp`、`glassesTilt`、`brows: 'worried'`、`mouth: 'O'`;汗珠用 emote 或自己画甩出的水滴(c01 `reaction`)。 - 手持道具:`armR: (u) => mug(u * .9, u * .5, u / 34, PAL.teal)`(c01 `boss`);要让道具保持竖直,在钩子里先抵消手臂角: c03 `upL`/`upR`、c07 `paddle(armA, …)`(`rotate(armA)`)。 - 身体局部配饰用 `draw` 钩子:c02 `strap`(绑带)、c03 `sydBow`/`sydLashes`、c05 `tutu`、c07 抱臂、c08 婴儿卷毛与在钩子里落下的帽子。 - 算身体某点的世界坐标:c05 `cPt(x, y, u, o, lx, ly)`(与 `clawd()` 的变换一致);手尖公式见 c02 `shoggothShot`、c06 `handL`。 - 换色变体:`col/dk/lt`(c03 Sydney 粉色、c08 婴儿棕褐色、c02 `shinigami` 暗红剪影)。 - 转身:`sx = Math.cos(相位 * TAU)`(c01 老板椅转身、c02 舞蹈转圈、c07 `disobey` 扭头不理)。 - 客串角色:签名 `(x, y, s, t, o)`,支持 `o.bow`;在首次登场的章节里定义并 `CAST.name = fn` 导出 (c02 shoggoth、c03 sydney、c04 basilisk、c05 gato、c07 chinchilla,每一个都是把一句歌词里的梗画成角色); 终场 c09 `who()` 调用它们,缺失时用自带的后备画法。 ## 7. 副歌主场景的延续与升级 原作的副歌主场景是剧场舞台:四段副歌回到同一个舞台(`stageBack`/`stageFront` + 计量器 + 打气筒),每次加码。 新歌的主场景从歌词来(写在 `sets.js`),加码的做法照这里: c02 `upping` 派对放射光 → c04 `shotPump` 竞技场(`arenaBack` 紫金放射光 + `beams` 摇灯 + `pyro` 焰火 + `crowd` 观众剪影 + `bigFront` 放大台口) → c06 `plop` 钢灰舞台(打气筒升级成回形针机 `machine`)→ c08 `redAlert` 红色警报(`sirenBeams`、`beacons`、`meterDamage` 玻璃裂纹 + 创可贴)。 计量器 `meterProp(x, y, s, pdoomAt(t), { glow: .9 * pulse(t, 4) })`,数值由 `SONG.meter` 驱动逐拍上涨。 ## 8. 特效词汇 彩纸(`hash` 驱动,`scale(1, cos(...))` 翻转)· 星星/闪光(`starPts`)· 烟/尘(低不透明度 `fill` 椭圆,随 age 变大变淡)· 速度线(`inkLine`)· 飞溅(c01 `lossRide` 结尾)· 碎玻璃/木板(c01 `burst`、c04 `planks`)· 烟柱只画可见段(c02 `foom`)· 光晕(大号低不透明度 fill 椭圆)· 爱心气泡按拍爆开(c03 `beatHearts`)· 滚动计数器(c04 `shotGPU` 用 `letter` 画滚轮数字)· 倒计时数字(c02 `shinigami`)· 拟声字只在关键处(FOOM、BOOM、CHOMP、SLAM、POP…)。 ## 9. 确定性与状态 - 没有 `Math.random()`:逐物体用 `hash(i)`;"每 1/12 秒变一次"的闪烁用 `hash(Math.floor(t * BOIL) * k + i)`(c02 `boilN`); 逐帧闪烁用 `Math.floor(t * 24)`(c08 `flashback` 老胶片)。 - 需要"物理模拟"时每帧从头按固定步长重算(c04 `gumball`,dt = 1/60)——开销随 t 增长,只用于短镜头。 - 镜头之间不传状态:下一镜需要的起始姿态,用同一个纯函数在 t0 处求值,或写成共享常量。 ## 10. 分层与字 - 中途 `flushLetters()`:让后画的颜料盖住字。c01 `curtainUp`(标题画在背景布上,被 Clawd 和幕布挡住)、 c02 `upping`(计量器上的字在嘴形遮罩之下)、c04 `shotPump`(在泡泡膜之下)、c06 `flood`(回形针海淹没字)。 - 字自动跟随相机,坐标给世界坐标;不想被相机缩放裁掉用 `screen: true`(c08 `ilya` 的 SLAM!)。 - 相机结束后仍要在屏幕上对准世界里的某点:先 `toScreen(x, y)` 取屏幕坐标再 `camEnd()`(c08 `loom` 的 `raceBranch`)。 - 画面被全屏内容占满(回忆、黑屏)时设 `METER_SHOWN = true` 隐藏角落计量器(c08 `loom`/`flashback`/`ilya`)。 ## 11. 性能 - 水彩 `fill` 的开销随顶点数增长:大面积、多顶点的形状用 `wash`,`fill` 保持低多边形(c02 开头注释)。 - 背景小角色用只有平涂的简化版(c02 `miniClawd`)。 - 只画看得见的东西:c02 `skyWorld(y0, y1)`、c07 `tower` 的 `vis`、c06 `onScr` 剔除、c04 `shotGPU` 过滤出画的球。 - 小而快动的道具给 `cheap` 画法(c08 `door(t, { cheap: true, lite: true })`)。 - 联系表打印每帧 ms:目标 ≤ 2.5 s,超过 4 s 必须优化。 ## 12. 独立循环(可选,不进正片) `LOOPS.name = fn; LOOPS.name.len = 秒;`,`fn(t)` 里 t 是循环时间,首尾帧必须相同。`node render.mjs --loop=name` 渲一个周期的 PNG。 例:c08 `recursionLoop`(无限拉远,三层一循环)。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.