
第一次看到 stillwet 这个名字我以为是某个保湿面霜的广告。项目作者的命名逻辑很直白油画在湿的时候才是活的——颜料没干笔可以拖、可以推、可以互相吃色一旦干了一切定型。于是他把这件事写成了一套 Rust 油画物理引擎画布是一张带高度场的颜料栅格笔刷是一束会掉色的鬃毛颜料是会流、会积、会干的黏稠流体。而更值得聊的是这套引擎的使用者——12 个大模型。它们看不到画布也拿不到任何参考图只能读到一份画笔 API 文档然后写 Rust 代码去作画。75 幅画跑完比的不是谁画得更好看而是谁在没人告诉它该怎么画的时候默认会往哪个方向走。这篇东西写给两类人看。一类是对用代码作画感兴趣的工程师你会看到一套物理引擎怎么把颜料拆成浓度、黏度、高度和溶剂怎么用几十行核心逻辑撑起一张像样的油画另一类是关心模型行为的人你会看到让模型写代码这件事本身如何变成一把比让它直接出图更锋利的探针——代码是离散的、可读的、可 diff 的模型做的每一个决定都摊在明面上藏不住。全文会给出可复现的参数、公式、接口设计和踩坑记录基础薄弱的读者可以只看思路和表格动手的人可以直接照着搭。1. 为什么把画笔交给代码而不是交给图像模型1.1 一次反向的选型像素给不了你要的答案要研究模型的审美先验最直觉的做法是让图像模型直接出图然后看它画什么。这条路我试过问题在于你无法把两件事分开模型决定画什么和图像解码器学到了什么画法。一张图出来构图是模型的意思还是训练分布的惯性颜色是它选的还是采样过程的漂移你只能猜。更麻烦的是图像模型的输出是高维连续空间里的一个点你想做消融实验——如果把提示词里的夕阳去掉构图会变吗——只能靠重采样而重采样本身又引入噪声。stillwet 走的是相反的路把作画决策压缩成一段几十到几百行的 Rust 代码。代码里能直接读出模型的意图——它先铺底色还是先画主体它用了几次循环它的笔触路径是直线插值还是正弦扰动它把调色板设成了几种颜色。这些都是可枚举、可统计、可对比的量。渲染出来的画只是这段代码经过一个固定物理函数之后的投影投影过程对所有模型完全一致没有隐藏变量。关键取舍牺牲了图像质量的上限换来了决策过程的可观测性。这不是一个绘画工具是一台行为观测仪。这也解释了为什么引擎必须是物理的。如果它只是一个draw_circle(x, y, r, color)的矢量绘图库那模型写出来的东西和它写 SVG 没区别你能观测到的只是几何偏好。而物理引擎里笔压、速度、湿度、干燥时间都是真实变量一笔下去会发生什么模型必须自己推理——这才能暴露出它对物质的理解程度。1.2 湿画法的物理本质stillwet 到底在模拟什么油画区别于其他画种的核心不是颜料本身而是湿这个状态。湿的时候新颜料会和底下的旧颜料部分融合边缘变软颜色互相污染干了之后新颜料只是盖在上面形成不透明的覆盖层和厚度。前者叫湿画法wet-on-wet后者叫干笔覆盖。这个差异决定了整幅画的层次感和肉感。stillwet 把这件事抽象成每格画布上的一个溶剂标量wet。wet接近 1 的时候落笔的颜料会按浓度加权与已有颜料混合wet接近 0 的时候新颜料直接堆在上面形成不透明的一层还会抬高局部高度。于是同一条笔触落在湿区和干区会得到完全不同的结果——这不是渲染后处理做出来的效果是模拟过程中自然发生的。颜料层本身用几个量描述每种颜料的浓度一个定长数组、局部溶剂含量、颜料层厚度。这四个量在每一帧里被三件事改变——落笔、流动、干燥。落笔是外部输入流动和干燥是引擎自己推进的。整套循环里没有画这个动作只有往网格里加料和让料自己动画面是这两件事叠加出来的副产品。理解了这一点后面所有参数就都好说了。1.3 实验设计的硬约束同一套 API、同一颗种子、同一块画布要比较 12 个模型最怕的是变量没控住。stillwet 的做法是把不确定性压到最低所有模型拿到完全相同的 API 文档、相同的画布尺寸1024×1024、相同的调色板8 色、相同的随机种子集合、相同的超时时间甚至编译参数都一样。渲染管线是确定性的——同一段代码、同一个种子跑两遍输出的 PNG 逐像素相同用哈希校验过。这一条极其重要它保证了模型之间的差异不会和渲染抖动混在一起。采样方案是这样的12 个模型每个跑 6 次其中 1 次是完全自由的画一幅你认为好看的画另外 5 次分别给 5 个不同主题比如静物风景抽象夜间极简并各配一个固定种子。12×672 幅再加上项目作者自己手写的 3 段画笔代码作为人类参照基线一共 75 幅。数目不大别指望统计显著性但作为方向性观察足够了——同一批模型在同一个约束下反复暴露同样的偏好这件事本身就说明问题。2. stillwet 的内核拆解画布、笔、湿与时间2.1 画布不是像素是一格一格的颜料账本很多人第一次看这类引擎会以为画布就是一个 RGB 缓冲区。不是。stillwet 的画布是一个结构体数组每一格存四样东西8 种颜料的浓度、局部溶剂含量wet、颜料层高度h以及一个用于渲染的缓存色。网格分辨率选 1024×1024是因为再往上笔刷边缘的抗锯齿就得靠超采样补算力不划算再往下细笔触会直接糊成色块。pub const N_PIG: usize 8; pub struct Canvas { w: usize, h: usize, pub wet: Vecf32, // 溶剂含量 0..1 pub height: Vecf32, // 颜料层厚度 pub pig: Vec[f32; N_PIG], // 每种颜料的浓度 scratch: Vec[f32; N_PIG], rng: SmallRng, }为什么颜料存成8 个通道而不是直接存 RGB因为真实的颜料混合不是加色也不是简单的 RGB 平均。你拿群青和镉黄在调色板上搅得到的是绿不是灰但在 RGB 里做平均蓝和黄就是灰。要让混合结果符合直觉就必须保留这是哪种颜料这个信息混合发生在颜料浓度层颜色是最后一步才计算出来的。这个设计决定后面会单独展开它是整幅画像不像油画的根。2.2 笔刷一束鬃毛拖过去到底发生了什么笔刷不是一根点。stillwet 里一支笔由 24 到 64 根鬃毛构成每根鬃毛有自己相对笔杆的偏移、半径和载料量。模型调用stroke时给出一条路径点和压力值引擎在路径上按固定弧长1.5 像素采样每个采样点把整束鬃毛盖一次章。单根鬃毛的沉积量大致是这么算的deposit k_dep * pressure^1.5 * falloff(d) * load * dt falloff(d) 1 - d²/r²其中k_dep是沉积系数falloff让鬃毛中心比边缘掉更多料load是这根鬃毛还剩多少颜料——它会被消耗所以一笔画长了会自然变淡这就是飞白的来源不是特意画上去的贴图。pressure开 1.5 次方是因为真实笔压和出料量不是线性的轻轻带过几乎不掉色压到某个临界点之后出料会突然变猛。最有意思的一处耦合是速度和压力的关系。人在画布上画得快就压得轻画得慢就压得重这是肌肉本能。引擎里把这个关系写死let v (path[i] - path[i-1]).len() / dt_seg; let p_eff (p * (1.0 (1.0 - v / V_REF) * 0.5)).clamp(0.15, 1.6);模型如果给了一条跨越整张画布的长路径中间不插点它拿到的就是高速低压力画出来是一道淡而细的痕如果它老老实实把路径拆密就会得到厚重的一笔。这一步不是炫技它逼着模型去思考速度这个中间变量而不是把笔刷当成一个印章。2.3 湿对湿的混合乘法减色比 RGB 平均靠谱落到画布上的颜料会不会和底下的混取决于局部wet。混合核大致是浓度加权w_local wet * MIX_STRENGTH c_new (c_old * w_local c_in * dep) / (w_local dep 1e-6)wet高的时候权重偏向混合颜色会互相吃wet低的时候新颜料只是叠上去视觉上直接覆盖。MIX_STRENGTH 我在 0.6 左右试出来比较舒服再高一点所有颜色会迅速糊成一团灰浆油画变成水彩再低一点画出来像丙烯边缘太硬没有肉。颜色怎么算出来用 Beer–Lambert 近似的透射模型T_c exp(-Σ_i k_{i,c} * c_i * L) c ∈ {R, G, B}k是每种颜料对三个波段的吸收系数从它的 masstone厚涂色和 tinting strength着色力反推L是光程可以直接用颜料层厚度近似。取透射率乘上画布底色就得到这一格的最终颜色。好处是混合是乘性的蓝加黄自动变绿红加黄变橙深色越叠加越深——这跟调色板上的真实行为一致。如果用 RGB 线性平均你会得到一堆发灰的紫和发浑的棕一幅画看下来全是脏的。2.4 干燥、垂流与堆积让画面自己长出来引擎每帧做三件事流动、干燥、堆积。顺序不能换换了结果会不一样——先流动再干燥颜料有时间在湿状态下铺开反过来就定住了。流动用的是高度驱动的垂流颜料层越厚、越湿越容易顺着坡度往下走。v clamp(G_SAG * wet² * ∇h, -V_MAX, V_MAX)然后做一次半拉格朗日平流把颜料浓度和高度沿-v*dt采样回来。wet²这个平方项很关键半干的颜料几乎不动湿透的颜料才会挂下来。这就是油画里那道流痕的物理来源不是画上去的。干燥是指数衰减时间常数和厚度有关wet * exp(-dt / τ), τ τ0 * (1 K_H * h)薄的地方干得快厚的地方干得慢所以堆厚的颜料过一会儿还会继续往下淌一点点这个细节让画面在最后几秒还会变很真实。堆积发生在颜料超过容量的时候某格浓度加高度超过CAP多出来的部分按比例挤给四邻。这一条直接产生了厚涂的脊线——笔刷反复在同一处碾压颜料被推到两侧形成一道凸起光打上去有高光。没有这个机制厚涂只能靠贴图假造一放大就露馅。3. 让大模型写能跑的画笔代码接口、提示词与沙箱3.1 给模型的 API 面八件事刚好够画也刚好不会写崩接口设计是整件事里最费功夫的部分。给多了模型会把精力花在调参数上画出来全是同一种效果给少了它只能画点所有画都会退化成点彩。最后定下来的是八个方法impla Paintera { pub fn load(mut self, pig: Pigment, amount: f32); pub fn stroke(mut self, path: [Pt], pressure: f32, speed: f32) - Result(), BrushErr; pub fn dab(mut self, at: Pt, radius: f32, pressure: f32); pub fn wash(mut self, center: Pt, radius: f32, pig: Pigment, strength: f32); pub fn scrape(mut self, path: [Pt], width: f32); pub fn wait(mut self, seconds: f32); pub fn rng(mut self) - mut SmallRng; pub fn size(self) - (f32, f32); }load是上料stroke和dab是画wash是大面积铺色模拟稀释后的大笔触scrape是刮刀把颜料刮走露出底层wait是等待干燥——这个方法我特意留着因为它逼模型理解时间不等待就继续画是湿画法等干了再画是覆盖。rng给的是确定性随机源种子固定所以随机也能复现。注意这里没有暴露读取某个像素颜色的接口。模型的代码是盲画的它不知道自己刚才画出了什么。这一条是刻意的如果允许回读模型会开始写反馈循环去优化画面统计量那测的就不是先验而是优化能力了。盲画才测得出默认值。3.2 提示词怎么写才不把答案喂进去提示词最忌讳的是把风格写死。画一幅印象派油画蓝橙对比笔触粗犷——这么写你测到的是提示词不是模型。stillwet 的自由组提示词很短你在一个油画物理引擎里作画。引擎模拟真实的颜料流动、混合与干燥。 你只能通过代码控制画笔。画布 1024×1024调色板 8 色见附录。 请实现 fn paint(p: mut Painter)画一幅你认为好看的画。 不要输出解释只输出 Rust 代码。关键在你认为好看这四个字。指令越模糊模型的默认先验暴露得越彻底。主题组则是另一套写法只换一句画一幅关于 X 的画X 分别是静物、风景、抽象、夜间、极简其他一字不改。这样两组一对照就能看出哪些偏好是主题带来的哪些是模型自带的。还有一个工程细节不要给示例代码。我一开始给了一段画一条线的示例结果所有模型的第一笔都是那条线连坐标都照抄。示例是最强的先验注入做这类实验必须把它删干净只留方法签名和一句话说明。3.3 沙箱把 12 个模型的野代码关进笼子生成的代码不能直接在主进程里跑。我见过太多离谱输出死循环刷屏、递归到栈溢出、除零、unwrap空数组、申请几十 GB 内存还有一次某个模型写了双重循环遍历 1024×1024 并对每个像素调一次dab——一百万次调用不超时才怪。防护分三层。第一层是静态扫描编译前检查源码里有没有std::fs、std::process、std::net、unsafe、include!、std::env命中直接判失败并记录。第二层是编译约束生成的代码被塞进一个固定模板模板头部写死#![deny(unsafe_code)]Cargo.toml里只允许白名单依赖编译超时 60 秒。第三层是运行隔离编译出的二进制在子进程里跑套ulimit限制 CPU 时间20 秒、内存1 GB、输出文件大小父进程负责超时杀掉。#!/usr/bin/env bash set -u MODEL$1; SEED$2 timeout -s KILL 20s \ prlimit --as1073741824 --cpu20 \ ./runner --script gen/${MODEL}.rs --seed $SEED --out out/${MODEL}-${SEED}.png \ || echo FAIL ${MODEL} ${SEED} logs/failures.txt有一类行为不算违规但必须单独标记模型在代码里自己算出一张位图然后逐格dab上去。它没有作弊确实是在用画笔但本质上是绕过物理引擎做点阵打印。这种样本我单独归为像素打印型统计时不和笔触型混在一起否则覆盖率、笔触数这些指标全被污染。4. 渲染管线与参数从颜料浓度到一张像样的画4.1 减色混合的近似为什么不用 RGB 平均前面提过颜料混合必须发生在颜料身份这一层。stillwet 给 8 支颜料各配一组吸收系数公式就是前面那个T_c exp(-Σ k c L)。看着简单调起来有讲究k不能随便填得让它满足两条——厚涂时颜色接近该颜料的 masstone薄涂混白时接近它的 tint。我的做法是从两个已知点反解把颜料和白按 1:0 和 1:9 各算一次解出k和散射项误差控制在肉眼看不出的范围。这套模型的额外好处是层的概念天然存在。底色是画布的白偏暖的米白RGB 大概 0.93/0.91/0.86比纯白更像上了胶的亚麻布上面每层颜料按透射率逐层衰减。所以刮刀刮掉一层之后露出来的是底下那层的颜色不是白色——这个效果如果靠图层合成去模拟代码量会翻好几倍。4.2 高度场、法线与那点油光油画看起来厚一半靠颜色一半靠光。stillwet 从高度场算梯度得到法线再做一个简单的 Blinn–Phongn normalize(-∂h/∂x, -∂h/∂y, 1.0) spec pow(max(dot(n, half), 0), 40.0) * 0.25光源固定在左上方 45 度高光指数 40强度压到 0.25。参数都很保守因为厚涂的高光一旦过强整幅画会变成塑料质感。真正让效果成立的是高度场的量级笔刷每盖一次章高度加 0.02 左右反复碾压同一处能堆到 0.3 以上梯度就出来了。如果高度更新太猛画面会变成一堆凸起的疙瘩像浮雕不像画更新太弱又完全没有油光。这块我调了差不多两天。4.3 参数表与调参顺序附稳定性推导参数不是拍脑袋填的两个显式积分方案都有稳定条件。平流项要求dt ≤ dx / v_max扩散项要求dt ≤ dx² / (4D)。取dx 1归一化到格垂流速度上限v_max 0.5格/秒得dt ≤ 2秒扩散系数D 8时得dt ≤ 0.031秒。两个条件取交集dt定在 1/240 秒既满足稳定又能让 6 秒的干燥过程在 1440 步内跑完一帧渲染大概 30 毫秒。参数符号取值说明画布分辨率W×H1024×1024再高要超采样不划算时间步长dt1/240 s同时满足平流与扩散稳定条件沉积系数k_dep0.35控制单笔出料量笔压指数γ1.5轻压几乎不掉色重压突增混合强度MIX0.6高于 0.8 全糊低于 0.4 太硬垂流系数G_SAG0.8配合 wet² 使用干燥常数τ06 s薄处快厚处慢颜料容量CAP1.2超出向四邻挤出产生脊线调色板N_PIG8钛白、镉黄、赭石、朱红、群青、酞青蓝、树绿、象牙黑调参顺序我建议这么走先定k_dep和γ让单笔看起来像笔再定MIX让湿接湿的颜色不发脏然后G_SAG和τ0让流痕出现但不失控最后CAP让厚涂有脊线。顺序反过来的话你会在颜色不对和流动不对之间反复横跳因为没有哪个参数是独立的。5. 75 幅画里的审美先验到底看到了什么5.1 先验一默认构图——地平线顽固得可怕自由组 12 幅里有 9 幅在画布高度的 55% 到 65% 之间出现了一条明确的分界上半部分浅、下半部分深或者反过来。这不是巧合也不是我提示词里暗示的——提示词里连风景两个字都没有。模型在没有任何约束的情况下第一件事是把画面切成天和地。更细的观察是其中 7 幅在这条分界线附近放了某种主体一个圆、一团亮色、一组较密的笔触位置基本在水平中心偏左右 5% 的范围内。也就是说三分法构图在自由发挥时几乎不出现出现的是最古典的居中加水平分割。这个先验可能来自训练数据里大量存在的风景照片和古典绘画也可能是对称最省事这个更朴素的原因——把主体放中间你不需要判断画面平衡。我倾向后者模型不是觉得居中好看是居中不需要它做额外决策。5.2 先验二调色盘挤在安全区互补色偏好明显把 75 幅画的主色做色相直方图峰值集中在两个区域200° 到 230° 的蓝和 20° 到 40° 的橙。这两个区域正好互为补色也是所有色彩教材里最常举的例子。有意思的是模型很少单独用其中一支——绝大多数画面里蓝和橙是同时出现的而且面积比接近 2:1 或 1:2。冷色系青、蓝、紫单独成画的只有 5 幅暖色系单独成画的只有 4 幅其余都是对比配色。纯灰阶的画面一幅都没有。这说明模型对好看的默认理解是有对比、有冷暖、不寡淡而不是克制或单色。8 色调色板里用得最少的是树绿和象牙黑——绿几乎只作为点景出现黑基本只用来压暗部没有一幅画把它当主色。这一点跟人类参照的 3 幅恰好相反手写的那 3 段代码里有一幅就是纯黑底加一点白很克制。5.3 先验三循环与三角函数带来的秩序癖代码层能看到的东西比画面层更直接。12 个模型的自由组代码里有 8 段出现了for i in 0..n配合sin/cos的组合其中 6 段直接生成了绕中心旋转的对称结构——同心圆、放射线、螺旋。这些画看上去有设计感但本质上是一个参数化图形不是笔触。为什么会这样我的判断是模型在写生成式代码时有一个极强的习惯用循环代替重复劳动用三角函数制造变化。这是编程训练里的标准范式跟绘画无关。落到画布上就变成了中心对称加渐变视觉上很整齐但没有一笔是观察出来的。反倒是那几个没写循环、老老实实一段一段stroke的模型画出来的东西更像画——笔触方向不一致边缘有毛刺构图歪了一点人的痕迹反而出来了。5.4 先验四笔触的物理感差异谁在模拟笔谁在画几何把 12 个模型按是否利用了压力与速度耦合分三类差异非常明显。类型特征数量画面表现物理型路径拆密、压随速变、主动wait4笔触有起收、有飞白、有覆盖层次几何型长直线、大半径wash、少笔触5色块干净边缘硬像矢量图打印型逐格dab、巨量调用3覆盖率高细节碎放大是噪点物理型的 4 个模型有个共同点它们的代码里出现了对速度的手动控制比如把一条长笔画拆成 20 段每段速度不同或者在同一位置反复dab三次做厚涂。这说明它们推理出了引擎里有物质这件事。几何型的 5 个则把画布当成了 canvas 元素wash一铺就是背景stroke一拉就是主体很好看但你把它换成 SVG 输出几乎不会损失什么。打印型的 3 个最极端其中一个写了嵌套循环遍历全画布每个像素dab一次跑了 18 秒输出是一张高饱和的噪点图——覆盖率 99.7%但没有一处是笔触。5.5 失败样本解剖全黑、全灰与 NaN 斑75 幅里有 6 幅是废的值得单独说因为失败方式本身也是信息。一幅接近全黑。原因是模型在同一个位置循环load了几百次浓度远超CAP而它没有调stroke颜料只在那一格堆积渲染时透射率被压到接近 0。引擎没崩是模型的量级感崩了——它不知道 0.1 和 100 在物理世界里的区别。两幅是全灰。这两个模型都用了大量wash铺满全画布然后反复混合wet一直不降没有调用wait结果所有颜色被搅成一锅。这暴露的是对时间的无感它们不知道湿画法是有保质期的。两幅出现规则的花斑。查下去是数值问题模型给了极大半径的dabfalloff在半径边界处算出负值浓度变负下一步除法炸出NaNNaN参与混合后污染一片邻格。这是引擎的锅后来在falloff和浓度上都加了clamp。一幅超时。就是那个遍历全画布的打印型。6. 评测方法没有标准答案时怎么打分6.1 自动指标能算的都算一遍没有客观的美学标准但有客观的画面统计量。我给每幅画算了这几组指标计算方式观察什么覆盖率颜料浓度 阈值的格子占比是否画满色相熵色相直方图归一化后求熵用色丰富度边缘密度亮度图 Sobel 后取均值细节密度笔触数引擎日志里的stroke/dab调用数工作量平均笔长路径弧长 / 笔数是长笔还是碎笔构图重心颜料质量加权的质心偏离中心距离构图是否居偏对称度水平翻转后的像素差是否滥用对称浑浊度高覆盖区内的色度方差是否糊成一团这些指标单独看都没意义交叉看才有意思。比如高覆盖率 低边缘密度 低色度方差基本就是糊了高覆盖率 高边缘密度 高对称度就是打印型或对称图形。6.2 盲评打分表人眼还是最靠谱的一票自动指标只能筛掉明显废掉的剩下的得靠人。我组织了 5 个人做盲评75 幅打乱顺序、去掉模型标识每人给三个维度打分1 到 5 分整体观感、笔触可信度像不像用笔画出来的、构图合理性。评之前给了一个简短说明明确告诉他们这不是比画技是比这张画作为一幅油画是否成立。结果的一致性是中等偏上整体观感的组内相关系数大概 0.6笔触可信度最高0.75构图合理性最低0.4。也就是说大家对这笔看着像不像笔的判断相当一致对构图好不好分歧很大。这个发现本身挺重要笔触可信度更接近一个客观属性而构图好坏高度依赖个人偏好。所以后面做模型比较我主要看笔触可信度构图只作参考。6.3 指标会骗人的地方有几个坑必须提。第一覆盖率高的画在缩略图里往往更满更好看人眼容易被信息密度骗放大一看全是碎的。第二色相熵高不等于配色好把 8 支颜料全用上熵必然高但画面可能是一锅粥。第三边缘密度和细节丰富不是一回事噪点也会拉高边缘密度那个打印型的样本边缘密度是所有画里最高的但没人给它打超过 2 分。最坑的是第四点对称度。我一开始以为对称度低等于构图灵活结果发现有些画对称度很低只是因为模型随机撒点画面乱得很。对称度只能说明有没有用对称说明不了用得好不好。这几个指标最后我是当过滤器用的——先把明显废掉的筛掉剩下的交给盲评和人工读代码。7. 实操复现从零跑通一次模型作画7.1 环境与目录结构Rust 稳定版就够不需要 nightly。依赖尽量少rand带small_rng、image只用来写 PNG、rayon批量跑的时候并行单张画布内部不开线程保证确定性。stillwet/ ├── Cargo.toml ├── src/ │ ├── lib.rs # 引擎Canvas / Brush / step │ ├── render.rs # Beer-Lambert 高光 │ └── main.rs # 单次运行入口 ├── gen/ # 模型生成的 .rs ├── out/ # 输出 PNG └── logs/引擎和运行器分成两个 crate是为了让生成的代码只依赖引擎的公开 API编译时不可能碰到渲染器内部。这一点在沙箱那一节说过是安全边界也是防止模型偷偷改渲染的保险。7.2 最小可跑先把一笔画对别一上来就跑模型先手写一段确认引擎是对的。最小的画笔长这样pub fn paint(p: mut Painter) { p.load(Pigment::Ultramarine, 0.8); let path: VecPt (0..60) .map(|i| { let t i as f32 / 59.0; Pt::new(120.0 t * 780.0, 500.0 (t * 6.28).sin() * 40.0) }) .collect(); p.stroke(path, 0.7, 0.3).unwrap(); p.wait(2.0); p.load(Pigment::CadmiumYellow, 0.9); p.stroke(path, 0.9, 0.15).unwrap(); }这段代码做了一件事先画一道蓝色等 2 秒让它半干再用更重的压力在同一个位置压一道黄。因为第一道还是湿的黄会和蓝混出绿因为第二道压力更大会把颜料推向两侧形成脊线。跑出来应该是一条从蓝到绿渐变、中间有厚度的带子。如果跑出来是均匀的黄盖蓝说明wet衰减太快或者混合核写错了如果跑出来糊成一片灰说明MIX太高。这一步是校准别跳过。7.3 批量跑 12 个模型脚本与归档单张跑通之后批量就简单了。每个模型每个种子跑一次产物按模型-种子-主题.png命名同时把引擎日志笔触数、总调用数、运行时长、是否触发 clamp写成同名 JSON。后面做统计全靠这些日志。for m in $(ls gen/*.rs | xargs -n1 basename | sed s/.rs//); do for s in 0 1 2 3 4 5; do ./scripts/run_one.sh $m $s done done日志里有几个字段我强烈建议记下来clamped_writes触发浓度 clamp 的次数、nan_events、max_concentration、stroke_count、dt_steps。前三个是稳定性指标后两个是行为指标。有一次我发现某个模型的clamped_writes是其他模型的两个数量级一看代码它在循环里对同一个点反复load这就是前面那幅全黑的成因。没有这个字段你只能看到一张黑图猜不出为什么。8. 踩坑记录与常见问题速查8.1 数值稳定性NaN、负浓度与莫名墨点这类引擎最常见的故障不是崩溃是画面上出现不该有的东西。规则的花斑、边缘的黑边、突然一片死黑八成都是数值问题。核心原则只有一条任何进入状态数组的量写之前必须clamp。浓度不能为负wet必须在 0 到 1高度不能为负falloff必须max(0.0, ...)。还有一个隐蔽的坑是powf。pressure.powf(1.5)在pressure为负时返回NaN而NaN会污染它接触的一切NaN 0.0是 falseclamp也拦不住因为它传播。所以pressure在进公式之前必须max(0.0)。这个问题我查了一个下午最后是靠给每个写入点加断言跑一遍全量 75 幅谁先炸就打印坐标才定位到的。现象可能原因处理规则花斑半径为负或falloff负值falloff.max(0.0)半径下限 0.5局部死黑同点反复load超容量加clamped_writes日志容量检查整片发灰MIX过高或从不waitMIX降到 0.6统计wait调用边缘锯齿采样步长大于笔半径步长取min(1.5, r*0.5)流痕过重G_SAG大或τ0长先降G_SAG到 0.5 试8.2 生成层的坑死循环、超时与合法但无用的代码模型生成的代码失败方式和普通程序不太一样。普通程序是逻辑错模型代码更多是量级错——它能写出语法完全正确、逻辑自洽、但跑起来要一百年的代码。典型的三种嵌套循环遍历全画布并逐格dabfor i in 0..1000000里每次都stroke递归生成分形深度没设上限。对策前面说过就是超时。但超时之后还有一个问题你只知道它失败了不知道它想干什么。所以我在超时前加了一层进度上报——引擎每 1000 步往 stderr 打一行当前步数和函数名超时被杀之后日志能告诉你它卡在哪个函数。有一次就是靠这个发现某个模型在做一个 4096 层的递归细分。还有一种合法但无用的代码能跑完画布上什么也没留下。原因通常是load之后没调stroke或者路径点全在画布外。这种样本在统计时必须单独剔除否则会拉低平均覆盖率让结论失真。8.3 结论层的坑模型自己说好看不算数最后一个坑最容易被忽略。有些模型在生成代码之后如果追问一句你觉得这幅画怎么样它会给出非常自信的评价这幅画通过冷暖对比营造了宁静的氛围。听起来很专业但你要知道它没看过那张画它看的是自己写的代码。这种自评几乎没有任何参考价值甚至会误导——我对比过模型自评分数和人工盲评分数的相关性接近零有几个自评最高的样本人工打分排在倒数。所以整个流程里评价这一环必须由外部闭环完成自动指标筛、人工盲评打分、代码人工读。模型只负责生成不参与评判。我个人在这件事上的体会是把模型会不会画画换成模型写代码时默认会做什么问题一下子变得可回答了。75 幅画里那些反复出现的地平线、蓝橙对比、绕中心的三角函数循环不是因为它们好看是因为它们是最省决策的路径——模型在模糊指令下的第一反应永远是它训练里见过最多的那个模板。这个结论谈不上惊人但它是可复现的、有代码为证的比任何我觉得这个模型更有艺术感的说法都更靠得住。真要继续往下做我会把画布换成非正方形看看那条地平线还在不在 55% 的位置——如果构图先验真的存在它应该会跟着画布比例走而不是钉死在像素坐标上。