You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

24 KiB

14 | 如何使用片元着色器进行几何造型?

你好,我是月影。

在WebGL中片元着色器有着非常强大的能力它能够并行处理图片上的全部像素让数以百万计的运算同时完成。但也正因为它是并行计算的所以它和常规代码顺序执行或者串行执行过程并不一样。因此在使用片元着色器实现某些功能的时候我们要采用与常规的JavaScript代码不一样的思路。

到底哪里不一样呢?今天,我就通过颜色控制,以及线段、曲线、简单几何图形等的绘制,来讲讲片元着色器是怎么进行几何造型的,从而加深你对片元着色器绘图原理的理解。

首先,我们来说比较简单的颜色控制。

如何用片元着色器控制局部颜色?

我们知道,片元着色器能够用来控制像素颜色,最简单的就是把图片绘制为纯色。比如,通过下面的代码,我们就把一张图片绘制为了纯黑色。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  gl_FragColor = vec4(0, 0, 0, 1);
}

如果想让一张图片呈现不同的颜色,我们还可以根据纹理坐标值来绘制,比如,通过下面的代码,我们就可以让某个图案的颜色,从左到右由黑向白过渡。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  gl_FragColor.rgb = vec3(vUv.x);
  gl_FragColor.a = 1.0;
}

不过这种颜色过渡还比较单一这里我们还可以改变一下渲染方式让图形呈现的效果更复杂。比如说我们可以使用乘法创造一个10*10的方格让每个格子左上角是绿色右下角是红色中间是过渡色。代码和显示的效果如下所示

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec2 st = vUv * 10.0;
  gl_FragColor.rgb = vec3(fract(st), 0.0);
  gl_FragColor.a = 1.0;
}

不仅如此我们还可以在上图的基础上继续做调整。我们可以通过idx = floor(st)获取网格的索引判断网格索引除以2的余数奇偶性根据它来决定是否翻转网格内的x、y坐标。这样操作后的代码和图案如下所示

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec2 st = vUv * 10.0;
  vec2 idx = floor(st);
  vec2 grid = fract(st);

  vec2 t = mod(idx, 2.0);
  
  if(t.x == 1.0) {
    grid.x = 1.0 - grid.x;
  }
  if(t.y == 1.0) {
    grid.y = 1.0 - grid.y;
  }
  gl_FragColor.rgb = vec3(grid, 0.0);
  gl_FragColor.a = 1.0;
}

事实上,再改用不同的方式,我们还可以生成更多有趣的图案。不过,这里我们就不继续了,因为上面这些做法有点像是灵机一动的小技巧。实际上,我们缺少的并不是小技巧,而是一套统一的方法论。我们希望能够利用它,在着色器里精确地绘制出我们想要的几何图形。

如何用片元着色器绘制圆、线段和几何图形

那接下来,我们就通过几个例子,把片元着色器精确绘图的方法论给总结出来。

1. 绘制圆

首先,我们从最简单的几何图形,也就是圆开始,来说说片元着色器的绘图过程。

一般来说我们画圆的时候是根据点坐标到圆心的距离来生成颜色的。在片元着色器中我们可以用distance函数求一下vUv和画布中点vec2(0.5)的距离,然后根据这个值设置颜色。代码如下:

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  float d = distance(vUv, vec2(0.5));
  gl_FragColor.rgb = d * vec3(1.0);
  gl_FragColor.a = 1.0;
}

通过这样的方法,我们最终绘制出了一个模糊的圆,效果如下:

为什么这个圆是模糊的呢这是因为越靠近圆心距离d的值越小 gl_FragColor.rgb = d * vec3(1.0); 的颜色值也就越接近于黑色。

那如果我们要实现一个更清晰的圆应该怎么做呢这个时候你别忘了还有step函数。我们用step函数基于0.2做阶梯就能得到一个半径为0.2的圆。实现代码和最终效果如下:

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  float d = distance(vUv, vec2(0.5));
  gl_FragColor.rgb = step(d, 0.2) * vec3(1.0);
  gl_FragColor.a = 1.0;
}


不过你会发现我们得到的这个圆的边缘很不光滑。这是因为浮点数计算的精度导致的锯齿现象。为了解决这个问题我们用smoothstep代替step。

为什么smoothstep代替step就可以得到比较光滑的圆呢这是因为smoothstep和step类似都是阶梯函数。但是与step的值是直接跳跃的不同smoothstep在step-start和step-end之间有一个平滑过渡的区间。因此用smoothstep绘制的圆边缘就会有一圈颜色过渡就能从视觉上消除锯齿。

