用高斯泼溅作画
文章摘要
作者 yogthos 去年做过一个「边缘感知的像素化工具」——不用固定网格,而是让网格随图像边缘变形,从而在把照片转成像素艺术时保住清晰的轮廓和细节。这次他意识到,同一套边缘信息也可以用在数字绘画上:一个绘画程序需要决定笔触落在哪里、多大、朝哪个方向流动,而这些信息大部分已经编码在图像的边缘里——边缘标记了区域之间的边界,也就是画笔会去描摹的轮廓;边缘的缺失则意味着平坦区域,几笔宽刷就够了。目标是做一个交互式工具:拖动滑块,画面就在你眼前重新成形。这个项目同时也是他给自己的 Clojure 实现 Jolt 和基于 GTK 的 Glimmer 响应式 GUI 库做的一次实战检验。
为什么用高斯? 一笔油画或丙烯的笔触是一个细长的痕迹,中心有颜色、向边缘淡出,方向来自画笔被拖过画布的过程;边缘半透明,笔触之间互相叠加,让画家可以先铺大块颜色、再用更小更透明的笔触在上面堆细节。2D 高斯泼溅(Gaussian splat)恰好对应得非常好:它有一个均值(笔触落点)、一个协方差矩阵(拉伸和旋转,主轴沿笔触方向、次轴横跨笔触)、以及颜色和不透明度。
作者特意跟已有的两个同类项目(DrawingWithGaussians、2d-gaussian-splatting-Art)划清了界限:它们都用梯度下降——随机撒下泼溅,然后反复微调位置、形状、颜色,直到渲染结果逼近目标图像。这个方法既慢又不透明,而且最终结果只是输入图像的有损重建,而不像任何一种绘画。既然他已经有了提取边缘信息的方案,就没必要盲目地演化图像。第一个技术坑是混合模式:参考光栅化器用的是加性混合(pixel = background + Σ(intensity × color)),这在拟合场景下可行,因为优化器会学出补偿重叠的颜色;但直接从像素颜色播种成千上万个泼溅再加性渲染,重叠会爆炸——64×64 图像上 1200 个泼溅,某些像素的和达到了 22.06,整张图长满纯白斑块。解法是改用 alpha 合成的标准 over 算子,让每个泼溅按其 alpha 遮挡背后的内容,颜色和不透明度也因此干净地解耦。
边缘告诉你往哪里画。 最初的渲染器给每个泼溅相同的大小、长宽比和旋转,结果是一张均匀的马赛克。改进方法是用结构张量:由梯度外积构成 2×2 张量并在邻域内模糊,其特征向量给出三个关键信息——主特征向量指向轮廓的垂直方向(最强梯度),次特征向量沿着边缘(即笔触方向),而特征值之比即相干性(coherence),告诉你这是清晰轮廓还是各向同性的糊状区域。每个泼溅从所在位置的张量取得自己的协方差,沿边缘拉长、拉长程度正比于相干性;平坦区域保持圆形,边缘变成细长的方向性笔触。这是 Litwinowicz 和 Hertzmann 的经典绘画渲染技巧。随后又遇到一个问题:结构张量用的是亮度梯度,对等亮度的色彩边缘(苍白皮肤上的红唇、灰墙上的蓝色标牌)完全失明——解法是 Di Zenzo 彩色张量,对 RGB 每个通道分别算 Sobel 梯度,把外积求和成一个张量。
但边缘还不够。 结构张量解决了朝向,却完全不告诉你某个区域的细节密度——该用多少笔、笔该多小。仅凭边缘强度会完全错过物体的纹理:碎石路几乎没有相干的边缘结构,却有大量高频细节值得用细笔;光滑的脸颊只有一条轮廓边缘,内部应该保持宽阔;而微弱但真实的边缘(衣褶、远处的树枝)梯度幅值很低却仍需渲染。这里引入 Haar 小波:对亮度做多尺度 2D Haar 分解,把各尺度的细节系数绝对值求和,得到细节能量图。但原始小波能量是绝对值,暗区的真实纹理产生的能量低于亮区的中等纹理,会导致暗部细节被冲掉——所以要做相对亮度归一化:每个单元的能量除以其局部平均亮度加上全局均值的一小部分。张量负责每一笔的朝向与相干性,小波负责驱动自适应布点的密度图,两者合起来才完整回答了「细节住在哪里、形状是什么」。
杀死网格。 即便有了自适应朝向和密度,输出仍有一丝规律感——来自规则的布点网格(分块后每格放一笔必然在边界产生伪影,这和高压缩率 JPEG 的问题同源)。在格内抖动或按细节层级旋转网格都只是绕开表象。最终方案是用泼溅索引的 Wang 雪崩哈希生成位置,得到真正无周期性的白噪声坐标。这里有个微妙之处:简单的线性哈希会让点落在 Marsaglia 超平面上,产生比网格伪影更糟的对角条纹,必须用真正的雪崩混合,让每个输入位影响每个输出位。
用噪声制造「手感」。 此时每笔的长度、间距、对齐仍然一致,不像真实笔触。作者用两条去相关的 2D Perlin 噪声通道构成平滑的向量流场,每笔的朝向由结构张量的边缘角与 Perlin 流场角按 (1 − 相干性) 加权混合:强边缘上(相干性接近 1)忠实贴合轮廓,平坦区域(相干性接近 0)则呈现更有机的流动。流场本身由高度模糊的结构张量副本算出,让远处轮廓的走向像涟漪一样扩散进背景;实在没有特征引导的区域就交给 Perlin 场产生无方向偏好的有机曲线。这里也踩了坑:Perlin 弯曲是共享的空间场,相邻笔触会同相位地一起起伏,形成织物般的编织纹理,在手指、布褶这类中等强度边缘附近尤其明显——需要给每个种子加上噪声坐标的相位偏移,让每笔独立摆动。
分层:画家如何堆一张画。 最后是模拟真实绘画过程:先用大泼溅定下大色块,再逐层叠加更小的泼溅添加细节,每一层比下面一层更透明、更具体。基础层是大而不透明的笔触,完整覆盖画面不留空隙;接着是由小波细节图驱动的「主体自适应宽笔层」——背景处笔触变大变稀形成散景,主体处保持紧密;中间层是更短更透明的罩染。细笔层才是真正的笔触:这个分辨率下单个点已不够用,每个细种子要沿边缘切线步进出一串锥形高斯片段并融合成连续线条,用低频 Perlin 噪声轻微弯曲,大小和 alpha 向尾部渐弱如同画笔提起;颜色从边缘的一侧采样,让两侧的颜料在边界相遇而不是越界。在强边缘上,细笔触携带近乎不透明的颜料,模拟压在罩染层之上的厚涂勾线。当一笔漂离原色太远时链条必须提前停止——这模拟了画家在区域边界抬笔的动作。
上 GPU。 CPU 流水线每帧 55–112 毫秒,几万个泼溅还行(这个过程还顺带发现了 Jolt 编译器缺少类型提示优化,催生了两个性能改进的 PR),但精细画面需要几十万笔小笔触,而且每个泼溅都是一次 Clojure 分配、整个向量每帧都要过一遍 GPU。最终把生成整个搬进 OpenGL 上下文,用几何着色器做 transform feedback:着色器对每个候选点用细节图做阈值测试,跑布点数学,只为幸存者输出打包的泼溅记录,缓冲区留在 GPU 上直接渲染。速度上来之后,泼溅数量、最小尺寸、硬度、跨分辨率平滑等参数就都能实时调整了——这些控制项大多松散对应着物理绘画概念:笔刷大小、载色量、罩染透明度、笔触长度、手的稳定度。作者的结论是:在生成式图像模型的时代,用传统图像变换技术能做到什么依然很有趣;分析图像的边缘和纹理提取出的结构,给出了画家用来决定笔触落点的大部分相同信息,从这个意义上说,这个算法确实是在做数字绘画。项目已开源在 GitHub。
HN 评论精华
这条帖子 115 分,讨论量不大但质量很高,作者 yogthos 几乎回复了每一条。
开场就是「为什么不直接用 AI」的质疑。 MeteorMarc 问:这本质上是在把画家的隐性知识全部编码出来,用「真实照片—画作」配对去微调一个图像生成模型不是更容易吗?yogthos 的回答很有代表性:生成式 AI 当然已经能做类似的事,但整个项目的意义就在于看看用传统技术能做到什么程度;另外的好处是它可以在本地运行、不需要大量资源,而且你对输出的形态有完全的控制权。「你甚至可以把两者结合——让模型生成一张图,再让这个工具重新画它。归根结底,重点是玩得开心,试试把这些技术组合起来会怎样。」jszymborski 干脆直接把原文结尾那两段引了回来作为回答。
最有价值的是 vanderZwan 这位有艺术学位的评论者的两条长评。 关于分层,他补充了一个绘画史的维度:油画与丙烯的一个区别在于前者透明度更高,而后者更容易层层覆盖下层;他在艺术学院的一位画家导师认为,正是油画的这种透明性让它能极好地模拟真实皮肤的质感——熟练的画家能借此制造出类似次表面散射的效果。这也是为什么真实画作看起来与屏幕图像如此不同(除了远超最好色屏的色域,还有颜料本身的表面质感)。他由此推断,作者的高斯泼溅在性质上更接近「油画」,并追问能否把这种次表面散射和质感也编码进去,让在 3D 中观看这些泼溅时重现看真画的感觉。他还提了一个作者当场采纳的建议:能否让算法输出 SVG?那样它还能当作一种艺术性的放大滤镜。yogthos 回应说层次本来就在,搭个 3D 场景加光源就能试;SVG 的想法「非常巧妙」,他会开个 issue 试试。这条评论末尾还附带一个无障碍提醒:作为红色弱视者(protanomaly),他看不出博客里的链接颜色——那些链接在他眼里是与白色正文亮度几乎相同的亮灰色,「这可能影响你博客 5% 到 10% 的男性访客」。作者道谢并表示会调整主题配色。
关于「像不像笔触」的技术批评相当到位。 swiftcoder 说效果在前景(尤其是狼和猫那两张)不错,但它极大地夸张了背景景深,那些区域「感觉像是先做模糊再做色调分离,而不是笔触」,猜测是作者偏爱散景照片的副作用,东京那张(作者更正:是香港)全图都不错。yogthos 说这更多是他的风格选择,加更多层就能充实背景细节。swiftcoder 进一步说明他真正的问题:那些区域给不出笔触的印象,而在真实画作里,即便是大块平坦色域也会有明显的笔触痕迹。vanderZwan 给出了原因诊断:高斯泼溅本质上是没有自身纹理的椭圆,缺少真实笔触的所有细节;在精细处椭圆被拉得很长,所以能感觉出层叠的笔触,但在粗糙的背景里泼溅的「平面感」占了上风。他还拿自己的点彩(stippling)notebook 类比,那个更简化的滤镜有同样的问题。shen 也建议示例应该挑一些不是以散景为主的图片;yogthos 解释输入用的都是他自己的照片,他偏好主体隔离的构图,但算法对细节丰富的图像并无差别,末尾那张香港的照片就是例证。
同类项目对照。 mkaic 分享了自己 2023 年受原始 3DGS 论文启发做的梯度下降版实验 painterbot,用了从后往前的 alpha 混合和极坐标高斯来做可变曲率的笔触。yogthos 说看它作画的动画过程很有趣。vanderZwan 对那段动画的观感很妙:作为学艺术出身的人看着它构建图像「感觉非常非常怪」——受过训练的人作画的过程有点像 MSB 基数排序让数组逐渐变得有序,从粗到精;而这个算法建立图像的方式「像是在看一个交替使用稳定 MSB 和 LSB 基数排序的基数排序,怪得要命,但真的很迷人」。
teodosin 留下了一个作者尚未回复的问题,也是最实际的:GPU 实现的每帧耗时是多少?有没有希望作为游戏的后处理跑到 60fps?能否用 3D 场景本身的数据跳过前面部分的分析步骤?andai 则代表了另一类读者的感受:「结果比我预期的好太多了。我对高斯一无所知,但我在图像编辑器里试过的大多数『绘画』效果都不令人满意,这个看起来很棒。」