RandomYang

Code + Design => Magic

有趣的片元着色器

引言

shader 编程是图形学编程中的重要环节,在 shader 代码中可以实现各种各样简单复杂的计算机图形学算法。本文通过列举一些简单的 shader 代码实例,看看能不能激发你学习的shader编程的兴趣。此外shader 的灵活性让我们除了实现各种传统的算法,还可以发挥想象力实现更多有趣的图形。

这不是一个从零开始的着色器教程,关于着色器网络上也有很多优秀的资料教程,读者可以提前搜索一下。不过这里还是带大家了解一下和本文相关的基础知识。

基础知识(熟悉可跳过)

  • 着色器程序一般分为两种,一个是顶点着色器(vertex shader),一个是片元着色器(fragment shader)。本文主要涉及到的是片元着色器。
  • 片元着色器编程的过程可以简单抽象为:gl_FragColor = fragment_shader(gl_FragCoord, …other_parameters),其中fragment_shader就是我们需要编写的片元着色器代码。gl_FragColor则是输出的某个像素点颜色,gl_FragCoord是某个点的坐标,以及other_parameters表示各个来源的其他入参。
  • 编写着色器程序用到的一门编程语言叫做glsl,语法和C系语言很像,且是强类型。
  • 一些常用的基本类型:int整型,float浮点数,vec(2/3/4)分别表示二维、三维、四维向量。(本文涉及到最多的是vec2)。
  • uniform变量:
uniform [变量类型] [变量名称]

这种方式定义的变量,其值不是由着色器来决定的,而是由外部环境通过某种方式传入的。例如web中用js通过的webGL Api或者是桌面端c++通过openGL Api等等。所以这种变量在着色器中通常都是拿过来用的只读变量。

  • 全局变量

    • gl_FragCoord:该变量由运行环境给出,表示当前坐标
    • gl_FragColor:该变量由运行环境给出,通常会被赋值为最终输出的颜色

分类实例

每一个实例都是由图片 + 代码 + 小段解释 + 在线demo组成。

Color Map

precision highp float; // 声明float的精度
  
// 外部环境与shader单向通信的变量
// 这个变量直接在shader中使用就好了
uniform vec2 u_size;   // 当前画布的尺寸

void main() {
    vec2 rg_size = gl_FragCoord.xy / u_size; // x,y分量限制在[0,1]
    gl_FragColor = vec4(  
        0.0,
        rg_size.x,
        rg_size.y, 
        1.0
      );
}

gl_FragCoord表示的是当前片元着色器处理的片元(暂时理解为像素)的坐标,是一个vec4类型的变量(x, y, z, 1/w),目前你只需要知道gl_FragCoord.xy表示取出第1和2两个分量,分别表示水平坐标x和纵坐标y(坐标原点位于左下角)。

这一段代码非常的简单,首先通过除以画布尺寸将坐标限制在[0, 1]的范围之内,然后将得到的结果的xy分量分配给保留的全局变量gl_FragColor。也就是,无论你前面进行了多么复杂的计算过程,最终都是要通过这一个全局变量来将颜色输出,否则你的着色器程序是没有意义的。

关于颜色分布,我们可以看几个特殊点——四个角。左下角vec4(0.0, 0.0, 0.0, 1.0)黑色,左上角vec4(0.0, 0.0, 1.0, 1.0)蓝色,右下角vec4(0.0, 1.0, 0.0, 1.0)绿色,右上角vec4(0.0, 1.0, 1.0, 1.0)青色。

你可以尝试调换0.0、rg_size.x、rg_size.y三者的位置,看看会得到什么样不同的颜色分布。

Demo: https://observablehq.com/d/cd4c47d15af19c1f

三角函数曲线

precision highp float; // 声明float的精度
#define PI 3.14159265359
  
// 外部环境与shader单向通信的变量
uniform vec2 u_size;   // 当前画布的尺寸
uniform float u_dpr;   // 绘制环境的devicePixelRatio

// 定义一个函数
// value 待测值
// target 目标值
float smoothstep_filter(float value, float target){
    return smoothstep(target - 0.01, target, value) - smoothstep(target, target + 0.01, value);
}

void main() {
    float rate = 100.0 * u_dpr;
    vec2 st = gl_FragCoord.xy / vec2(rate);               // 减小画布坐标数值范围
    vec3 color = vec3(0.0);                               // 黑色背景
    vec3 line_color = vec3(38.0, 204.0, 213.0) / vec3(255.0);

    float y = sin(st.x) + (u_size.y / rate) * 0.5;        // 一个周期为2PI, 振幅为1的sin三角函数
    // float y = tan(st.x) + (u_size.y / rate) * 0.5;     // 一个周期为PI的tan三角函数
    
    float percent = smoothstep_filter(st.y, y);
    color = mix(color, line_color, percent);
    
    gl_FragColor = vec4(color, 1.0);
}

平时学习一些图像绘制的时候总是绕不开的一个东西就是三角函数曲线,以正弦为例,y = sin(x),这是一个周期为2PI,振幅为1的正弦函数。不过想要在shader中绘制这样一根简单的曲线,似乎还不那么直观。

