【ShaderToy】边栏的小雨伞


写在前面


我在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可以学习!

总之,希望这篇文章可以对一些人有所帮助。以后我会写些三维的,不过最近比较忙,可能又要搁置一段时间了,没办法,感觉要学要做的好多好多好多好多……

原文地址:https://www.cnblogs.com/xiaowangba/p/6314645.html