写在前面
我在9月份的时候对博客的主页换了个模板,一些童鞋可能会发现边栏多了个小雨伞的动画,再细心的同学可能会发现如果一直开着我的博客电脑耗电更快了……当然啦,也有可能你看到的是一团黑,这说明你该换更高级的浏览器啦!
前几天有人问我这个是怎么实现的,其实我一直都想写一下的。这个例子的灵感是我暑假的时候喝了一杯奶茶(我超爱喝奶茶……),然后觉得杯子上的图案非常可爱(有点类似于吉米的那种画风)。当然啦,用我相信用PS很多人都可以画出来,后来我就想可以不可以在shadertoy上把这个图画出来?于是就有了画卡通伞的想法。
事实上,我和很多人一样,一直惊叹于shadertoy上那些宏大的场景竟然没有使用任何传统模型,而是用代码写出来的。其中一种重要的方法就是使用distance field。而雨伞其实二维的distance field的一个简单应用。
P.S. 如果你很好奇ShaderToy上那些效果都是怎么写的,可以看两个创始人在Siggraph Asia 2014上的一个课程(而且是在中国深圳讲的呦)—— Learn to Create Everything in a Fragment Shader。
我的Umbrella:
再P.S. 很兴奋的是,Iq给这个作品留言啦,被偶像说cute好开心呀,哇哈哈哈
再再P.S.小雨伞现已加入我的Github项目Shadertoy_Lab(https://github.com/candycat1992/Shadertoy_Lab)。
什么是Distance Field
Distance Field(中文翻译为距离场?)的含义很好理解,我们可以用它来判断一个点是否在一个区域内。我们往往用一个函数来表示某个需要绘制图形的distance function,然后把屏幕上某点的位置代入计算,如果得到的值为负,那么该点就在该图形内部,如果为正,就在图形外部。这种思想看似很简单,但实际上当使用一些复杂的distance function后,就可以得到非常复杂的场景,再配合使用一些光照、图形处理的技术,就可以得到非常出色的画面效果。Iq(Inigo Quilez,Shadertoy的创始人之一)在他的博客里概述过distance field的技术。感兴趣的一定要去拜读一下。
在Umbrellar的例子里,我实际上只是非常简单地应用了一下二维空间里的distance field。这些效果都是由简单的圆、椭圆、有宽度的线段变化而来的,配合使用了并集(Union)、交集(Intersection)、差集(Difference)的操作。这些变化大部分是使用了正弦函数和一些简单的线性方程(例如伞上的条纹),只是为了得到比较好的效果需要不断尝试各种参数。
我是怎么实现的
我一开始就计划伞大概可以用一些基本图元来表示,例如伞的主题可以用两个椭圆的交集画,伞柄可以用线段+圆的并集+差集,至于伞上面的条纹其实也是很多椭圆的交集+差集画出来的。看到这里有些人可能会觉得有些混乱,实际上你在脑海里想象一下这些图元的组合关系就可以明白了。
所以,我只需要对三个图元——圆、椭圆和线段定义它们的distance function就可以了(实际上椭圆是圆的超集,但为了方便我还是把圆和椭圆分开了):
float sdfCircle(vec2 center, float radius, vec2 coord) {
vec2 offset = coord - center;
return sqrt((offset.x * offset.x) + (offset.y * offset.y)) - radius;
}
float sdfEllipse(vec2 center, float a, float b, vec2 coord) {
float a2 = a * a;
float b2 = b * b;
return (b2 * (coord.x - center.x) * (coord.x - center.x) + a2 * (coord.y - center.y) * (coord.y - center.y) - a2 * b2)/(a2 * b2);
}
float sdfLine(vec2 p0, vec2 p1, float width, vec2 coord) {
vec2 dir0 = p1 - p0;
vec2 dir1 = coord - p0;
float h = clamp(dot(dir0, dir1)/dot(dir0, dir0), 0.0, 1.0);
return (length(dir1 - dir0 * h) - width * 0.5);
}
上面的代码都很简单,就是利用了圆和椭圆的公式,线段就是斜截式的变种,在之前的文章中都看过很多次。只是需要注意,distance function要保证图元内部的点返回值小于0,外部则大于0,顺序不要搞反。
有了距离,我们就可以据此来画图了。render函数就是做这个用的:
vec4 render(float d, vec3 color, float stroke) {
//stroke = fwidth(d) * 2.0;
float anti = fwidth(d) * 1.0;
vec4 colorLayer = vec4(color, 1.0 - smoothstep(-anti, anti, d));
if (stroke < 0.000001) {
return colorLayer;
}
vec4 strokeLayer = vec4(vec3(0.05, 0.05, 0.05), 1.0 - smoothstep(-anti, anti, d - stroke));
return vec4(mix(strokeLayer.rgb, colorLayer.rgb, colorLayer.a), strokeLayer.a);
}
render接受三个参数,第一个就是距离值,第二个是需要绘制的颜色,第三个描边的宽度。我首先在render函数里绘制绘制颜色层,并进行了抗锯齿处理(原理参见之前的文章:http://blog.csdn.net/candycat1992/article/details/44673819),然后判断需不需要描边。如果需要的话在下面首先绘制一层描边层,再把颜色层混合上去。
有了这些函数,我们就可以在屏幕上画一些基本的图元了,例如:
那么,伞在哪里呢?完全看不到嘛!别着急,我们还需要把这些基本的图元组合起来,这可以依赖基本的并集、交集和差集操作:
float sdfUnion( const float a, const float b ) {
return min(a, b);
}
float sdfDifference( const float a, const float b) {
return max(a, -b);
}
float sdfIntersection( const float a, const float b ) {
return max(a, b);
}
读者如果还记得初中数学的话应该对上面的概念并不陌生。交集就是取A和B共同的部分,并集就是取A和B加起来的部分,而差集就是取A-B得到的部分。应用到distance field,那么交集就是取距离值a和b中较大的一个,这样只有其中有大于0的(在区域外)结果也会大于0,并集则取两者中较小的,这样只要其只能有小于0的(在区域内)结果也会小于0。而差集首先对b取反,即取b表示区域的补集,再和a对应的区域取交集就可以了。
上面这些函数,就是我们用到的所有函数。现在,就可以在真正开始画伞啦!
等等,在动手写代码前,我们需要首先安排下伞的各个部分的绘制部分。我把整个伞分成了三个部分:伞柄,伞身,和伞上的条纹,它们的绘制顺序也是依次从前往后。我把这三个部分绘制在不同的层上面,最后再按顺序混合它们。
伞柄
那么,第一步就是画伞柄:
vec4 main(vec2 fragCoord) {
float size = min(iResolution.x, iResolution.y);
float pixSize = 1.0 / size;
vec2 uv = fragCoord.xy / iResolution.x;
float stroke = pixSize * 1.5;
vec2 center = vec2(0.5, 0.5 * iResolution.y/iResolution.x);
// Draw the handle
float bottom = 0.08;
float handleWidth = 0.01;
float handleRadius = 0.04;
float d = sdfCircle(vec2(0.5-handleRadius+0.5*handleWidth, bottom), handleRadius, uv);
float c = sdfCircle(vec2(0.5-handleRadius+0.5*handleWidth, bottom), handleRadius-handleWidth, uv);
d = sdfDifference(d, c);
c = uv.y - bottom;
d = sdfIntersection(d, c);
c = sdfLine(vec2(0.5, center.y*2.0-0.05), vec2(0.5, bottom), handleWidth, uv);
d = sdfUnion(d, c);
c = sdfCircle(vec2(0.5, center.y*2.0-0.05), 0.01, uv);
d = sdfUnion(c, d);
c = sdfCircle(vec2(0.5-handleRadius*2.0+handleWidth, bottom), handleWidth*0.5, uv);
d = sdfUnion(c, d);
vec4 layer0 = render(d, vec3(0.404, 0.298, 0.278), stroke);
老规矩,首先要计算当前绘制的这点在屏幕上的uv值。我们以水平方向的为基准,即变换后水平方向的值域为[0, 1],而竖直方向的要取决于分辨率。同时,为了方便后面定位一些参数和位置,我提前计算了描边的宽度值stroke,和屏幕中心的位置center。
伞柄还需要进一步细化它的结构。我是从下往上画的。首先,1)画一个落空的圆圈(对两个圆去差集),2)再去掉上半部分只留下半部分(交集),3)之后画一条表示主杆的线段(取并集)。4)伞头我想用一个更大的圆表示,所以又画了一个半径更大的圆(取并集)。5)最后,手握的那里有些突兀,所以又使用了一个圆(取并集)。得到最后的距离值后,使用棕色绘制出来,伞柄部分完成。这个过程可以用下面的图展示。当然啦,里面的位置和参数都是手调的,试了很多次,还要考虑屏幕分辨率的变化。
伞身
和伞柄相比,伞身就更加简单了。只需要用两个长短轴不同的椭圆,然后对它们取交集即可:
float a = sdfEllipse(vec2(0.5, center.y * 2.0 - 0.34), 0.25, 0.25, uv);
float b = sdfEllipse(vec2(0.5, center.y * 2.0 + 0.03), 0.8, 0.35, uv);
b = sdfIntersection(a, b);
vec4 layer1 = render(b, vec3(0.32, 0.56, 0.53), fwidth(b) * 2.0);
当然,里面的参数也是手调的,凭感觉。这里描边的时候没有用之前计算的宽度定值strock,就因为椭圆函数的距离值并不是线性的,所以使用定值会使得描边得到的宽度不一致。所以改用导数了。
绘制完这一步就可以得到下面的效果了。
条纹
实际上,条纹才是整个shader里最麻烦的部分。我一开始就想到使用正弦函数来模拟波浪的效果,但是为了让这些条纹有从上到下逐渐加宽、弧度逐渐增大的效果,还是调了很一会。
// Draw strips
vec4 layer2 = layer1;
float t, r0, r1, r2, e, f;
vec2 sinuv = vec2(uv.x, (sin(uv.x * 40.0) * 0.02 + 1.0) * uv.y);
for (float i = 0.0; i < 10.0; i++) {
t = mod(iGlobalTime + 0.3 * i, 3.0) * 0.2;
r0 = (t - 0.15) / 0.2 * 0.9 + 0.1;
r1 = (t - 0.15) / 0.2 * 0.1 + 0.9;
r2 = (t - 0.15) / 0.2 * 0.15 + 0.85;
e = sdfEllipse(vec2(0.5, center.y * 2.0+0.37 - t * r2), 0.7 * r0, 0.35 * r1, sinuv);
f = sdfEllipse(vec2(0.5, center.y * 2.0+0.41 - t), 0.7 * r0, 0.35 * r1, sinuv);
f = sdfDifference(e, f);
f = sdfIntersection(f, b);
vec4 layer = render(f, vec3(1.0, 0.81, 0.27), 0.0);
layer2 = mix(layer2, layer, layer.a);
}
sinuv是基础的波浪式变化的屏幕坐标,所有条纹都是由它延伸出来的。由于这些条纹是随着时间不短向下移动,所以它们的位置可以使用不同的时间点来绘制。首先,我把条纹的循环时间定义为了3秒,这个值决定了后面的许多计算,例如要安排多少个条纹、它们的间距等等。每个条纹都是由两个椭圆取差集得到的,并和之前的结果取交集来不断增加条纹。啊,这里面的椭圆参数我就不解释了,其实如果现在让我再重现之前的实现我也很难做到了……不过可以说一下基本思路。对于每个条纹,我需要确定它们的长短轴的值。由于要实现从上到下弧度逐渐增大的效果,所以长短轴应该随着时间增加而增大。这个增大的幅度是很多实验得到的结果,最后计算得到了r0和r1这两个值。两个椭圆的中心位置,x分量好说,都是0.5,y分量的比较难办,因为想要实现从上到下宽度依次增加的效果,所以也是需要和t有关,但是如果两个椭圆关于t的系数是完全相等的话就无法实现改变宽度的效果,因此最后实验得到了r2这个参数。
完成后,把这三层和背景层混合后就得到了下面这样的效果。
Gamma校正
最后,在输出前进行伽马校正,得到最终的效果。
写在最后
有没有觉得distance field很神奇?的确,数学的魅力就是这么强大,哇哈哈哈。实际上,ShaderToy上很多3D效果也是基于这样的想法。我们看到很多看似很复杂的形状,往往也是由一些非常基本的三维图元变换而来的,例如球、三角锥、圆柱、长方体等等。那些大牛的厉害之处,在于他们可以随手拈来一些数学公式,把平淡无奇的图元逐渐变化成各种不可思议的图像。当然啦,人家的基本功肯定练了很多年了。大家可以在ShaderToy上直接搜distancefield,一定有很多不错的shader可以学习!
总之,希望这篇文章可以对一些人有所帮助。以后我会写些三维的,不过最近比较忙,可能又要搁置一段时间了,没办法,感觉要学要做的好多好多好多好多……