首先,我们定义了一个函数plot用与判断当前坐标是否在目标值的范围内,返回一个[0, 1]之间平滑后的值。这里用到了一个shader内置的函数——smoothstep,用于生成平滑的插值。

然后在main函数当中调用plot函数得到颜色混合的百分比percent,最后再调用一个shader内置函数mix,该函数用于线性插值。将插值得到的颜色赋给gl_FragColor

同样你可以尝试不同的三角函数,例如余弦函数、正切函数等等。

Demo: https://observablehq.com/d/43753a27f77906b3

极坐标曲线

precision highp float; // 声明float的精度
#define PI 3.14159265359

// JS与shader单向通信的变量
uniform vec2 u_size;   // 当前画布的尺寸
uniform float u_dpr;   // 绘制环境的devicePixelRatio

// 根据输入的value和目标value
// 返回位于目标点[0.0, +half_pi]范围内的突变值
float step_filter(float value, float taget_value){
    return step(taget_value, value) - 
           step(taget_value + 0.2, value);
}

// color pattern
vec3 color_1 = vec3(45.0, 89, 198) / vec3(255.0);
vec3 color_2 = vec3(49, 142, 222) / vec3(255.0);
vec3 color_3 = vec3(38, 205, 213) / vec3(255.0);
vec3 color_4 = vec3(118, 224, 214) / vec3(255.0);

void main() {
    vec2 st = gl_FragCoord.xy/u_size.xy;
    vec3 color = vec3(0.0);
    
    vec2 pos = vec2(0.5) - st;                        // 中心指向当前坐标点的向量
    float distance = length(pos)*2.0;                 // 中心点到当前坐标点的距离
    
    float alpha = atan(pos.y,pos.x);                  // 当前位置对应的旋转角
    float f = tan(alpha * 5.);                        // 当前角度对应的函数值
        
    color = mix(color, color_1, step_filter(distance, f));  // 比较实际距离 distance 和函数值 f 的大小
    gl_FragColor = vec4(color, 1.0);                        // 进而影响当前像素的最终颜色
}

绘制极坐标曲线的关键在于得到当前点到坐标原点的距离distance以及目标距离值f,然后通过step_filter函数获取需要插值的值,最后用mix函数进行插值,将得到的颜色赋值给gl_FragColor。

这里用到的极坐标表达式为 f = tan(alpha * 5),你还可以尝试其他的函数例如 f = 1.0,这将绘制一个正圆。

Demo: https://observablehq.com/d/0ed9b475e8bc72af

噪声

precision highp float; // 声明float的精度
#define PI 3.14159265359

// JS与shader单向通信的变量
uniform vec2 u_size;   // 当前画布的尺寸

// 2D Random
float random (in vec2 st) {
    return fract(sin(dot(st.xy,
                         vec2(12.9898,78.233)))
                 * 43758.5453123);
}

// 2D Noise based on Morgan McGuire @morgan3d
// https://www.shadertoy.com/view/4dS3Wd
float noise (in vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);

    // Four corners in 2D of a tile
    float a = random(i);
    float b = random(i + vec2(1.0, 0.0));
    float c = random(i + vec2(0.0, 1.0));
    float d = random(i + vec2(1.0, 1.0));

    // Smooth Interpolation

    // Cubic Hermine Curve.  Same as SmoothStep()
    vec2 u = f*f*(3.0-2.0*f);
    // u = smoothstep(0.,1.,f);

    // Mix 4 coorners percentages
    return mix(a, b, u.x) +
            (c - a)* u.y * (1.0 - u.x) +
            (d - b) * u.x * u.y;
}

void main() {
    vec2 st = gl_FragCoord.xy/u_size.xy;

    // Scale the coordinate system to see
    // some noise in action
    vec2 pos = vec2(st*400.0);

    // Use the noise function
    float n = noise(pos);

    gl_FragColor = vec4(vec3(n), 1.0);
}

这个例子是关于噪声的。我们首先定义了两个函数,一个random函数,用于根据一个二维向量生成一个float随机值,然后定义了一个noise函数,用于根据二维向量生成一个float噪声值。

然后,我们通过noise函数来对放大后的实际坐标pos进行干扰,将干扰得到的结果n,赋值给gl_FragColor,最终出来的效果如上图(笔者感觉很像小时候看的彩电没有型号的时候的画面)。

Demo: https://observablehq.com/d/7ff962a5ca69b910

分形

// https://www.shadertoy.com/view/lsX3W4

precision mediump float;

uniform vec2 u_size;
uniform float u_time;
float fixedTime = u_time / 2.0;


