使用 SVG 和 fragment shader

在设置页面背景时,使用 Pattern 这种平铺的效果是很常见的做法,其中使用 CSS 属性是最常规的了:

body {
    background-image: url("paper.gif");
    background-repeat: repeat;
}

除此之外,使用 SVG 和 WebGL 也能实现这种效果。

SVG Patterns

SVG 中填充对应的属性是 fill,取值除了简单的颜色,也可以通过 url 关联到某个静态资源,甚至是我们定义好的 Pattern:

<rect fill="url(#Pattern)" stroke="black" x="0" y="0" width="200" height="200"/>

声明一个 Pattern 也并不复杂,其中可以包含一些常规的 SVG 元素。例如这里我们定义每一个 Pattern 包含一个天蓝色的正方形。 唯一让人困惑的是宽高的单位。

<pattern id="Pattern" x="0" y="0" width=".25" height=".25">
    <rect x="0" y="0" width="50" height="50" fill="skyblue"/>
</pattern>

单元系统

Pattern 有自己的单元系统,体现在 patternUnits 这个属性上。默认情况下,取值为 objectBoundingBox。 在这样的单元系统下,宽高的取值范围就是 0-1。例如我们想让 Pattern 在 xy 方向各平铺四次:

<pattern id="Pattern" x="0" y="0" width=".25" height=".25" patternUnits="objectBoundingBox">

patternUnits 的另一个取值 userSpaceOnUse,在使用时需要根据最终画布的尺寸计算 Pattern 的宽高。例如在 200*200 的场景中,要实现同样平铺四次的效果:

<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg" version="1.1">
<defs>
    <pattern id="Pattern" x="0" y="0" width="50" height="50" patternUnits="userSpaceOnUse">

See the Pen SVG Patterns by xiaop (@xiaoiver) on CodePen.

下面让我们用 WebGL 来实现更加复杂的效果。

WebGL Shader

仅用 fragment shader 就可以实现 Pattern 效果。

首先,需要将整个空间切割成同样大小的块,使用内置 fract 函数可以实现这一点。 例如,将初始坐标空间放大到 3 倍,然后通过 fract 取得小数部分。这样后续只需要考虑坐标范围在 0-1 的小块即可。

uniform vec2 u_resolution;

void main() {
	vec2 st = gl_FragCoord.xy/u_resolution;
    vec3 color = vec3(0.0);

    st *= 3.0;      // Scale up the space by 3
    st = fract(st); // Wrap arround 1.0

    // Now we have 3 spaces that goes from 0-1
    color = vec3(circle(st,0.5));

	gl_FragColor = vec4(color,1.0);
}

至于画圆形,这里使用 step 函数根据每个 frag 到圆心 (0.5, 0.5) 的距离决定是否涂色。 由于背景是黑色,需要用 1- 得到反色白色。使用 smoothstep 能让圆形边缘稍微圆滑一些。

float circle(in vec2 _st, in float _radius){
    vec2 l = _st-vec2(0.5);
    return 1.-smoothstep(
        _radius-(_radius*0.01),
        _radius+(_radius*0.01),
        dot(l,l)*4.0);
}

交错的平铺效果

利用简单的平移变换,可以得到交错的平铺效果。将之前的圆形向上下左右四个方向平移:

float circlePattern(vec2 st, float radius) {
    return circle(st+vec2(0.,-.5), radius)+
        circle(st+vec2(0.,.5), radius)+
        circle(st+vec2(-.5,0.), radius)+
        circle(st+vec2(.5,0.), radius);
}

修改背景和圆形图案的颜色也很容易,利用内置函数 mix 可以实现:

color += mix(vec3(0.075,0.114,0.329),vec3(0.973,0.843,0.675),circlePattern(grid1,0.224));

增大半径让四个圆互相重叠,可以得到如下效果:

值得一提的是,我在 shader toy 上得到了一位名叫 FabriceNeyret2 的开发者指点,比如:

  1. 1-smooth(a,b) = smooth(b,a), 所以上面的 circle 函数可以改写
  2. 关于抗锯齿。上面原始的例子中,如果仔细观察,圆形边缘的锯齿感很强。这是由于选择了 _radius*0.01 这样的 Magic Number,当分辨率变高半径变大时,smoothstep 的插值范围也就变大了。选择基于分辨率的两个临界值能优化这一点:
     float circle(vec2 U, float r) {
         float p = 9./iResolution.y;
         return smoothstep( p, - p, length(U) -r );
     }
    
  3. 原本需要平移四个圆,通过 U=abs(U) 的映射,只需要两个就够了。
  4. 相较于在 0-1 范围内进行操作,可以采用放大两倍的方式,避免 0.5 这样的坐标值。一来是操作方便,二来减少字符总量呀。但是要注意放大之后平移到原点:
     U = fract( U ) * 2. - 1. ;
    

短短几行就有这么大的优化空间,而且最后这位开发者提供了一个最精简的版本更是只有 180 个字符。相比我的原始 500+ 版本,不知道高到哪里去了。

圆环

知晓了圆形的画法,圆环其实就是两个圆相剪得到的区域:

color += mix(
    vec3(0.075,0.114,0.329),
    vec3(0.973,0.843,0.675),
    // 相减
    circlePattern(grid1,0.23)-circlePattern(grid1,0.170));

更加复杂的效果

结合 time 变量和时间函数,可以得到更加复杂的动画效果

其他基本图形

更多基本图形的画法,例如正方形,三角形和线段,可以参考:

都是只使用 fragment shader 完成,不需要 vertex shader 过多的参与。

总结

以上例子都是在 shader toy 上写的。参考了「the book of shaders」,原站点也提供了可在线编辑的 playground,强烈推荐阅读。

参考资料