【悄悄】【ShaderToy】小雨伞效果
写在前面
原文链接:http://blog.csdn.net/candycat1992/article/details/49389905
9月份时,我更换了博客主页的模板,一些同学可能注意到边栏新增了一个小雨伞动画。更细心的同学或许会发现,若长时间打开我的博客,电脑耗电会更快。当然,也有可能你看到的只是一团黑,这表明你需要更换更高级的浏览器了。
前几天,有人询问我这个动画是如何实现的,其实我一直都打算写一篇相关的文章。这个例子的灵感源于暑假时我喝的一杯奶茶,我觉得奶茶杯上的图案十分可爱(有点类似吉米的画风)。我相信,很多人使用PS都能绘制出这样的图案,后来我就思考能否在Shadertoy上实现它,于是便有了绘制卡通伞的想法。
实际上,和许多人一样,我一直对Shadertoy上那些宏大的场景感到惊叹,因为它们没有使用任何传统模型,而是完全用代码实现的。其中一种重要的方法就是使用距离场(Distance Field),而雨伞效果其实就是二维距离场的一个简单应用。
另外,如果你对ShaderToy上的效果实现方式感到好奇,可以查看两位创始人在Siggraph Asia 2014上的课程——Learn to Create Everything in a Fragment Shader(该课程在中国深圳举办)。
我的Umbrella作品获得了Iq的留言,被偶像称赞“cute”,我非常开心。该作品现已加入我的Github项目Shadertoy_Lab。
什么是距离场(Distance Field)
距离场(Distance Field)的概念并不难理解,它可用于判断一个点是否位于某个区域内。通常,我们会用一个函数来表示某个需要绘制图形的距离函数(distance function),将屏幕上某点的位置代入该函数进行计算。若得到的值为负,则该点位于图形内部;若为正,则位于图形外部。这种思想看似简单,但当使用一些复杂的距离函数时,就能创建出非常复杂的场景,再结合光照和图形处理技术,可得到出色的画面效果。Iq(Inigo Quilez,Shadertoy的创始人之一)在他的博客里对距离场技术进行了概述,感兴趣的读者可以去深入了解。
在雨伞的例子中,我只是简单应用了二维空间的距离场。这些效果由简单的圆、椭圆和有宽度的线段变换而来,并配合使用了并集(Union)、交集(Intersection)和差集(Difference)操作。这些变换大多使用了正弦函数和一些简单的线性方程(如伞上的条纹),为了获得较好的效果,需要不断尝试各种参数。
实现过程
基本图元的距离函数
我最初计划用基本图元来表示雨伞,例如伞的主体可以用两个椭圆的交集绘制,伞柄可以用线段和圆的并集与差集组合,伞上的条纹则可以通过多个椭圆的交集和差集绘制。
因此,我只需要为三个图元——圆、椭圆和线段定义它们的距离函数(实际上椭圆是圆的超集,但为了方便,我将圆和椭圆分开处理):
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);
}
上述代码较为简单,圆和椭圆的距离函数基于其几何公式,线段的距离函数是斜截式的变种,在之前的文章中也多次出现。需要注意的是,距离函数要确保图元内部的点返回值小于0,外部的点返回值大于0,顺序不能颠倒。
绘制函数
有了距离函数,就可以根据距离值来绘制图形了。render函数用于实现这一功能:
vec4 render(float d, vec3 color, float stroke) {
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函数接受三个参数:距离值、需要绘制的颜色和描边宽度。首先,在函数中绘制颜色层并进行抗锯齿处理(抗锯齿原理可参考之前的文章),然后判断是否需要描边。若需要,则绘制描边层,并将颜色层混合上去。
图元的组合操作
有了基本图元的绘制函数后,还需要将它们组合起来,这可以通过基本的并集、交集和差集操作实现:
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的部分。在距离场中,交集取距离值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。
伞柄的绘制从下往上进行:
- 绘制一个空心圆圈(通过两个圆的差集实现)。
- 去掉上半部分,只保留下半部分(通过交集实现)。
- 绘制一条表示主杆的线段(通过并集实现)。
- 用一个更大的圆表示伞头(通过并集实现)。
- 为了使手握部分不那么突兀,再使用一个圆(通过并集实现)。
得到最终的距离值后,使用棕色绘制伞柄。这个过程中的位置和参数都是手动调整的,需要考虑屏幕分辨率的变化。
伞身
伞身的绘制相对简单,只需用两个长短轴不同的椭圆取交集即可:
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);
这里的参数也是手动调整的。由于椭圆函数的距离值不是线性的,使用固定的描边宽度会导致描边宽度不一致,因此改用导数来计算描边宽度。
条纹
条纹是整个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是基础的波浪式变化的屏幕坐标,所有条纹都基于此延伸。由于条纹会随时间向下移动,因此根据不同的时间点来绘制条纹。每个条纹由两个椭圆取差集得到,并与之前的结果取交集以增加条纹。椭圆的参数是通过多次实验得到的,为了实现从上到下弧度和宽度逐渐变化的效果,长短轴和中心位置都与时间t有关。
完成上述步骤后,将这三层和背景层混合,即可得到雨伞的效果。
伽马校正
最后,在输出前进行伽马校正,以获得最终的效果。
写在最后
距离场技术是不是很神奇?数学的魅力就在于此。实际上,ShaderToy上的很多3D效果也是基于类似的原理,许多看似复杂的形状往往由基本的三维图元变换而来,如球、三角锥、圆柱、长方体等。那些大神们能够熟练运用数学公式,将普通的图元变换成不可思议的图像,这需要多年的基本功积累。大家可以在ShaderToy上搜索“distancefield”,学习更多优秀的shader作品。
希望这篇文章能对大家有所帮助。以后我会写一些关于三维效果的文章,但最近比较忙,可能需要搁置一段时间,毕竟要学习和实践的内容还有很多。