float distanceToMandelbrot(in vec2 c)
{
#if 1
  {
    float c2 = dot(c, c);
    // skip computation inside M1 - http://iquilezles.org/www/articles/mset_1bulb/mset1bulb.htm
    if (256.0 * c2 * c2 - 96.0 * c2 + 32.0 * c.x - 3.0 < 0.0)
      return 0.0;
    // skip computation inside M2 - http://iquilezles.org/www/articles/mset_2bulb/mset2bulb.htm
    if (16.0 * (c2 + 2.0 * c.x + 1.0) - 1.0 < 0.0)
      return 0.0;
  }
#endif

  // iterate
  float di = 1.0;
  vec2 z = vec2(0.0);
  float m2 = 0.0;
  vec2 dz = vec2(0.0);
  for (int i = 0; i < 300; i++)
  {
    if (m2 > 1024.0)
    {
      di = 0.0;
      break;
    }

    // Z' -> 2·Z·Z' + 1
    dz = 2.0 * vec2(z.x * dz.x - z.y * dz.y, z.x * dz.y + z.y * dz.x) + vec2(1.0, 0.0);

    // Z -> Z² + c
    z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;

    m2 = dot(z, z);
  }

  // distance
  float d = 0.5 * sqrt(dot(z, z) / dot(dz, dz)) * log(dot(z, z));
  if (di > 0.5)
    d = 0.0;

  return d;
}

void main()
{
  vec2 p = (2.0 * gl_FragCoord.xy - u_size.xy) / u_size.y;

  // animation
  float tz = 0.5 - 0.5 * cos(0.225 * fixedTime);
  float zoo = pow(0.5, 13.0 * tz);
  vec2 c = vec2(-0.05, .6805) + p * zoo;

  // distance to Mandelbrot
  float d = distanceToMandelbrot(c);

  // do some soft coloring based on distance
  d = clamp(pow(4.0 * d / zoo, 0.2), 0.0, 1.0);

  vec3 col = vec3(d);

  gl_FragColor = vec4(col, 1.0);
}

这个例子放在这里是觉得很有趣,且为了图形分类的完整性。有兴趣深入研究其算法的可以看看这篇https://www.iquilezles.org/www/articles/distancefractals/distancefractals.htm。(这段代码我就不解释,因为笔者也没怎么看懂)

Demo: https://observablehq.com/d/2a14b96ee57a4800

图形pattern

precision mediump float;

uniform vec2 u_size;
uniform float u_time;
float fixedTime = u_time / 2.0;

float width_1 = 2.0;
float width_2 = 2.0;
float f_x = 100.0;
float f_y = 100.0;

void main()
{
  vec3 color = vec3(1.0);

  // x方向上满足
  if(mod(gl_FragCoord.x, f_x) <= width_1) {
    color = vec3(0.0);
  }
  // y方向上满足
  if(mod(gl_FragCoord.y, f_y) <= width_2) {
    color = vec3(0.0);
  }

  gl_FragColor = vec4(color, 1.0);
}

pattern通常是由周期性重复的图案组成,这里的例子很简单,周期性最小重复单元可以看作是一个黑色的方框。

主要逻辑分为两个部分,一个是x方向,另一个是y方向。用到了条件if语句和内置求余函数mod。该实例建议打开demo链接调节几个参数看看实际的效果会发生什么变化。

Demo: https://observablehq.com/d/5f64a6701696c782

时间系数

precision highp float; // 声明float的精度

// 与JS单向通信的变量
uniform vec2 u_size;   // 当前画布的尺寸
uniform float u_time;  // 一个随时间变化的量

void main() {
    vec2 rg_size = gl_FragCoord.xy / u_size; // x,y分量限制在[0,1]
    gl_FragColor = vec4(  
      abs(sin(u_time)),  // 加入时间系数
      rg_size.x,
      rg_size.y, 
      1.0
    );
}

截止到目前的例子都是静态的图形,在静态的基础上加上一个随时间变化的量u_time即可作出简单的动画效果。

这里我们以最简单的Color Map为例,将之前的0.0替换为 abs(sin(u_time)) 即可得到一个简单的颜色渐变动画效果。如果是更加复杂的动画效果思路都是类似的——将时间系数引入到颜色计算的过程当中去。

Demo: https://observablehq.com/d/dbee4f41ec1c28bc

彩蛋——组合

看完了上面的“基础”,我们很容易想到将某些方式进行组合,也许会产生一些新的效果。下面就是笔者自己组合出的一些结果,有些挺出乎意料的。(代码都比较长,而且与前面的实例有很大部分重叠,就不放代码了,看代码直接去到链接。)

三角函数 + 时间系数

Demo: https://observablehq.com/d/8800b295dd539047

Pattern + 噪声

Demo: https://observablehq.com/d/a3dce179e339ee5a

极坐标曲线 + 噪声

Demo: https://observablehq.com/d/a5d33e8a3b5566c4

三角函数 + 噪声

Demo: https://observablehq.com/d/a36424c0af13a391

三角函数 + 过滤函数 +噪声

ps:这线段上的四种颜色你眼熟吗(狗头)

Demo: https://observablehq.com/d/adfc9b466b450ff9

More and more…

组合方式远不止笔者列举出的这些,更多可能性留给读者自己去探究。

参考

https://thebookofshaders.com
www.shadertoy.com

总结

shader编程思想和语法相对稳定,学习了shader编程你既可以给webGL编写shader也可以给openGL编写shader,甚至是游戏引擎环境下编写shader等等。并且其对于像素的控制能力可以让你做出很多惊人的效果。

当然了,我们学习shader编程并非只是是为了写这些“花哨”的图形,而是让你对于渲染有了更强的控制能力。