片元着色器绘制的圆,在构建图像的粒子效果中比较常用。比如,我们可以用它来实现图片的渐显渐隐效果。下面是片元着色器中代码,以及我们最终能够实现的效果图。

#ifdef GL_ES
precision highp float;
#endif

uniform sampler2D tMap;
uniform vec2 uResolution;
uniform float uTime;
varying vec2 vUv;

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

void main() {
    vec2 uv = vUv;
    uv.y *= uResolution.y / uResolution.x;
    vec2 st = uv * 100.0;
    float d = distance(fract(st), vec2(0.5));
    float p = uTime + random(floor(st));
    float shading = 0.5 + 0.5 * sin(p);
    d = smoothstep(d, d + 0.01, 1.0 * shading);
    vec4 color = texture2D(tMap, vUv);
    gl_FragColor.rgb = color.rgb * clamp(0.5, 1.3, d + 1.0 * shading);
    gl_FragColor.a = color.a;
}

2. 绘制线

利用片元着色器绘制圆的思路,就是根据点到圆心的距离来设置颜色。实际上,我们也可以用同样的原理来绘制线,只不过需要把点到点的距离换成点到直线(向量)的距离。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec3 line = vec3(1, 1, 0);
  float d = abs(cross(vec3(vUv,0), normalize(line)).z); 
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

比如,我们利用上面的代码,就能在画布上画出一条斜线。

如果你还不能一眼看出上面的代码为什么能画出一条直线,说明你对于图形学的向量计算思维还没有完全适应。不过别着急,随着我们练习的增多,你会逐渐适应的。下面,我来解释一下这段代码。

这里我们用一个三维向量line来定义一条直线。因为我们要绘制的是2D图形所以z保持0就行而x和y用来决定方向。

然后呢我们求vUv和line的距离。这里我们直接用向量叉乘的性质就能求得。因为两个二维向量叉积的z轴分量的大小就是这两个向量组成的平行四边形的面积那当我们把line的向量归一化之后这个值就是vUv到直线的距离d了。因为这个d带符号所以我们还需要取它的绝对值。

最后我们用这个d结合前面使用过的smoothstep来控制像素颜色就能得到一条直线了。

3. 用鼠标控制直线

画出直线之后我们改变line还可以得到不同的直线。比如在着色器代码中我们再添加一个uniform变量uMouse就可以根据鼠标位置来控制直线方向。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;

void main() {
  vec3 line = vec3(uMouse, 0); // 用向量表示所在直线
  float d = abs(cross(vec3(vUv,0), normalize(line)).z); // 叉乘表示平行四边形面积底边为1得到距离
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

对应地我们需要在JavaScript中将uMouse通过uniforms传入代码如下

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

renderer.uniforms.uMouse = [-1, -1];

canvas.addEventListener('mousemove', (e) => {
  const {x, y, width, height} = e.target.getBoundingClientRect();
  renderer.uniforms.uMouse = [
    (e.x - x) / width,
    1.0 - (e.y - y) / height,
  ];
});

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

renderer.render();

在上面的例子中我们的直线是经过原点的。那如果我们想让直线经过任意的定点该怎么办我们可以加一个uniform变量uOrigin来表示直线经过的固定点。代码如下

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;
uniform vec2 uOrigin;

void main() {
  vec3 line = vec3(uMouse - uOrigin, 0); // 用向量表示所在直线
  float d = abs(cross(vec3(vUv - uOrigin, 0), normalize(line)).z); // 叉乘表示平行四边形面积底边为1得到距离
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

延续这个绘制直线的思路,我们很容易就能知道该如何绘制线段了。绘制线段与绘制直线的方法几乎一样,只不过,我们要将计算点到直线的距离修改为计算点到线段的距离。

但是因为点和线段之间有两种关系一种是点在线段上另一种是在线段之外。所以我们在求点到线段的距离d的时候要分两种情况讨论当点到线段的投影位于线段两个端点中间的时候它就等于点到直线的距离当点到线段的投影在两个端点之外的时候它就等于这个点到最近一个端点的距离。

这么说还是比较抽象我画了一个示意图。你会看到C1到线段ab的距离就等于它到线段所在直线的距离C2到线段ab的距离是它到a点的距离C3到线段的距离是它到b点的距离。那么如何判断究竟是C1、C2、C3中的哪一种情况呢答案是通过C1到线段ab的投影来判断。

所以我们在原本片元着色器代码的基础上抽象出一个seg_distance函数用来返回点到线段的距离。修改后的代码如下

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform vec2 uMouse;
uniform vec2 uOrigin;

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

void main() {
  float d = seg_distance(vUv, uOrigin, uMouse);
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

这么修改之后如果我们将uOrigin设为vec2(0.5, 0.5),就会得到如下效果:

4. 绘制三角形

你可能已经发现了,不管是画圆还是画线,我们使用的原理都是求点到点或者是点到线段距离。实际上,这个原理还可以扩展应用到封闭平面图形的绘制上。那我们就以三角形为例,来说说片元着色器的绘制结合图形的方法。

首先,我们要判断点是否在三角形内部。我们知道,点到三角形三条边的距离有三个,只要这三个距离的符号都相同,我们就能确定点在三角形内。

然后,我们建立三角形的距离模型。我们规定它的内部距离为负,外部距离为正,并且都选点到三条边的最小距离。代码如下:

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  return cross(p, normalize(ab)).z;
}

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
  float d1 = line_distance(st, a, b);
  float d2 = line_distance(st, b, c);
  float d3 = line_distance(st, c, a);

  if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
    return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
  }
  
  return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}

