【ShaderToy】水彩画
作者与原文信息
- 作者:candycat1992
- 原文地址:http://blog.csdn.net/candycat1992/article/details/47777937
- 泰斗文章链接:http://www.taidous.com/forum.php?mod=viewthread&tid=32209
写在前面
许久未更新Shadertoy系列,没想到还有童鞋惦记着。之前期望一周更新一篇,如今看来不太现实,一个月更新一篇的可能性较大(大家别再轻信我啦)。
我已将之前实现的这个系列上传至GitHub(https://github.com/candycat1992/Shadertoy_Lab),感兴趣的朋友可下载查看。同时,也希望有网友能一同为该项目贡献力量。
GitHub上此项目的大部分灵感源自Shadertoy(https://www.shadertoy.com),部分是配合博客文章讲解,还有些是对原Shadertoy里的例子进行扩展。每个lab我都会在README中给出相关参考资料。
项目链接:https://github.com/candycat1992/Shadertoy_Lab
接下来,我们看看本篇要讲的例子。如标题所示,目标是模拟水彩风格效果。这里实现的是简化版本,仅涉及渲染部分。
参考资料
- https://www.shadertoy.com/view/XdSSWd
- Curtis C J, Anderson S E, Seims J E, et al. Computer-generated watercolor[C]// Proceedings of the 24th annual conference on Computer graphics and interactive techniques. ACM Press/Addison-Wesley Publishing Co., 1997.
论文研讨:Computer-Generated Watercolor
此例子源于一篇著名论文——1997年的《Computer-Generated Watercolor》。尽管年代久远,但该论文开创了用计算机模拟水彩画的先河,后续诸多相关论文都能看到它的影子。
这篇论文主要分为四个部分:
- 描述水彩颜料的物理性质,并从艺术角度阐述水彩画的风格特性。
- 给出模拟这些特性的方法。
- 具体描述对水彩和颜料(pigment)的物理模拟算法。
- 描述如何渲染这些颜料。
本文仅实现了最后一个部分,后续会简略介绍论文中其他三个方面的内容。若读者对这方面研究感兴趣,强烈建议阅读原论文。
水彩的物理属性
水彩画(watercolor paint ,简称watercolor)是常见的艺术风格。一幅水彩画涉及两种材质:
- 水彩纸(watercolor paper):并非由木材制成,而是将亚麻布或棉花捣碎成细小纤维制成。这种材质吸水性强,为防止颜料迅速蔓延,会对纸张进行上浆(sizing)处理。
- 颜料(pigment):固体材质,由众多微小的单独粒子组成。水彩颜料通常由0.05到0.5微米的粉末构成,可渗透水彩纸,附着在纸上后扩散速度会降低。
此外,水彩画有以下特点:
- 干笔画(Dry brush):用较干的画笔画在粗糙纸上,会出现不规则空隙和粗糙边界效果。
- 边界颜色较深(Edge darkening):用较湿的画笔画在较干纸面上,在纸的浆料和水的表面张力作用下,颜料不再扩散,边缘会留下颜色更深的沉淀痕迹。
模拟
论文中,作者提出用三个图层模拟水彩画中颜料的流动:
- shallow - water layer:水和颜料在纸张表面扩散流动。
- pigment - deposition layer:颜料沉淀进入和释放出纸张。
- capillary layer:被纸张吸收的水通过毛细管作用继续扩散(仅用于模拟水彩画的回流效果)。
模拟时,作者使用诸多参数控制模拟效果,如颜料扩散速度、画笔压力、纸张高度、颜料密度、液体饱和度、液体容量等。
对于纸张模拟,作者采用简单的高度场方法,结合Perlin噪声(Ken Perlin. An image synthesizer. In SIGGRAPH ’85 Proceedings, pages 287–296. July 1985.)和Worley的多孔纹理(Steven P. Worley. A cellular texturing basis function. In SIGGRAPH ’96 Proceedings, pages 291–294. 1996.)生成,这种方法较为常见。
算法
有了上述参数,便可进行算法模拟。主循环在每个时间步内进行四个计算步骤:
- 在shallow - water layer移动液体(Move Water)。
- 在shallow - water layer移动颜料(Move Pigment)。
- 在pigment - deposition layer传递颜料(Transfer Pigment):模拟颜料的吸收和释放。
- 在capillary layer模拟毛细流动(Simulate Capillary Flow):模拟回流现象等。
具体算法需参考论文,本文不涉及算法实现。
渲染
前面内容是为保证完整性,与本博客相关的仅为渲染部分。
经过上述算法处理,可得到每个区域的颜料厚度。
作者使用Kubelka - Munk(KM)模型渲染颜料。论文中,为每个颜料指定两个系数:吸收系数(absorption coefficients)K和散射系数(scattering coefficients)S。K和S均为三维属性,分别表示颜料吸收和散射的能量。
指定颜料的光学属性
虽然K和S系数通常由经验决定,但作者允许用户指定:通过选择希望的“unit thickness”(单位厚度)的颜料在黑白背景下的外观来确定。具体方法是,给定用户选择的两个RGB颜色Rw(白色背景下的颜色)和Rb(黑色背景下的颜色),K和S系数可由以下等式得出:
[ \begin{align} S&=\frac{1}{b}\cdot\coth^{-1}(\frac{b^2-(a - R_w)(a - 1)}{b(1 - R_w)})\ K&=S(a - 1) \end{align} ]
其中, [ \begin{align} a&=\frac{1}{2}(R_w + R_b-\frac{R_w + 1}{R_b})\ b&=\sqrt{a^2 - 1} \end{align} ]
作者在论文中给出了一些计算出的不同颜色、不同属性颜料的KS系数,如下所示(图片来源《Computer - Generated Watercolor》):
这些颜料类型多样,例如:
- 不透明颜料(Opaque paints):如Indian Red(图中的b),在白色和黑色区域颜色相近,具有高散射、高吸收属性。
- 透明颜料(Transparent paints):如Quinacridone Rose(图中的a),在白色背景下有颜色,在黑色背景下几乎为黑色。这种颜料的scattering波长很低,而absorption分量很高,且与颜色互补。
- 干涉颜料(Interference paints):如Interference Lilac(图中的l),在白色背景下接近白色,在黑色背景下有颜色。
光学的颜料层混合
给定一定厚度x的颜料层及其散射和吸收系数S和K,可按以下公式计算该颜料层的反射比R和透射比T:
[ \begin{align} R&=\frac{\sinh(bSx)}{c}\ T&=\frac{b}{c} \end{align} ]
其中,(c = a\sinh(bSx)+b\cosh(bSx))
对于两个相邻的层,可按以下公式计算合成后的颜料层的R和T:
[ \begin{align} R&=\frac{R_1+T_2^2R_2}{1 - R_1R_2}\ T&=\frac{T_1T_2}{1 - R_1R_2} \end{align} ]
Shader的实现
下面解释如何使用Unity Shader实现上述渲染部分。
从渲染算法可知,渲染部分主要涉及每个区域的颜料厚度x以及颜料的系数K和S。在下面的实现中,使用论文提供的一系列K和S。在shader中定义如下变量:
// Table of pigments
// from Computer-Generated Watercolor. Cassidy et al.
// K is absorption. S is scattering
// a
#define K_QuinacridoneRose vec3(0.22, 1.47, 0.57)
#define S_QuinacridoneRose vec3(0.05, 0.003, 0.03)
// b
#define K_IndianRed vec3(0.46, 1.07, 1.50)
#define S_IndianRed vec3(1.28, 0.38, 0.21)
// c
#define K_CadmiumYellow vec3(0.10, 0.36, 3.45)
#define S_CadmiumYellow vec3(0.97, 0.65, 0.007)
// d
#define K_HookersGreen vec3(1.62, 0.61, 1.64)
#define S_HookersGreen vec3(0.01, 0.012, 0.003)
// e
#define K_CeruleanBlue vec3(1.52, 0.32, 0.25)
#define S_CeruleanBlue vec3(0.06, 0.26, 0.40)
// f
#define K_BurntUmber vec3(0.74, 1.54, 2.10)
#define S_BurntUmber vec3(0.09, 0.09, 0.004)
// g
#define K_CadmiumRed vec3(0.14, 1.08, 1.68)
#define S_CadmiumRed vec3(0.77, 0.015, 0.018)
// h
#define K_BrilliantOrange vec3(0.13, 0.81, 3.45)
#define S_BrilliantOrange vec3(0.009, 0.007, 0.01)
// i
#define K_HansaYellow vec3(0.06, 0.21, 1.78)
#define S_HansaYellow vec3(0.50, 0.88, 0.009)
// j
#define K_PhthaloGreen vec3(1.55, 0.47, 0.63)
#define S_PhthaloGreen vec3(0.01, 0.05, 0.035)
// k
#define K_FrenchUltramarine vec3(0.86, 0.86, 0.06)
#define S_FrenchUltramarine vec3(0.005, 0.005, 0.09)
// l
#define K_InterferenceLilac vec3(0.08, 0.11, 0.07)
#define S_InterferenceLilac vec3(1.25, 0.42, 1.43)
对于颜料厚度,基于distance field方法,通过一些计算模拟Edge darkening效果。
首先看颜料层的反射比R和透射比T的代码:
// Kubelka-Munk reflectance and transmitance model
void KM(vec3 K, vec3 S, float x, out vec3 R, out vec3 T) {
vec3 a = (K + S) / S;
vec3 b = sqrt(a * a - vec3(1.0));
vec3 bSx = b * S * vec3(x);
vec3 sinh_bSx = my_sinh(bSx);
vec3 c = a * sinh_bSx + b * my_cosh(bSx);
R = sinh_bSx / c;
T = b / c;
}
该函数输入为该区域颜料的吸收系数K、散射系数S和颜料厚度x,输出该区域的反射比R和透射比T。代码只是代入公式计算。
另一个用于混合两个颜料层的公式代码如下:
// Kubelka-Munk model for optical compositing of layers
void CompositeLayers(vec3 R0, vec3 T0, vec3 R1, vec3 T1, out vec3 R, out vec3 T) {
vec3 tmp = vec3(1.0) / (vec3(1.0) - R0 * R1);
R = R0 + T0 * T0 * R1 * tmp;
T = T0 * T1 * tmp;
}
此函数输入为两个颜料层的反射比和透射比,输出合成层的反射比和透射比,同样是代入公式计算。
至此,渲染还需提供KM函数中的颜料厚度x。论文中该厚度通过一系列算法计算,我们的实现进行了简化,采用基于distance field的方法计算厚度。为模拟水彩风格特性,如Dry - brush和Edge darkening,我们利用噪声(模拟粗糙边界效果)和数学计算(模拟Edge darkening效果),而非原文复杂算法。
在实现中,我们在fragment shader中逐像素渲染图形。渲染图形时,需以下步骤:
- 给定渲染区域的位置pos:为模拟水彩画粗糙边缘效果,使用噪声函数处理屏幕坐标。例如:
vec2 uv = fragCoord.xy / iResolution.xy; ... pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.02 * noise2d(uv * vec2(0.1)));uv是处理后xy范围在(0, 1)的屏幕坐标。先针对屏幕长宽处理坐标,使x方向范围为(0, 1),y方向范围为(0, height/width),再对结果添加噪声处理。
噪声函数noise2d代码如下:
// Simple 2d noise fbm (Fractional Brownian Motion) with 3 octaves
float Noise2d(vec2 p) {
float t = texture2D(iChannel0, p).x;
t += 0.5 * texture2D(iChannel0, p * 2.0).x;
t += 0.25 * texture2D(iChannel0, p * 4.0).x;
return t / 1.75;
}
这是简单的噪声实现,通过对一张噪声纹理采样并使用三层octaves。不同的octave表示不同频率和振幅的噪声,组合后可得到Perlin噪声。相关内容可参考这里和这里。
- 计算距离值dist:将处理后的pos代入distance field计算,得到距离值dist。例如:
dist = DistanceCircle(pos, vec2(0.2, 0.55), 0.08);
DistanceCircle函数代码如下:
float DistanceCircle(vec2 pos, vec2 center, float radius) {
return 1.0 - distance(pos, center) / radius;
}
该函数计算pos相对于圆心在center、半径为radius的圆的距离,返回值 > 0表示在圆内,返回值 < 0表示在圆外。类似的距离计算函数还有DistanceLine(画直线)、DistanceSegment(画线段)和DistanceMountain(画由正弦函数得到的山脉)等。
- 计算颜料厚度:根据距离值dist判断点是否绘制水彩,并将dist转换为颜料厚度,通过BrushEffect函数实现。例如:
float circle = BrushEffect(dist, 0.2, 0.1);
BrushEffect函数代码如下:
// Simulate edge darkening effect
// Input: dist < 0 outer area, dist > 0 inner area
float BrushEffect(float dist, float x_avg, float x_var) {
// Only when abs(dist) < 1.0/10.0, x > 0.0
// Means that the edges have more thickness of pigments
float x = max(0.0, 1.0 - 10.0 * abs(dist));
x *= x;
x *= x;
return (x_avg + x_var * x) * smoothstep(-0.01, 0.002, dist);
}
BrushEffect函数不仅将距离值转换为颜料厚度,还模拟Edge darkening效果。输入为上一步计算的dist(dist < 0表示在渲染图形外部,dist > 0表示在内部)、平均颜料厚度x_avg和边缘厚度变化x_var。计算过程如下:
- 第一行根据dist计算初始边缘颜料厚度x,范围在(0, 1)。当dist绝对值小于1/10(靠近边界)时,x大于0;否则x为0。可调整参数10,值越小,Edge darkening范围越广。
- 后面两行对边缘颜料厚度x自乘两次,进一步收紧Edge darkening范围。
- 计算返回值,通过smoothstep函数控制厚度整体变化,当dist小于 - 0.01时返回0,大于0.002返回1,否则返回0到1之间的值。 - 0.01和0.002一正一负处理边界,正数(0.002)数值小于负数绝对值(|-0.01|),以模拟颜料在边界处扩散速度非线性下降效果。然后将该值与(x_avg + x_var * x)相乘,x_avg是渲染图形内部多数区域的颜料厚度,x_var控制边界处颜料厚度(边界处大于内部),x_var越大,边界Edge darkening效果越明显。实现中,一般取x_avg为0.2,x_var为0.1。若要模拟粗糙感,可传入噪声,例如:
float mountains = BrushEffect(dist, 0.2, 0.3 * Noise2d(uv * vec2(0.1)));
为模拟颜料不均匀分布,可对结果值进一步噪声处理,例如:
mountains *= 0.65 + 0.35 * Noise2d(uv * vec2(0.2));
注意系数0.65和0.35之和需为1,调大0.35、调小0.65,粗糙感更强烈。
- 渲染:将颜料厚度和选择的KS系数传递给KM函数得到该颜料层的反射比和透射比,若需与之前颜料层混合,再代入CompositeLayers函数。例如:
KM(K_HansaYellow, S_HansaYellow, circle, R1, T1); CompositeLayers(R0, T0, R1, T1, R0, T0);
实现效果
Shadertoy中原作者的绘制结果在Unity中重现(调整了一些参数)如下:
上述场景的绘制代码如下:
///
/// First Scene
///
// Background
float background = 0.1 + 0.1 * Noise2d(uv * vec2(1.0));
KM(K_CeruleanBlue, S_CeruleanBlue, background, R0, T0);
pos = uv + vec2(0.04 * Noise2d(uv * vec2(0.1)));
dist = DistanceMountain(pos, 0.5);
float mountains = BrushEffect(dist, 0.2, 0.3 * Noise2d(uv * vec2(0.1)));
mountains *= 0.45 + 0.55 * Noise2d(uv * vec2(0.2));
KM(K_HookersGreen, S_HookersGreen, mountains, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.02 * Noise2d(uv * vec2(0.1)));
dist = DistanceCircle(pos, vec2(0.2, 0.55), 0.08);
float circle = BrushEffect(dist, 0.2, 0.2);
KM(K_HansaYellow, S_HansaYellow, circle, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
我在原shader基础上进行了扩展,给出原论文中所有样例的KS系数和更多距离计算函数,组合后可得到更多效果。例如:
///
/// Second Scene
///
// Background
float background = 0.1 + 0.2 * Noise2d(uv * vec2(1.0));
KM(K_HansaYellow, S_HansaYellow, background, R0, T0);
// Edge roughness: 0.04
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.04 * Noise2d(uv * vec2(0.1)));
dist = DistanceCircle(pos, vec2(0.5, 0.5), 0.15);
// Average thickness: 0.2, edge varing thickness: 0.2
float circle = BrushEffect(dist, 0.2, 0.2);
// Granulation: 0.85
circle *= 0.15 + 0.85 * Noise2d(uv * vec2(0.2));
KM(K_CadmiumRed, S_CadmiumRed, circle, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
// Edge roughness: 0.03
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.03 * Noise2d(uv * vec2(0.1)));
dist = DistanceCircle(pos, vec2(0.4, 0.3), 0.15);
// Average thickness: 0.3, edge varing thickness: 0.1
circle = BrushEffect(dist, 0.3, 0.1);
// Granulation: 0.65
circle *= 0.35 + 0.65 * Noise2d(uv * vec2(0.2));
KM(K_HookersGreen, S_HookersGreen, circle, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
// Edge roughness: 0.02
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.02 * Noise2d(uv * vec2(0.1)));
dist = DistanceCircle(pos, vec2(0.6, 0.3), 0.15);
// Average thickness: 0.3, edge varing thickness: 0.2
circle = BrushEffect(dist, 0.3, 0.2);
// Granulation: 0.45
circle *= 0.55 + 0.45 * Noise2d(uv * vec2(0.2));
KM(K_FrenchUltramarine, S_FrenchUltramarine, circle, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
// Opaque paints, e.g. Indian Red
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.02 * Noise2d(uv * vec2(0.3)));
dist = DistanceSegment(pos, vec2(0.2, 0.1), vec2(0.4, 0.25), 0.03);
float line = BrushEffect(dist, 0.2, 0.1);
KM(K_IndianRed, S_IndianRed, line, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
// Transparent paints, e.g. Quinacridone Rose
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.02 * Noise2d(uv * vec2(0.2)));
dist = DistanceSegment(pos, vec2(0.2, 0.5), vec2(0.4, 0.55), 0.03);
line = BrushEffect(dist, 0.2, 0.1);
KM(K_QuinacridoneRose, S_QuinacridoneRose, line, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
// Interference paints, e.g. Interference Lilac
pos = uv * vec2(1.0, iResolution.y / iResolution.x) + vec2(0.02 * Noise2d(uv * vec2(0.1)));
dist = DistanceSegment(pos, vec2(0.6, 0.55), vec2(0.8, 0.4), 0.03);
line = BrushEffect(dist, 0.2, 0.1);
KM(K_InterferenceLilac, S_InterferenceLilac, line, R1, T1);
CompositeLayers(R0, T0, R1, T1, R0, T0);
注意上述参数调整和不同图形效果的区别,如边界颜色更深、边界粗糙感和整体颗粒感等。
完整代码可在https://github.com/candycat1992/Shadertoy_Lab中的WaterColorScene找到。
写在最后
本文实现了水彩风格的渲染部分,颜料厚度通过简单数学计算模拟,效果不够真实。若要获得更真实效果,需配合更复杂算法,可参考上述论文及其他相关论文。
这篇文章抛砖引玉,通过实现可学习KM模型在实时渲染中的应用以及噪声的简单使用。本文基于distance field方法,图形用数学表达式计算得出。读者可添加更多函数绘制复杂图形,若要实现用户交互应用,可采用其他方法计算颜料厚度。
希望本文对大家有所帮助!