我一只觉得 digital painting 的笔刷引擎很好玩,甚至有点神秘。虽然其实粗略来讲大部分引擎都逃不开把很多笔刷头 (tip) 重复叠加,然后再结合材质 (texture) 得到渲染结果这个基本套路,但其实这个基本框架下面有很多细小的 design decision,会直接导致最终整个引擎究竟是否可用。这里有三类人:一是设计和开发引擎的程序员;二是根据引擎设计具体笔刷的笔刷设计师;三是艺术家。这三类人在技能树上经常并没有很明显的重叠,最终能互相配合得好,发展出一个完整的生态,感觉还是很了不起的。
当然真正的艺术家不管是用树枝还是用 MS Paint 其实都能画;反过来讲基本上任意一个引擎只要实现了足够的图像处理功能其实也都 technically 互相 equivalent。但“技术上能做”和“实际中是否适合做”之间其实有很大的鸿沟,就好像一个能坐和好坐的椅子之间的差别,这是需要“设计”来填补的。不过这里扯得有点远了,待会再讨论这个问题。
回到笔刷的问题上,例如上面这个类似于毛笔的笔刷。究竟要如何实现这样的效果呢?这里主要有两个流派:一是直接从引擎层面对传统笔刷进行模拟,例如模拟很多毛发的运动和互相影响之类的;二是在传统的 tip 叠加模型的基础上增加一些简单的额外功能,由笔刷设计师根据这些原始组件来做一些创造性的组合,直接“伪造”毛笔笔刷的渲染效果。有点像建立坐标系暴力求解的解析几何和画几根巧妙的辅助线真相突然大白的平面几何之间的区别。
我也觉得后者更有意思一些,而且现在物理模拟效果和性能各方面综合考量,大部分绘图软件都是走的第二条路。况且让 digital painting 引擎去模拟物理笔刷有一点本末倒置的感觉。
所以究竟怎么样“伪造”出毛笔的样子来呢?基于一个笔划是很多个笔头图像的叠加这个基本结构,做一个简陋的像刷子一样的笔刷很容易,只需要让笔头图像本身是栅格状,然后让笔头旋转按照笔划路径改变,例如用左边的笔头就可以做出这样的“耙子”笔刷。
但是耙子和毛笔的区别在于耙子是硬的不会改变形状,但毛笔的毛是软的会动来动去一会儿分开一会儿黏在一起,所以毛笔笔划有孔洞,但孔洞是随机的。几乎所有的笔刷引擎都支持随机数,例如可以让笔刷头随机旋转。下图分别是随机旋转角度较小和较大的两种情况:
中间的空洞被随机性打破,不再像耙子了,但很显然也不像毛笔。因为笔刷头是 uniformly random 旋转的,并没有任何结构或者连续性可言。那是不是可以在随机数里加上结构性或者连续性?通过 random field (例如 fBm) 可以实现这样的 coherent noise,听起来似乎很有用,但似乎市面上没有那个 brush engine 提供类似的功能。下图分别展示了普通 uniform random 旋转,和两种不同光滑程度的 coherent noise 旋转的效果(下面的点线图标注了采样出来的旋转角度):
似乎有点感觉了,但其实还差很多,这也许也是很多引擎都没提供这个功能的原因?到这里感觉就是像我这样缺乏创造力的人能做出的笔刷样子的极限了。这里顺便展示一下创造力和我一样匮乏,同时还没有任何审美的 agent 助手 (Fable 5 君) 搞出来的自认为完美实现毛笔效果的笔刷:
最后直接来看正确答案,解构一下 Procreate 5.5 里 ink 类里名叫 Silvereye 的笔刷是如何伪造毛笔笔触的(本文最开始的那个图里就是这个笔刷)。虽然需要很多步骤和组合才能让它足够自然好用,但核心思想其实很简单:“自然”的笔触变化和结构很难用随机数去模拟,但可以直接从大自然中借用,这里的关键不是笔刷头的形状,而是笔刷的材质。在 Procreate 里新建一个笔刷,笔刷头用圆头即可,然后选择一个有自然黑白结构的材质图(这里我直接拍了一张路边石头的照片,变成黑白):
当然上面的图看起来一点也不像毛笔,因为材质有很多种方式可以附加到笔刷上,这样全局固定坐标通常是为了伪造类似纸张的材质,比如像炭笔在粗糙纸张上涂抹会留出的孔洞。不同的笔刷引擎可能会提供不同的方式来对材质进行处理、变换、融合。在 Procreate 里有一个选项可以让材质以笔划为锚点,并沿着笔划的方向进行拉伸,下面是四种不同拉伸程度下的效果(为了容易观察,我把 flow 值调低了):
可以看到在材质的尺寸和拉伸程度到合适值的时候,笔划就看起来有点像毛刷的结构了,这里的关键点在于这些毛刷结构其实是由石头材质图里的黑白结构拉伸而来,所以继承了其 organic 的特性,比较不太有重复、机械的感觉,这就解决了最困难的问题。当然这种过度光滑的状态还称不上一个毛笔笔刷,我们需要把笔触做成墨水干掉的感觉,粗略来说这可以通过增强对比度来实现。具体到 Procreate 里,用了一个 Color Burn 的操作:
当 很小的时候,除了非常接近 1 的 值之外(非常重的墨),通通都会被压缩到零,形成空洞。实际上 Silvereye 笔刷这里使用了 的情况。照理说 是未定义的,一个可能的定义是直接将结果设为零,另一个在将 Color Burn 作为图层 blend 操作时常用的选择是 代表 mask 不存在,结果直接 pass through,也就是返回原始的 值。但 Procreate 在 layer blend 和 brush engine 上采用了不同的行为,在后者取了 的结果:对于大部分 值来说最终结果就是零,但是如果 ,结果就变成了 1。所以这里变成了对 进行二值化的一个操作符。
有了这个二值化操作符,丝滑笔刷就能变成干笔刷啦,下面是加了二值化之后,同样的材质在三种不同拉伸强度下的效果。
最右边基本上已经算是比较成形的毛笔笔刷啦,只需要在进行一些微调,例如把材质换成黑白结构更合适的素材,把笔刷头换成不规则形状,调整材质的 blend 模式,都可以在一定程度上放大由材质拉伸获得的自然毛刷结构,从而实现本文第一张图里的那个最终效果。上面三个图里笔划中间有一条实心的白线,左边的图最明显,这是因为其实 Procreate 并没有提供一个 Color Burn 的 post-process 操作符,但提供了将两个笔刷组合在一起的 dual brush 功能,而组合的时候可以选择 Color Burn 作为其中一个组合模式,于是只要做一个 size 比较小的笔刷进行组合,小笔刷没有覆盖到的地方就是 的情况,就变相地进行了二值化操作,而小笔刷本身 intensity 比较高的话,其实也给组合笔刷的中心提供了一个稳定的没有孔洞的支撑,反而更像真正的毛笔了。
到这里终于搞清楚 Procreate 是如何伪造毛笔形状的笔刷效果的了。但是另一个未解之谜是:这里将 Color Burn 定义为多种可能行为中的任意一种似乎都是合理的,但只有采取 的定义才能产生这个二值化操作符。如果没有这个二值化操作符,是否又要找一个完全不同的实现办法呢?类似地,如果 Procreate 没有提供沿着笔划拉伸材质的操作?或者它提供的拉伸功能会根据笔划的曲率将材质进行扭曲和差值,又是否还能做出同样的效果呢?这里似乎涉及到了一系列的 design question,几乎没有绝对的对错,但是任意一个选择也许都会导致后续的笔刷设计必须选择完全不同的方式来进行。难道引擎开发的程序员预见到了这些?亦或是笔刷设计师对底层很了解,给了程序员具体的需求和建议?又或者是一切都是偶然和创意碰撞的火花?
之所以有这些疑问其实是因为最近尝试用 agent 来造一个自己的定制 painting app,结果发现非常不容易。现阶段 Agent 本身能力缺陷当然是一方面,但更致命的是开发过程中会不断地碰到这样大大小小的 design question,如果是有严格对错之分,特别是可以写代码进行排除或者验证的情况,Agent 通常经过一番折腾之后差不多都能搞好,又或者是有非常标准的参考解决方案的情况,Agent 也可以直接借鉴。但一旦进入这两种情况之外的领域,责任就还是落到了用户自己身上,结果是我虽然对自己想要定制的方向有具体的想法,但对于笔刷引擎实现和笔刷库的构建方法并没有什么经验,所以会不停地碰到难以抉择的 design question,如果随意选择,或者根据 Agent 信心满满的推荐来,经常会到达一个完全不可用的死胡同,比如一个数学上优美的 opacity/flow 模型,但必须要加上 16 倍增率的动态曲线才能画出墨的笔刷引擎,因为 Agent 不知道人类画画并不会以一种想要把 Apple Pencil 压断的气势和力道来进行。到头来最有可能做出真正可用方案的办法还是完全模仿或复现某一个已有的成熟系统,但那样还不如直接用就好了。
感觉 Agent 给“定制”软件带来的可能性有点像 Linux / Android 与 Mac / iOS 之间的对比,可能性很多,但除非是本身就有很强的 design sense,或者是真正完美知道自己详细需求的用户,否则过多的可能性反而会带来更多的混乱。倒是工作上一位朋友的说法挺有意思:也许现阶段的 Agent 带给我们的恩惠并不是个人定制的软件这个最终产品,而是在合作开发过程中你学到的新知识或者对于自己真正诉求的深入了解。