第一次看到 stillwet 这个名字我以为是又一个给图片套滤镜的小工具。翻完标题才发现方向完全不同它是一个用 Rust 写的油画物理引擎颜料有厚度、有湿度、会流动、会干而在这个引擎上作画的不是人是十二个大模型——它们不生成像素而是写代码去调用引擎的画笔接口。七十五幅画就是这么攒出来的没有一张是图像模型画的。这件事最有意思的地方不在于AI 会画画而在于代码是透明的模型选了哪个颜色、下笔几次、笔刷多粗、留白多少全都写在脚本里可以直接读、直接统计。于是这七十五幅画就变成了一份关于审美先验的采样报告——模型对什么样算好看的隐含假设被它的参数选择出卖了。下面我按引擎、接口、观察、复现四条线,把这个项目拆开讲一遍尽量把每一步为什么这么做说透。1. 这件事的靶心为什么让语言模型去写画画的代码而不是直接出图1.1 图像模型给的是像素代码给的是可复现的手用扩散模型出图你拿到的是一个 1024×1024 的矩阵。你知道它看起来像油画但你不知道它为什么在那里放了一笔也不知道那一笔的覆盖范围是多大。整个过程是一个黑盒采样同一个提示词跑两次构图就变了。让语言模型写代码就完全是另一回事。模型输出的是一串动作序列move_to(120, 300)、stroke_to(260, 280)、set_color(0.62, 0.18, 0.12)、set_load(0.7)。这些动作被 Rust 引擎逐条执行最后渲染成图。任何一个环节都可以停下来检查第 17 笔的半径是 22 像素第 18 笔把颜料载量调到了 0.9所以那一片比周围厚反光更强。这里的差别不是精度更高或者更可控这种泛泛的说法而是可归因。一幅画难看我能定位到具体是主体区域笔触密度只有 3 笔/千像素而不是笼统地说风格不对。做审美研究最怕的就是无法归因代码作画恰好把这个问题绕过去了。代价也很明显模型的绘画能力被压缩成了它对 API 的理解能力。它不会看到颜色它只知道color是个三元组。所以最终效果的上限一半在模型一半在接口设计。这也是为什么这个项目里接口那部分值得单独拎出来讲。1.2 stillwet 的定位物理正确优先于视觉讨喜大部分程序化绘画项目走的是讨巧路线——用噪声加噪点模拟笔触纹理用高斯模糊模拟湿边最后叠一层画布贴图。远看像那么回事放大就露馅笔触之间没有厚度差颜料不会互相污染改一个参数整个画面全变。stillwet 走的是另一条路它把画布当成一个二维的浅水系统来算。每个格子存四样东西已沉积的颜料RGB、悬浮在液体里的颜料RGB、液体层厚度、纸面肌理高度。画笔扫过去是在这几个场上做局部注入时间推进是解一个简化的对流-扩散-蒸发方程。这么做的直接好处是湿画法会真的晕开。你在湿区域上叠第二笔第一笔的颜色会被带着走边界自然模糊而且模糊的形状取决于液体层厚度的梯度不是随手一个模糊核。干画法下笔覆盖率由笔刷边缘的软硬决定颜料几乎不移动留在原地形成厚涂的肌理。提示这类引擎最容易犯的错是物理算得挺对但画出来发灰。原因通常不在物理在颜色空间——混合必须在线性光下做不能在 sRGB 编码值上直接加权平均。这些细节对人来说可能看不出区别但对用代码画画的模型来说非常关键因为它写的每一个参数都有明确的物理后果模型的意图和结果之间才是可对齐的。如果引擎是纯视觉欺骗模型调参就变成了瞎猜。1.3 七十五幅画这个规模是怎么定下来的十二个模型七十五幅画平均下来每个模型六幅左右。这个数量谈不上大样本但对于读代码这种人工分析方式来说已经到上限了——再多就只能靠脚本统计反而丢掉最有价值的部分。我的理解是这个规模解决的是三个层次的问题。第一层是分布同一个提示词跑不同模型看谁偏保守谁偏激进六幅足够看出趋势。第二层是稳定性每个模型跑两到三次同题看输出方差方差大的模型说明它对接口的理解不稳定。第三层是失败模式这个必须靠数量堆出来因为失败样本本身就是长尾——越界一次、超时一次、坐标写反一次只有凑够量才看得见规律。顺带说一句同题多次跑很重要。单幅画很容易被某次运气好干扰而同一个模型在同题下的三次输出如果笔触数量差了四倍那说明它根本没建立稳定的作画流程只是碰巧拼出了一组能跑的代码。这种信息比哪幅好看有价值得多。2. Rust 侧的四件事颜料堆、笔刷扫掠、湿扩散、干燥曲线2.1 画布不是像素数组是三层带厚度的场从数据结构上看画布大概是这个样子struct Canvas { w: usize, h: usize, pigment: Vec[f32; 3], // 已沉积颜料线性光 RGB susp: Vec[f32; 3], // 悬浮颜料在液体里 fluid: Vecf32, // 液体层厚度 h height: Vecf32, // 纸面肌理 颜料堆积高度 seed: u64, }四组数据分开存的理由pigment是已经定住的susp是还能被带走的这两者分开湿画法的混色才有物理依据——只有悬浮部分的颜料会随液体流动迁移沉积下来的就不会。height单独拿出来是为了模拟厚涂的立体感渲染时用法线扰动算一次光照笔触的隆起和凹槽就出来了。如果用单个Vec[f32;4]把 RGBA 一把梭会省一点内存但没法表达沉积/悬浮这个状态区分湿画法就只能退化成一个 alpha 混合那这个引擎存在的意义就没了。还有一个容易忽略的点pigment和susp都应该是线性光下的值不是 sRGB。渲染到屏幕前的最后一步才做 gamma 编码。这一点我在第 5 章会再拎出来说因为它是最常见的画面发灰元凶。2.2 笔刷把一条折线扫成覆盖率场模型给出的从来不是一张笔刷贴图而是一个路径加一堆参数。引擎要做的是把这条折线扫成一个连续的覆盖率场。具体做法是把折线拆成若干段每段当成一个胶囊体两端半圆的圆柱计算每个像素到这条胶囊的有符号距离d然后fn coverage(d: f32, radius: f32, hardness: f32) - f32 { let edge radius * (1.0 - hardness).max(0.05); let t ((radius - d) / edge).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) // smoothstep边缘不硬切 }hardness越大边缘越锐笔触越干越小越软越像湿笔。这一层 smoothstep 不是可有可无的美化它直接决定了笔触边缘是不是会出现锯齿状的硬边——模型的笔刷半径常常是小数硬切边界在斜线方向会非常明显。沿路径还要考虑载墨量衰减一笔拖得越长笔上颜料越少覆盖率和一点厚度都在下降。如果不做衰减长笔触会从头到尾一样饱满画出来有种机械感。引擎里通常用load * exp(-k * segment_length / radius)这种形式k在 0.02 到 0.08 之间比较自然。注意笔刷半径和画布尺寸的关系必须明确写进文档。同一个模型在 512 画布上写radius: 18是合理的换到 2048 画布上就变成了头发丝这就是后面第 5 章说的单位混用坑。2.3 湿扩散显式积分与稳定性阈值液体层的演化是这类引擎里最麻烦的部分。简化到二维浅水近似核心是两个方程液体厚度的守恒和悬浮颜料的平流-扩散。工程上更常见的做法是直接用一个扩散方程加一层方向性平流// 一个子步先扩散悬浮颜料再按液体厚度做平流搬运 fn step(c: mut Canvas, dt: f32, diff: f32) { let mut next c.susp.clone(); for y in 1..c.h - 1 { for x in 1..c.w - 1 { let i y * c.w x; let lap |v: Vec[f32; 3]| - [f32; 3] { let l v[i - 1]; let r v[i 1]; let u v[i - c.w]; let d v[i c.w]; [0.0, 1, 2].map(|k| l[k] r[k] u[k] d[k] - 4.0 * v[i][k]) }; let l lap(c.susp); for k in 0..3 { next[i][k] diff * dt * l[k] * (c.fluid[i] 0.01) as u8 as f32; } } } c.susp next; }这段代码里藏着两个必须处理的现实问题。一是稳定性。显式欧拉解扩散稳定条件是dt dx² / (4·D)。像素间距算作 1扩散系数如果给到 0.5那dt就必须小于 0.5如果给到 4dt只能到 0.0625。模型完全可能写出一个我要晕染得很厉害的意图然后传一个巨大的扩散系数如果你不加钳制画面会直接爆成噪点。稳妥做法是内部固定dt把外部参数换算成子步数量扩得越狠就多跑几个子步而不是把dt放大。二是边界。逐格遍历时如果边界处理成邻居取自己边缘会形成一个暗圈看起来像画布四周有一圈脏边。正确做法是边界用镜像或零梯度同时笔刷路径也不能超出画布——这一点必须在 API 层钳制不能指望模型自己算。液体厚度的上限也要给。物理上液体多到一定程度就会流走代码里对应一个clamp(0.0, 1.0)超过部分视为溢出。不做这个钳制模型写一个超湿参数整幅画会糊成一团。2.4 干燥与层叠干画法和湿画法在参数上怎么分干燥是一个随时间推进的衰减过程最简形式是线性蒸发fn dry(c: mut Canvas, dt: f32, evap: f32) { for i in 0..c.fluid.len() { let before c.fluid[i]; let after (before - evap * dt).max(0.0); let deposit (before - after) / before.max(1e-4); // 蒸发掉的比例对应沉积量 for k in 0..3 { let moved c.susp[i][k] * deposit; c.pigment[i][k] moved; c.susp[i][k] - moved; } c.fluid[i] after; } }这段逻辑决定了干湿两种画法的分界线。湿画法是液体层还在的时候下笔颜料会互相污染干画法是等液体层归零再下笔新颜料直接叠在旧颜料上覆盖率决定暴露多少底层。对模型来说这两者只是一组参数wetness高就是湿画配合低speed和长笔触晕开效果明显wetness接近 0 就是干笔配合高hardness和短促笔触会得到飞白的效果。这里有个很实用的设计经验不要给模型一个等待干燥的显式调用。让它在脚本里写wait(15)会让时序变成模型的心智负担大部分模型会写错。更好的做法是把整个作画过程切成若干层每层渲染结束后引擎自动推进一段时间让表面干燥模型只需要决定这一层画什么。这个设计改动直接把失败率降下来一大截——因为模型不需要理解时间它只需要理解顺序。2.5 确定性随机种子、浮点顺序与复现做这种实验复现性是底线。同一个脚本跑两次必须出同一幅画否则你没法比较改了一个参数之后画面变化了多少。要保证这一点需要控制三处。第一处是随机源纸面肌理的生成、笔刷边缘的轻微抖动、颜料的颗粒感都要走同一个seed派生的伪随机序列不能用系统熵。第二处是浮点累加顺序并行渲染时如果每个线程用局部累加再合并合并顺序不同会导致末位差异虽然肉眼不可见但在做逐像素差值对比时会是一堆非零噪声。第三处是迭代顺序遍历格子必须固定行优先不能中途切换。第三点听起来很琐碎但我在类似项目里吃过亏为了让扩散循环快一点改成按块遍历结果同一脚本两次渲染的图像差值不再是 0而是千分之一量级的散点。视觉上完全看不出但这七十五幅画是同一份代码跑出来的这个前提就不成立了后面的统计全都要打折。3. 把引擎包成模型能握住的画笔API 层的取舍3.1 只暴露十来个高层动作的理由一个完整的物理引擎底层接口可能有几十个设置单个像素的液体厚度、调整局部扩散系数、读取某点的颜料浓度……这些接口对人有用对大模型是灾难。原因很直接模型的输出是要一次性写完的。它不会在写代码的过程中反复运行、观察、修正除非你做了多轮反馈。所以它倾向于把每一步都当成一次性的精确操作如果接口太细它会写出几十上百行细碎的调用其中任何一行出错都会毁掉整幅画。七十五幅画这个项目里最终暴露给模型的接口大概是这个粒度{op: stroke, points: [[120,300],[180,292],[260,280]], radius: 18, hardness: 0.55, load: 0.7, wetness: 0.85, color: [0.62, 0.18, 0.12]} {op: wash, center: [400,200], radius: 120, wetness: 0.95, color: [0.90, 0.88, 0.80], strength: 0.4}stroke是主线wash是大面积罩染可能还有drybrush低载墨量高硬度、blend在已有颜料上做局部推移、lift吸走颜料做高光。十来个动作每个动作五到八个参数这个复杂度模型能稳定驾驭。提示参数名要尽量用绘画领域的词不要用物理术语。wetness比fluid_thickness_coefficient好得多模型对前者的先验知识明显更丰富写出来的值也更接近合理区间。这不是玄学。模型在训练数据里见过大量关于湿画法干笔罩染的描述这些词的语义分布已经带着合理参数范围的暗示。你用物理术语它就真的只能靠猜。3.2 参数钳制模型最爱越界的那几个值不设边界的话模型的输出会稳定地出现在几个位置参数模型常见输出问题处理方式radius0.5 或 300太小看不见 / 比画布还大钳到画布短边的 0.3%12%hardness1.0 或 1.2硬切边缘出锯齿超 1 无意义钳到 0.050.95wetness2.0液体溢出整幅糊掉钳到 0.01.0超过部分转成子步坐标负数 / 超出画布整笔丢失或越界崩溃裁剪路径段不整笔丢弃颜色(255,0,0)与 0~1 浮点混用全白自动识别 0~255 并归一颜色命名色字符串解析失败提供一张固定色名表兜底笔触数单幅 800渲染超时硬上限 提示最后一条特别值得说。模型的细节焦虑是有量化表现的画布越大它写的笔触数增长得越快而且往往超出必要的好几倍。如果你不设硬上限一部分渲染会直接超时样本就废了。设了上限之后模型会在中途被截断画面反而更有未完成感这在油画里不算缺点。坐标裁剪也建议做但是裁剪而非丢弃。路径超界时把它截断到画布内保留有效部分。整笔丢弃会让画面出现莫名其妙的空洞而模型完全不知道发生了什么。3.3 文本回执与自检给不给模型看渲染结果这里有个关键选择模型写完代码是直接渲染出图还是给它一份渲染结果的结构化描述让它自己改纯单向一次生成直接出图的优点是干净、可比较缺点是模型的错误没人纠正你得靠后处理过滤。带回执的话模型可以根据画面右下角颜料覆盖率 4%这样的反馈做第二轮调整效果通常好不少但引入了轮次变量不同模型的差异就不纯粹是审美先验还混进了改错能力。如果实验目的是研究审美先验我倾向于主实验走单向把回执作为单独一组对照。因为审美先验看的是第一反应——模型在没有任何视觉反馈时的默认选择才最能反映它的先验分布。一旦给了反馈它就开始做工程优化了那测的是另一件事。回执的格式也很有讲究。别给它图像给它数字{coverage: 0.63, mean_fluid: 0.04, dominant_colors: [[0.61,0.19,0.12],[0.88,0.85,0.74]], empty_regions: [[0,0,120,180]], stroke_count: 214}给图像的话多数文本模型处理不了给 JSON它能直接读懂右下角空了。3.4 沙箱、超时与渲染预算模型写的是代码代码必须跑在沙箱里。这不是防御模型恶意而是防御模型犯傻写死循环、申请巨大内存、在渲染循环里做sleep这些都真实发生过。具体做法是每个样本设定一个渲染预算单幅图最长 30 秒内存上限 512MB笔触数上限 600。超了就截断渲染把已完成的画面存下来同时记录一条timeout标记。这条标记在后面的统计里非常有用——超时率本身就反映了模型的工作量估计能力。七十五幅画的统计里如果某个模型的超时率超过三成那它的作品不能简单地和别人比好看因为它的样本已经被截断机制偷偷修改过了。这一点在做对比表的时候必须标出来不然得出的结论是错的。4. 十二个模型、七十五幅画作品里暴露出来的审美先验为了便于描述这里把十二个模型记作 A 到 L不涉及任何具体厂商。下面的观察来自一次模拟复现——我用类似的引擎和接口跑了一组对照样本量和原始项目不具备可比性但规律方向是一致的。4.1 笔触密度与细节焦虑把每幅画的笔触数除以画布面积得到一个笔触密度指标笔/万像素。十二个模型的分布很分散大致分三档分组笔触密度行为特征画面表现稀疏型A/D/H820少量长笔触敢留白有呼吸感但容易空中间型B/F/I/K4090分层铺色笔触长度中等观感最稳接近人画速写密集型C/E/G/J/L150400反复叠笔笔触极短细节多但糊看不出结构密集型的共同点是它们会在同一个区域反复下笔笔触长度普遍短于画布短边的 3%。看起来像是在描而不是在画。这背后是一个很典型的先验——模型对精细的理解偏向数量多而不是位置准。它会觉得多下几笔就是更用心但实际上油画的表现力来自每一笔的形状和方向不是笔数。还有一个有意思的现象稀疏型的模型往往在开头几笔很有想法比如先铺一个大色块定调但后面不知道怎么收就开始在角落零敲碎打。中间型反而更像是有一个隐含流程。这说明流程感是区分模型的更关键指标比笔触数量更能说明问题。4.2 调色盘高饱和、爱对比、缺中间调统计每幅画用到的不同颜色数量以及颜色的色相分布结果是相当一致的绝大多数模型的有效调色盘在 4 到 7 种颜色之间而且明显偏向高饱和、高明度对比。它们非常喜欢深色背景 亮色主体的组合也喜欢把互补色直接怼在一起。这让画面在缩略图状态下很抓眼放大看就有问题中间调缺失。真实的油画里一个红色的苹果从亮部到暗部中间要经过十几种明度层次模型往往只给三档——亮、红、暗。所以它的画看起来块面感很强但转不过去。还有一个细节模型极度偏好命名色。写crimson、teal、ochre的比例远高于写具体数值。命名色的好处是不会出错坏处是它们本身就是一组离散的、饱和度偏高的样本。一旦模型只在命名色里挑它的调色盘天然就远离莫兰迪那种低饱和的灰调。这个偏差不是模型审美差是接口给的词表就带着倾向。提示如果你的目的是让模型画出更丰富的色彩提供一个灰调色板文件让它参考比在提示词里写请用低饱和度有效得多。4.3 构图居中、对称、地平线压在黄金位置构图上的规律最集中。把主体包围盒的中心点统计出来明显集中在画布中央区域——超过七成的样本主体中心落在画布中心 15% 范围内。这不是巧合。模型的默认构图模板就是主体居中因为这是训练数据里最常见、最被反复描述的构图。三分法它们知道但很少主动用即使提到了落地的时候也常常算错位置把 1/3 算成 0.3 而不是 0.333在 1024 画布上差 34 像素肉眼能看出来。对称倾向也很明显。画风景的时候超过一半的样本把地平线放在了画布垂直方向的 0.550.65 之间而且左右内容高度对称。这个位置本身不算错但所有人都放同一个位置就变成了一种可识别的模型味。更值得说的是负空间。几乎没有模型会主动经营留白。稀疏型虽然笔触少但它的空白是没画到不是故意留的——从笔触分布能看出来它的空白是随机的不是形状化的。真正做到留白有形状的样本七十五幅里可能只有个位数。4.4 体积感谁会主动补投影谁只会平涂判断一幅画有没有体积一个简单指标是有没有主动加投影。统计结果显示只有少数几个模型会在铺完主体色之后专门用一笔更暗的颜色在背光面加一层。大部分模型是平涂型一个区域一遍颜色填满边缘清晰没有过渡。画面干净但扁。这里有个可操作的观察方法把画面转成灰度看明度直方图。平涂型的直方图是几个尖峰对应几种颜色的明度有体积感的画面是一条连续的斜坡。这个指标比人眼判断客观得多而且能自动化。还有一类模型会滥用高光。它们知道亮部要提亮于是在整个主体上撒满小白点结果看起来像撒了盐。这是典型的知道概念但不知道位置——高光应该出现在朝向光源的转折面上不是随机分布。4.5 失败样本的五种典型形态七十五幅里真正能算完成的大概七成剩下的失败样本反而信息量更大。归类下来有五种第一种坐标体系搞反。把 y 轴当成从下往上整幅画上下颠倒。这类错误集中在少数模型上而且同一个模型会连续犯——说明它对坐标系的理解是错的不是偶然。第二种单位混用。颜色用 0255坐标用 01 归一化半径用像素。三者混在同一段脚本里渲染出来的东西完全不可控。第三种过度绘制。笔触数打满上限画面被彻底覆盖所有结构被抹平。这类样本看一眼就知道因为它没有任何明暗关系只是一片糊。第四种时序错误。在干区域上做湿画法的操作或者在湿区域上做干笔效果和意图完全相反。这类失败最难发现因为画面本身不崩只是没有晕染效果。第五种结构缺失。只画了主体没有背景、没有投影、没有环境色。画面像贴纸主体飘在画布上。这类占比最高也最能说明问题——模型的注意力几乎全部集中在要画的东西上对周围发生了什么缺乏意识。5. 反推设计怎么让模型把画画这件事做得更像人5.1 把好看翻译成可执行的硬约束提示词里写请画一幅有氛围感的油画对模型基本没用。它会把氛围感翻译成高对比 暖色调 加暗角三件套一上所有人都长一个样。真正有效的是把审美拆成可检查的硬约束。我在复现时用的一套大致是这样约束具体形式作用明度跨度灰度直方图覆盖至少 0.150.85防止画面只有中间调或只有高对比笔触尺度分级大笔半径 短边 6%至少 3 笔强制建立大关系避免一上来就抠细节颜色数上限单层不超过 5 色逼模型做色彩取舍留白比例保持 15%35% 区域未被覆盖防止填满整个画布边缘对比主体边缘对比度高于环境 20%让主体脱离背景这些约束不是靠提示词而是靠在接口层面卡住。比如给定分层作画第一层只允许大半径笔刷模型想抠细节也没工具。这一招比任何提示都管用。5.2 三段式流程起稿、铺色、收型把作画拆成三个阶段每个阶段给不同的接口子集这是我试下来效果最明显的改动起稿阶段只给大笔刷和 3 种颜色要求覆盖 60% 以上画面。这个阶段的目的是定大关系——主体在哪、光源在哪、明暗怎么分。铺色阶段给中等笔刷和完整色板要求在起稿的基础上补充中间调。这个阶段最容易出问题因为模型会忍不住开始抠细节。收型阶段给细笔刷但限制总笔触数比如不超过 80 笔。这个限制很关键它逼模型只做最必要的几笔——现实中的画家收型也就是几十笔的事。三段之间引擎自动推进干燥时间模型不用管。这套流程下来画面的结构完整度提升非常明显尤其是结构缺失那类失败样本几乎消失了。5.3 局部观察窗口与增量反馈一次给模型整幅画的反馈它会抓不住重点。更好的方式是给一个局部窗口比如当前最空的区域坐标是 (620, 180, 200×240)然后让它只针对这块区域补笔。这个设计的好处是把大任务拆小了。模型处理在这个矩形里补点什么比处理让整幅画更好看要稳得多因为它有了具体的空间锚点。实现上渲染完一层之后算一次覆盖率网格比如 16×16 的粗网格找出覆盖率最低的连通区域把它的包围盒作为回执发回去。这个计算很便宜但效果拔群。要注意的是别让它一直补最空的区域。我试过纯按最空优先调度结果模型把画面填得非常匀反而失去了疏密。后来改成最空区域优先但连续两轮之后强制换到次空区域画面才有节奏。5.4 坐标、色域、单位三个最容易被忽略的坑这三个坑我在复现时全踩了一遍逐个说。坐标原点。引擎内部按图像惯例是左上角为原点、y 轴向下。但模型在描述天空地面时心智模型是 y 轴向上的。如果不显式说明一部分模型会把天空画在下面。解决办法很简单在接口文档第一行写死原点左上y 向下并且在回执里用上方/下方这种自然语言描述区域两者互相校验。色域。前面提过混合必须在线性光下做。具体的坑是模型给的[0.62, 0.18, 0.12]如果直接当 sRGB 用做加权混合得到的结果会比预期暗一档。正确流程是入参先做srgb_to_linear混合完再linear_to_srgb。我一开始漏了这步所有画都发灰查了半天以为是颜料覆盖率的问题。单位。半径是像素还是画布比例必须二选一并且强制。我最终选的是像素因为模型对18 像素的笔有直觉对0.017 的画布比例没有。但文档里要给出换算表512 画布上短边的 3% 是 15 像素2048 画布上是 61 像素。6. 复现一份同类实验从环境到评测表6.1 环境与依赖Rust 侧不需要太多东西一个能跑就行cargo new stillwet-lab --bin cd stillwet-lab cargo add image rayon serde serde_json randimage负责 PNG 编解码rayon做行并行注意前面说的浮点累加顺序问题扩散部分建议单线程或者按固定分块serde解析模型输出的 JSON 脚本。渲染核心不依赖任何图形库纯 CPU 算好处是可复现坏处是慢——512×512 画布600 笔扩散每帧 4 子步大概两三秒一幅能接受。模型侧用最朴素的方式把接口文档和示例脚本放进上下文让它输出一个 JSON 数组落盘后交给渲染器。不要让它直接写 Rust编译错误会浪费大量轮次。6.2 最小渲染循环核心循环大概长这样重点是顺序不能乱for layer in script.layers { for op in layer.ops { match op { Op::Stroke(s) brush::stroke(mut canvas, s), Op::Wash(w) brush::wash(mut canvas, w), Op::Lift(l) brush::lift(mut canvas, l), } } // 层间自动干燥推进固定时长模型不感知 for _ in 0..layer.dry_steps { fluid::step(mut canvas, DT, DIFF); dry::evaporate(mut canvas, DT, EVAP); } } let img render::to_rgb(canvas); // 含 height 法线光照 gamma 编码 img.save(out.png).unwrap();有两个细节值得强调。第一dry_steps由引擎根据层的wetness决定不由模型决定这样避免了时序错误。第二render::to_rgb里必须先算光照再 gamma 编码顺序反了会让高光过曝。6.3 评测表怎么设计才有信息量不要只存图。每幅画同步落一份指标 JSON字段至少包含这些字段计算方式用途stroke_count脚本里的 stroke 操作数反映细节焦虑程度unique_colors量化到 5bit 后的去重颜色数调色盘丰富度coverage非零颜料像素占比是否填满画面value_range灰度 5%95% 分位差明暗跨度centroid颜料浓度加权重心构图偏向symmetry左右半图的结构相似度对称倾向timeout是否触发预算截断标记样本有效性empty_ratio覆盖率低于阈值的网格占比留白经营关键是timeout这一列。做汇总的时候先把超时样本单独拎出来剩下的再比。混在一起比会得出错误结论——密集型模型的笔触数天然高超时率也高如果混着算它的平均笔触数其实是被人为截断过的偏低反而显得它不密集。6.4 我实际踩过的四个坑第一个笔刷路径没去重。模型经常在同一位置连续写三个相距 0.5 像素的点胶囊体计算退化成零长度除零得到 NaN然后 NaN 污染了整个扩散场画面全黑。修法是路径预处理相邻点距离小于 0.5 像素就合并。第二个扩散的边界反射。我一开始把边界条件写成零梯度结果颜料在画布边缘堆积形成一圈暗边。改成镜像反射之后正常了。这个坑很微妙因为中间区域完全没问题只有贴边的地方有。第三个颜色量化误差累积。为了省内存我用u8存颜料结果湿画法反复混合之后暗部很快被量化到 0变成一片死黑。改成f32之后解决代价是内存翻四倍。这个必须用浮点没有折中。第四个超时判定写在渲染后。一开始我是渲染完再检查耗时超时就丢弃。问题是渲染本身可能卡很久。必须做成渲染循环内部计时每 50 笔检查一次超预算就中断并标记。改完之后整体吞吐至少翻倍。最后一点个人体会这套东西跑下来我最大的感受是所谓的模型审美很大程度上是接口的镜像。你给它命名色它就画高饱和你给它居中坐标的默认值它就居中构图你给它无限的笔触预算它就把画面填满。真正属于模型自己的偏好当然有——比如对细节数量的执念、对中间调的忽视、对负空间的无感——但这些偏好之所以能被观察到恰恰是因为代码把这些选择变成了可读的数字。如果让我给一个建议就是在做这类实验之前先手动用同一套接口画三幅。你会立刻发现哪几个参数是陷阱哪几个是真正能表达意图的。手画过一遍的人设计出来的接口和没画过的人设计出来的差别大得离谱。还有一个小技巧把每次渲染的中间层也存下来。起稿阶段的灰度稿往往比最终成图更能看出模型的构图能力而且它没有色彩干扰一眼就能判断这个模型到底会不会安排画面。我后来做对比基本只看灰度稿成图只用来确认渲染没问题。
阅读完成 · 觉得有帮助?