void main() {
  float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
  gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

这样,我们就绘制出了一个白色的三角形。

实际上,三角形的这种画法还可以推广到任意凸多边形。比如,矩形和正多边形就可以使用同样的方式来绘制。

片元着色器绘图方法论:符号距离场渲染

现在,你应该知道这些基本的线段、圆和几何图形该怎么绘制了。那我们能不能从中总结出一套统一的方法论呢?我们发现,前面绘制的图形虽然各不相同,但是它们的绘制步骤都可以总结为以下两步。

第一步:定义距离。这里的距离,是一个人为定义的概念。在画圆的时候,它指的是点到圆心的距离;在画直线和线段的时候,它是指点到直线或某条线段的距离;在画几何图形的时候,它是指点到几何图形边的距离。

第二步:根据距离着色。首先是用smoothstep方法选择某个范围的距离值比如在画直线的时候我们设置smoothstep(0.0, 0.01, d)就表示选取距离为0.0到0.01的值。然后对这个范围着色,我们就可以将图形的边界绘制出来了。

延续这个思路我们还可以选择距离在0.0~0.01范围以外的点。下面,我们做一个小实验。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec3 line = vec3(1, 1, 0);
  float d = abs(cross(vec3(vUv,0), normalize(line)).z);
  gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

我们用之前绘制直线的代码,将 gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0) 修改为 gl_FragColor.rgb = (smoothstep(0.195, 0.2, d) - smoothstep(0.2, 0.205, d)) * vec3(1.0),我们看到输出的结果变成对称的两条直线了。

这是为什么呢因为我们是对距离原直线0.2处的点进行的着色那实际上距离0.2的点有两条线,所以就能绘制出两条直线了。我把它的原理画了一个示意图,你可以看看,其中红线是原直线。

利用这个思路再加上使用乘法和fract函数重复绘制的原理我们就可以绘制多条平行线了。比如通过下面的代码我们可以绘制出均匀的平面分割线。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec3 line = vec3(1, 1, 0);
  float d = abs(cross(vec3(vUv,0), normalize(line)).z);
  d = fract(20.0 * d);
  gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

利用同样的办法,我们还可以绘制圆环或者三角环或者其他图形的环。因为原理相同,下面我就直接给你展示代码和效果了。

首先是绘制圆环的代码和效果:

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  float d = distance(vUv, vec2(0.5));
  d = fract(20.0 * d);
  gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

然后是绘制三角环的代码和效果:

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  return cross(p, normalize(ab)).z;
}

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
  float d1 = line_distance(st, a, b);
  float d2 = line_distance(st, b, c);
  float d3 = line_distance(st, c, a);

  if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
    return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
  }
  
  return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}

