Tephra v8
核心脚本 scripts/tephra.py,确定性(同参数同 seed 逐字节相同)。
改代码时的铁律:不要往共享
rng上加无条件的抽取。 每次渲染都会跑到的新噪声场,必须自己开一条流(np.random.default_rng(seed * 常数))。 从共享流上抽会把后面所有抽取(笔触播种、雨点)整体挪位,同一条命令换个版本就渲不出 同一张图了。0.2.0 修的就是这个(湍流卷曲场_n1)。 交付时必须报出完整命令——那就是版本号。
先读:工作流(七步,不要跳)
这套东西最常见的失败不是参数调错,是跳步。按顺序走:
- 叠网格 在源图上叠 0.1 间距的归一化网格,所有坐标参数(漩涡中心/分区/锚定)从图上读出来,不要估。
- 体检
python3 scripts/dp_fitness.py 图.png [--invert]。不过就别硬渲,回第 3 步。 - 前处理 见下「M0.5」。改的是明暗,不是几何。
- 列元素 → 逐个判物理 画面里有哪几样东西,每样在物理上往哪走。见「M3 物理对照表」。
- 写成分区 一个元素一条
--flow-region;天然同心的结构才用--swirl。 - 渲
- 验 相位互相关逐区量位移,与第 4 步的判断核对。方向靠眼睛看会错。
两种正典画法(先选一种,再往下调)
这套东西有两条都成立的正典,各自对应一批不同的参考作品。选错了后面全是白调。
同一张源图、同一个主体,左 A 右 B(动图:assets/examples/canon-modes.gif)。
两边都是各自路线调到最好的版本,不是「同参数开关 mass」——把 B 的参数原样关掉 mass,
画面会直接空掉(B 的 --density 0.02 几乎没有笔触,整张画都靠网点层撑着)。
| A · 纯粒子 | B · 网点实体 | |
|---|---|---|
| 一句话 | 用会动的短笔触把图重画一遍 | 把明暗翻译成一层静止的有序点阵 |
| 参考作品 | 样本J 的「尘」那一层 | 样本A / 样本K |
| 开关 | --mass 0(preset flux 的默认) |
--mass 2.6 + 原生网格渲染 |
| 主体逐帧 | 一直在动(帧间留存 ≈ 0.83) | 完全静止(≈ 0.999) |
| 落点怎么定 | 按亮度加权随机采样 | 跟固定 8×8 Bayer 表阈值判定 |
| 质感 | 密集绒毛状笔触,边缘硬 | 规则点阵,能读出连续调子 |
| 擅长 | 浪、烟、光柱、火、水花——本来就在流动的东西 | 人像、雕塑、动物、建筑——要读出轮廓和体积的东西 |
| 不擅长 | 人像(糊成一团毛,读不出结构) | 流体(点阵钉死,动感全没了) |
A · 纯粒子——裸跑即正典
python3 scripts/tephra.py in.png -o out --size 1400 --seconds 1.6 --fps 25 --mp4
默认 preset flux 就是它,--floor auto 从这张图自己的直方图读。
不要为了「用上正典」手打一长串参数——那串就是默认值。
B · 网点实体——必须走原生网格
python3 scripts/tephra.py in.png -o nat --size 768 --supersample 1 --palette ash \
--mass 2.6 --density 0.02 --sparsity 6.0 --floor 0.008 --contrast 1.15 --coherence 16 --stroke 3 \
--dissolve 溃散方向,0.45,0.40 --dissolve-patch 0.35 --escape 0 \
--comb-life 0.04 --comb-emit 0.2 --comb=梳向,2.3,30,1.45,0.16,5,0.70,66,0.24,7.0,0.26,3.6 \
--seconds 0.96 --fps 25 --mp4
python3 scripts/upscale_nearest.py nat.png out.png 1400
细节与实测验收表见 M4。这一路必须在 512–768 的原生网格上渲再 NEAREST 放大—— 直接渲 1400 会把碎料糊成烟,这是最容易踩的一个坑。
--comb的碎裂条只能从网点上梳出来。 没有 mass 层就没有点阵可梳,梳笔触只会得到一团运动模糊。 所以 B 那种硬边碎裂条在 A 里做不出来——不是参数没调对,是缺一层。
--mass不是风格开关,是亮度门槛。 它只决定「多暗算暗到不画」。 画出来长什么样由源图的明暗分布形状决定,不由这个数决定: 侧光雕塑的调子摊在中间段 → 网点连续过渡,读得出体积; 逆光剪影被相机压死、内部几乎一个值 → 近三成面积顶到 90% 以上,读起来是一块剪纸。 (实测:同一套参数,雕像只有 8.5% 的面积超过 80% 占空比,马是 29.8%。) 想让剪影也有层次,要在 M0.5 前处理里造体积明暗,改--mass没用。
共用的加料(两条路都适用)
| 想要 | 加什么 |
|---|---|
| 换色 | --palette gold / ember / blood / violet / phosphor / neon |
| 虚空不够/主体发碎 | --floor 0.05 覆盖自动值 |
| 白底图或暗主体 | --invert(注意副作用,见 M0.5 P1) |
| 焦点星爆 | --burst-at X,Y,强度 |
| 三维漩涡 | --swirl X,Y,S,R,RATIO,TILT,PITCH |
| 保留一个实体物 | --anchor X,Y,RX,RY[,S][,#色][,块][,曝光][,黑点分位][,彩度] |
| 一部分溃散 | --dissolve DEG,START,SOFT + --dissolve-patch 0.5 |
模块(一个模块只负责一件事)
铁律:模块之间不许偷偷改对方的参数。
历史教训:--dissolve 曾经把 --mass 从 0 改成 1,于是「只加一点溃散」实际换掉了整张图的画法。
M0 输入判定
scripts/dp_fitness.py。判据:中位亮度 < 0.20 直接过;否则要求可分离性 > 0.55。
(依据:4 张成功/失败样本实测。中位亮度低 = 虚空是图自带的;可分离性高 = 删哪儿由图决定不由你猜。)
M0.5 前处理(只改明暗,不改几何)
| 规则 | 触发 | 手段 |
|---|---|---|
| P1 反相 | 主体比背景暗 | --invert。副作用:主体自己的高光会变成 floor 首先删除的对象,造型强的物体会掉一截 |
| P2 去梯度 | 大尺度亮度梯度(大气/景深/渐晕) | 减一个超大尺度高斯背景再归一化 |
| P3 裁非画面物 | 水印/字幕/拼接缝 | 裁掉,不要修补——修补留下的直角缝会被忠实渲染成方块 |
| P4 换轴 | 主体与背景亮度接近(差 < 0.05)但纹理差 1.5 倍以上 | 用局部对比度当门槛 |
| P5 重打光 | 前四条都救不回来 | img2img,denoise 0.25–0.35,几何锁死,结构保真 > 0.55 |
什么时候必须上 P4:主体上某个你要保住的部件(耳朵、下颌、发丝)与背景中位亮度差 < 0.02。
量一下再决定——雕像的耳朵和背景烟都是 0.055,试多少个 --floor 都是白试。
现成脚本:scripts/p4_mask.py 源图 输出.png(小而亮的东西如余烬会被纹理判据误杀,脚本单独保留)。
P4 的陷阱(必读):纹理能量本质上是边缘检测,直接拿它当形体会把实心物体变成铁丝网—— 只有一圈亮边、内部是空的,粒子化之后糊成一团。正确用法是: 纹理只用来造「区域掩膜」(闭运算→填洞→大尺度平滑),形体交回给主体自己的明暗。 这样还有个好处:背景已由掩膜杀掉,就不需要反相,主体内部直接用源图明暗—— 受光处密、背光处疏,「张弛有度」是光影本身给的,不用另外发明规则。
M1 形体
互斥三选一,前两个都是正典,按上面那张表按题材选:
A 纯粒子 --mass 0 / B 网点实体 --mass 2.6(要碎裂条就只能选它) / 局部锚定 --anchor。
--anchor 不做主体分割:逐 RGB 通道对源图抖动,暗的自己不印、彩的自己出色,
黑点分位从椭圆区内自取(区内多数面积是背景 → 背景自动归零)。
彩度提升必须按已有彩度开门,否则中性色的微弱色偏会被放大成假色(白浪变青)。
M2 笔触质感
--stroke 最长笔长 / --length-mix 长短偏斜 / --aniso-len 各向异性区才给长笔 /
--fray 子段错位 / --gap 断口 / --jitter --curve 歪与弯 / --density --sparsity。
笔长必须小于你要读出来的最小部件。一根 22px 的笔画不出一个 25px 的蹄子,只会盖过去。
细节多的图:--stroke 10 --coherence 18;大形为主:--stroke 22 --coherence 45。
M3 流动(三层,各自独立、都可局部化,权重最强者赢)
| 层 | 参数 | 何时用 |
|---|---|---|
| 全局朝向 | --flow-bias / --flow-from-burst |
兜底 |
| 分区朝向 | --flow-region X,Y,RX,RY,ANGLE[,S] |
默认手段,一个元素一条 |
| 分区旋转 | --swirl X,Y,S,R[,RATIO,TILT,PITCH] |
只给天然同心的结构 |
物理对照表:光柱/雨/瀑布/坠落=90° | 火焰/余烬/上升烟=−90° | 破碎浪的浪背≈−40° |
浪的桶内/发髻/年轮/星系=--swirl | 爆炸碎片=--flow-from-burst | 岩石/建筑不动,什么都不给。
拿不准就问:关掉重力和风,它还会往那个方向动吗?不会就是编的。
M3.a 三维漩涡:RATIO = 投影椭圆的短长轴比(必须实测,拟合泡沫环主轴),TILT 长轴倾角,PITCH 径向内收。
在水面平面里旋转再把速度投影回画面——透视压缩自动产生(端部慢、鳍部快),速度按 1/r。
平面圆形漩涡会把水推着横切过所有的圈,读起来就是一个转盘。
--conserve 1.5 必开:消除吸引子造出的假轮廓。
M4 溃散(可选,非每图必用)
最关键的一条:在原生网格上渲,再 NEAREST 放大
样本A 的原生画幅是 512×384(GIF 容器 2048×1536,4×4 块 100% 一致,实测), 墨严格二值(只有 0 和 210 两个值),点亮率 10.2%。 一个"像素"= 画幅宽度的 1/512。 直接在 1400 宽上抖动,格子就细了 2.7 倍—— 单个像素永远读不出来,整片碎料糊成烟。这是"感觉像烟不像破损"的唯一根因。 做法:
--size 512 --supersample 1渲,再用scripts/upscale_nearest.py放到交付尺寸。 验收用scripts/native_check.py 成品.gif,逐项对照下表实测值。
指标(512 网格) 样本A 实测 点亮率 10.21% 实体(≥80% 帧点亮) 8.18%,逐帧完全静止 1.000 碎料 2.03%,1101 个独立块/帧 单块面积 中位 2,p90 8,p99 25,max 36(最大约 15×10) 碎料运动 每帧漂移 +1,+1 px,最佳平移后仍每帧死掉 61% 碎料射程 184 px ≈ 1.0 S 漂移与消亡是两件事:慢漂移给方向感,逐帧重掷给闪烁感。只做其中一个都不对。
人像先看这条。 这一路的实体半边是平滑的有序网点(画法 B,
--mass 2.6),碎的半边是同一块网点被朝一个方向梳出去的 硬边条纹——不是粒子、不是脉络、不是喷射。用--comb ANGLE,LEN[,SPREAD,THIN,WOBBLE]。 梳出去多少,原处就少多少((1-R)^0.85),质量守恒是机制自带的,不用另外"咬"。 纯粒子--mass 0画人像是错的(MODULES E-8 早写过,我在这条上连续走了很多轮弯路)。--dissolve DEG,START,SOFT+--escape+--turbulence。与 M1 完全解耦,不会替你开 mass。
溃散是局部侵蚀,不是整体擦除。 原作实测(4 件,PROPOSALS_EDGE):
- 灰烬不是飞出去的,是生在那儿的。逃逸粒子寿命 2–3 帧、速度 1–5 px/帧,一生只挪 0.02–0.10 S。 远场密度是出生分布:近场指数(e 折减 0.05–0.10 S)+ 约 1/d 的肥尾,延伸到 ~0.9 S。 旧实现让每颗灰一生飞 0.22×画幅(长 10–30 倍),结果不是"崩解"而是一层均匀灰雾。
- 溃散侧的实体本身也要变稀:同等深度只有完好侧的 0.46 倍,从边界往里渐变。
- 散出去的东西要变成画面的一部分,不是消失。 实测 样本S 单帧:核内填充 0.16, 出核后掉到 0.05,然后从 0.25 S 一直到 3 S 保持 0.017–0.020 的平台不再衰减(是核的 0.10–0.12 倍)。 没有"淡出"这回事。材料一路铺到画外,靠结构——分叉的丝状脉络——继续成形。 连通笔画最长到 0.35 S(约单笔的 10 倍),这就是碎片长成翅膀/根系/神经的物理。
- ⚠️ 不要用"绕核心质心的径向平均"去判断飞得远不远。翅膀是一个窄的定向叶: 在 ±15° 扇区里 d=1 S 处可能有 0.10 填充,除以 360° 平均下来只剩 0.008,和"什么都没有"无法区分。 本项目就是这么得出过"原作飞得不远"的错误结论(第 4 次栽在自选指标上)。要用扇区剖面或连通长度。
- 前沿是咬出来的:核会裂成 ≥2 块,轮廓粗糙度 2.3–6.0(光滑圆 ≈1.3),前方有脱离主体的离岛。
- 完好侧是真空崖:0.03 S 内从 0.86 掉到 0.006,崖外纯黑。
- 墨是二值的:四件各只有一个亮度值。淡出靠存活抽签,不靠 alpha。
| 参数 | 默认 | 含义 |
|---|---|---|
--dissolve-life |
0.10s | 溃散粒子寿命;每个周期在新位置重生(模周期数保无缝) |
--dissolve-near |
0.08 | 近场 e 折减,单位 = S。不要拿它当"飞多远"的旋钮——调大它是把整朵云一起加厚,出来是一张均匀灰毯(实测:调到 0.26 后断口只有参照的 0.3 倍、远场是参照的 3.8 倍,contrast 从 63:1 塌到 5:1)。要飞得远,调 --dissolve-reach |
--dissolve-reach |
0.9 | 肥尾能到多远(单位 S)。肥尾只带少量粒子,拉远它不加厚 |
--dissolve-floor |
0 | 按面积均匀撒出去的比例 → 远场是平台而不是衰减尾。要"散出去的成为画面一部分"就开 0.3–0.5 |
--dissolve-strand |
0.6 | 逃逸位移沿图像自己的流场走而不是直线弹射;越小越跟着场走 |
--spray |
— | N,ANGLE,FAN[,SPEED,SPAN] 把碎掉的像素推出去。和雨点是同一套引擎(短命闪现、小块、逐条抽角度),区别只有两点:发射点是碎块本身(按溃散场播种),角度是围绕 ANGLE、半角 FAN 的扇形。碎片于是离开形体、越飞越开,而不是待在原地 |
--filament |
— | N,LEN[,BRANCH,WIDTH,GAIN] 让碎片长成脉络。在溃散前沿播 N 根,顺流场积分 LEN×S 长,逐级分叉,画进 dust 权重里让笔触沿着它排队。这是"翅膀/根系"的唯一来源——散点无论多少都长不出结构 |
--dissolve-min |
0.05 | 最小逃逸位移(不设下限的话灰烬落回自己核里,溃散侧永远稀不下来) |
--dissolve-tail |
0.25 | 走肥尾(远场基座)的比例 |
--dissolve-patch |
0.35 | 局部性。0 = 旧的单调半平面梯度(像扫描线擦除);0.45–0.6 出咬痕和离岛 |
--escape |
1.0 | 现在的含义是逃逸概率倍数(P(出走)=clip(DIS×escape)),不是"飞多远" |
--dissolve-keepout |
— | X,Y,RX,RY[,S] 软椭圆:灰烬不许落进去。给画面里另一个要自己成立的元素留真空(余烬、亮窗、锚定物)。落进去的位移被拉短而不是删除,墨量不变;椭圆半径逐颗抖动 ±15%,免得出现一条一眼可辨的遮罩边 |
--escape-legacy |
— | 回到旧的长途飞行,只为复现老图 |
三条硬规则:
- 必须留一块不散的。
--dissolve START要让 ramp<0.05 的区域覆盖主体的 ≥25%。 全散 = 每颗都出走 = 整体平移+糊,不是崩解(实测:马 START 0.38 时主体只剩 4% 不散,结果是一坨模糊)。 - DEG 要让识别特征排在最后。 沿 DEG 轴投影主体,识别靠哪儿哪儿就得投影值最小。 (雕像仰头,脸在"上"——用 −90° 会先吃眉骨和眼窝;换成 −30° 才是"脸留住、后脑散"。先算再定。)
--conserve会抵消侵蚀:它按"home 密度/当前密度"补亮,而溃散造成的变稀正是它要修的东西。 home 密度必须按逃逸后的周期平均位置算(脚本已改);别用旧版跑新参数。
M4.a 断口要切在哪:切过识别特征,不是绕开它。 参照 样本A 是「脸的左半完整、右半崩解」——溃散穿过脸,不是从后脑开始。 我在这个项目里连续几轮把断口放在后脑以「保住脸」,方向反了: 被打碎的那部分正是延伸并充实画面的材料,全绕开就只剩「一个完整的头 + 旁边一点灰」。
做法:把主体的识别地标沿 DEG 轴投影,读出各自的 t,再挑 START 落在地标之间。 雕像实测(DEG=0,t 越大越先碎):
| 耳 | 眼窝 | 鼻梁 | 颊 | 颅顶 | 后脑 |
|---|---|---|---|---|---|
| 0.06 | 0.28 | 0.39 | 0.67 | 0.64 | 1.06 |
START 0.20 → 耳与眼窝守住(ramp 0 / 0.20),鼻梁半碎(0.47),颊与颅顶全碎。
一句话:留住一只眼 + 一条轮廓就够认了,剩下的交给画面。
锚区仍要 ≥25%(见上文硬规则 1)。
溃散方向 = 该作品的全局流向下游(实测 4/4 件:溃散侧、粒子漂移、独立雨层三者同向,差 <30°)。
--dissolve DEG 应等于 M3 主 flow-region 的 ANGLE,不要独立拍——除非撞上规则 2,那时以规则 2 为准。
溃散比例有上限:辨识度靠轮廓的(马)不能多散;靠亮部脊线的(人像)可以散到 55%。
验收(scripts/m4_erode.py 源图 成品图 DEG START SOFT FLOOR [--invert]):
按源图算出的 ramp 给成品分箱(不是按成品自己的密度分——那是自指的),
fill 应随 ramp 单调下降,最深箱 ≈ 0.33–0.50 倍。
scripts/m4_check.py 成品图 DEG:完好侧 0.05 S 外 <0.008、粗糙度 2.3–6.0、核 ≥2 块,
并**逐箱对照 样本A 实测剖面**(偏离 >1.8× 或 <0.55× 会标出来)。断口/远场比值要在 20:1 以上。
M5 空域层(与 M1–M4 无关的独立层)
雨点的模型是照原作动画逐帧实测标定的,不要凭感觉改:
| 参数 | 值 | 实测依据 |
|---|---|---|
--rain-block |
1.1 | 原作网点面积中位 4px²@2560 ≈ 边长 1.1 输出 px |
--rain-short |
4 | 长度分位 4/4/5/7/17,中位只有 5px,长条只占 5% |
--rain-spread |
25 | 链间角差在 200px 和 1200px 上相同 → 逐条独立抽,不是平滑场 |
--rain-flow |
0 | 同上;平滑角度场是错的结构 |
--rain-life |
0.16s | 原位残留为零、像素存活 14.7%、轨迹从不超过 4 步 → 短命闪现不是巡航 |
--rain-px-per-sec |
0.6 | 位移中位 1600px/s @2560 = 0.62 画幅/秒 |
--rain-speed-len |
0.5 | 位移与长度相关 +0.36 |
--rain-taper / --rain-spacing |
1.0 / 1.0 | 链内无梯度。调成非 1 会出现流星尾,那不是这个语言里的东西 |
--rain-keepout X,Y,RX,RY |
视构图 | 软椭圆禁入:不生成也不飞入,边缘淡出 |
--rain-wild 一个数放大四个维度的离散度。重尾分布调大方差时必须同时封顶,否则调的不是「更乱」而是「偶尔炸一个巨块」。
M6 输出
--palette --size --seconds --fps --mp4 --seed。
无缝循环校验:|末帧−首帧| ≈ 相邻帧差。参照的节奏是 0.48s / 12 帧 / 25fps 的快循环。
调参纪律
- 不要用静帧调流动。
--static-only时 frames=1,粒子完全不推进——凡是涉及 flow/boil/conserve 的调参,静帧测的是另一张画。 - 不要相信自己临时写的分析程序。 本项目已三次栽在这上面:聚类阈值造出了原图没有的「彗星尾」;手放的取样框让同一张图得出 0.16σ 与 0.59σ 两个相反判断;「墨在核内的份额」这个指标是自指的(核由渲染图自己的密度定义),越加外围墨它反而越高。凡用到阈值/网格/聚类半径的地方,先换两三个取值看结论翻不翻。
- 参考是动画就必须按动画取证。 静帧给不出速度、寿命、相位。而且要确认抓到的是相邻帧——间隔 1 秒的截屏落在 0.48s 循环的随机相位上,测出来的位移全是假的。
常见失败
- 蚯蚓状意义不明的亮线 →
--conserve 1.5没开 - 光柱向上流 / 方向违反物理 → 用了全局 flow-bias 兜所有区,改逐元素
--flow-region - 满屏中长线发土 →
--length-mix调到 3,--density降到 1.5 以下 - 主体像剪纸/贴纸 → 先分清是哪一种。画法 A(纯粒子)出现这个,是做了无差别的边缘填充,去掉它; 画法 B(网点)出现这个,是源图内部被压死(近三成面积顶到 80% 占空比以上), 要回 M0.5 造体积明暗,不是回纯粒子——人像回了纯粒子只会更糊。量一下再决定: 按 8×8 格统计占空比,中间段(35–65%)占比低于 15% 就是压死了。
- 主体成了铁丝网(只有轮廓、内部空) → 拿纹理能量当形体了。见 M0.5 P4 的陷阱
- 雨点像蛆 → 给单根加了曲率。原作每根都是直的,活力来自多条不同角度的短命闪现交错
- 雨点像流星有尾巴 →
--rain-taper不是 1
边界与伦理
细节(五官、文字、小窗)必然消失;平缓明暗过渡会变均匀绒毛。适合「一个大形 + 强对比」。
算法与代码全部原创,输出由确定性脚本渲出,不含任何模型。参数中的目标常数来自对一批 公开发布的粒子动画作品的逐帧实测——测量的是可量化的统计特征(网格尺度、点亮率、 块面积分布、帧间留存),不复制任何具体作品的画面、构图或主体。不得以他人名义署名或宣传。