void main() {
  float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
  d = fract(20.0 * abs(d));
  gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

实际上,上面这种绘制图形和环的方式,在图形渲染中有一个专有的名称叫做符号距离场渲染Signed Distance Fields Rendering。它本质上就是利用空间中的距离分布来着色的。我们把上面的三角环代码换一种渲染方式你就能看得更清楚一些了。代码如下

void main() {
  float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
  d = fract(20.0 * abs(d));
  gl_FragColor.rgb = vec3(d);
  // gl_FragColor.rgb = (smoothstep(0.45, 0.5, d) - smoothstep(0.5, 0.55, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

我们在渲染的时候还可以把main函数中原来的smoothstep渲染方式注释掉直接用vec3(d)来渲染颜色,就会得到的如下的效果。

你能看到这里的每一环两两之间的距离是沿着法线方向从0到1的所以颜色从黑色过渡到白色这就是三角环的距离场分布。相同颜色值的环线就是距离场的等距线我们用step或smoothstep的方式将某些等距线的颜色设置为白色其他位置颜色设置为黑色就绘制出之前的环线效果来了。

着色器绘制几何图形的用途

讨论到这里,你一定有些疑惑,我们学习这些片元着色器的绘图方式,究竟有什么实际用途呢?实际上它的用途还是挺广泛的,在这里我想先简单举几个实际的应用案例,你可以先感受一下。

不过在讲具体案例之前我还想多啰嗦几句。着色器造型是着色器的一种非常基础的使用方法甚至可以说是图形学中着色器渲染最基础的原理就好比代数的基础是四则运算一样它构成了GPU视觉渲染的基石我们在视觉呈现中生成的各种细节特效的方法万变不离其宗基本上都和着色器造型有关。

所以呢,我希望你不仅仅要了解它的用途,更要彻底弄明白它的原理和思路,尤其是非常重要的符号距离场渲染技巧,一定要理解并熟练掌握。关于着色器造型的更多、更复杂的应用场景,我们在后续的课程中还会遇到。明白了这一点,我们接着来看三个简单的案例吧。

首先,我们可以用着色器造型实现图像的剪裁。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

uniform sampler2D tMap;
uniform float uTime;

...

void main() {
  vec4 color = texture2D(tMap, vUv);
  vec2 uv = vUv - vec2(0.5);
  vec2 a = vec2(-0.577, 0) - vec2(0.5);
  vec2 b = vec2(0.5, 1.866) - vec2(0.5);
  vec2 c = vec2(1.577, 0) - vec2(0.5);

  float scale = min(1.0, 0.0005 * uTime);
    float d = triangle_distance(uv, scale * a, scale * b, scale * c);
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * color.rgb;
    gl_FragColor.a = 1.0;
  }

利用上面的代码,我们对图像进行三角形剪裁,可以实现的效果如下:

其次,我们可以实现对图像的动态修饰,比如类似下面这种进度条。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

uniform sampler2D tMap;
uniform float uTime;

...

void main() {
  vec4 color = texture2D(tMap, vUv);
  vec2 uv = vUv - vec2(0.5);
  vec2 a = vec2(0, 1);
  float time = 0.0005 * uTime;

  vec2 b = vec2(sin(time), cos(time));
  float d = 0.0;

  float c0 = cross(vec3(b, 0.0), vec3(a, 0.0)).z;
  float c1 = cross(vec3(uv, 0.0), vec3(a, 0.0)).z;
  float c2 = cross(vec3(uv, 0.0), vec3(b, 0.0)).z;
  if(c0 > 0.0 && c1 > 0.0 && c2 < 0.0) {
    d = 1.0;
  }
  if(c0 < 0.0 && (c1 >= 0.0 || c2 <= 0.0)) {
    d = 1.0;
  }

  gl_FragColor.rgb = color.rgb;
  gl_FragColor.r *= mix(0.3, 1.0, d);
  gl_FragColor.a = mix(0.9, 1.0, d);
}


第三我们还可以在一些3D场景中修饰几何体比如像这样给一个球体套一个外壳这个例子的代码我就不贴出来了在后续3D课程中我们再详细来说。

要点总结

这一节课我们学习了使用片元着色器进行几何造型的2种常用方法。

首先,用片元着色器可以通过控制局部颜色来绘制图案,比如根据像素坐标来控制颜色变化,然后利用重复绘制的技巧,形成有趣的图案花纹。

其次,我们可以定义并计算像素坐标的距离,然后根据距离来填充颜色,这种方法实际上叫做符号距离场渲染,是着色器造型生成图案的基础方法。通过这种方法我们可以绘制圆、直线、线段、三角形以及其他图形。

使用着色器绘制几何图形是WebGL常用的方式它有许多用途比如可以剪裁图像、显示进度条、实现外壳纹路等等因此在可视化中有许多使用场景。

小试牛刀

这一节课我们介绍了圆、直线、线段和三角形的基本画法其他图形也可以用t方法来绘制。试着用同样的思路来绘制正方形、正六角星、椭圆吧

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!


源码

本节课完整代码.