创意无限!GPU(GLSL)绘画入门

“我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

开篇前的小牢骚

已经很久没有发文了,距离上次写文章已经过去了4个月之久。感觉自己每天都在焦虑中度过,明明想要的很多,但是每天却又什么都没有做似的。这种状态时自己十分讨厌的,从现在开始我决心要改变这种状态,造成这样的状态正是因为每天想的太多做的太少。既然如此,不如每天做一件微不足道的小事,也许是写一篇文章,也许是学习一个编程小技巧,也或许是跑步突破了自己的极限…… 不能总是沉浸在工作中,这样会让人的精神崩溃。。。

前面说了这么多的废话,我们还是进入正文吧,今天给大家带来的技术是利用GPU的能力进行2D作画。我们可以先看一下最终我们可以得到什么(效果如下图)

image.png

其中使用到的技术就是

  • WebGL/GLSL
  • SDF(Signed Distance Field)(有向距离场)

其中 WebGL 是浏览器底层提供的一种底层绘图API。但是WebGL 在这里只是作为了运行GLSL的宿主环境,可以理解为是JavaScript运行在浏览器中的关系。

今天的重点不在于 WebGL 的各项API介绍,我们的重点会在于 WebGL 中使用的着色器语言 GLSL —— OpenGL Shading Language。我们在片元着色器中编写代码来完成这幅画。 如果想要了解关于 WebGL 更多的知识,可以查看这篇文章:WebGL概述——原理篇 - 掘金 (juejin.cn)

编程环境介绍

为了屏蔽除开GLSL以外的因素,我为大家推荐一个VSCode插件: glsl-canvas,该插件可以直接运行我们编写着色器代码。 image.png

第一个GLSL程序

现在,让我们来编写第一个GLSL程序。

  1. 新建一个文件 hello_world.glsl
  2. 写入以下内容
void main () {
    gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0);
}
  1. Ctrl + P, 输入 >show glslCanvas 就可以运行我们的着色器程序了。

此时,可以看到我们右侧的屏幕显示了一个纯色。现在我们就来简单解释一下上述的代码是什么意思。

其实GLSL的语法与C语言比较类似,void main表示该着色器是入口程序,而 gl_FragColor 是一个内置变量,它代表了屏幕上像素的颜色。每个像素都拥有一个叫gl_FragColor的值。

接下来,我们将引入更多的内置变量与函数,逐步实现我们最终的效果。

更进一步

接下来,我们让画面变的稍微绚丽多彩一些。在 glsl-canvas 插件中,它为我们提供了一系列的内置变量,比如 u_resolution 表示的是当前预览窗口的分辨率。而在 GLSL 中也提供了一些内置变量,比如: gl_FragCoord 表示了当前像素在预览窗口中的坐标。通常我们将其归一化表示: 通过 gl_FragCoord.xy / u_resolution 可以将坐标归一化到0~1之间。

注意:有的内置变量是插件所提供的,有的内置变量是GLSL本身自带的,这一点我们需要弄清楚。(以 u_xxx开头的变量名是由插件提供的,由gl_XXX 开头的变量则是GLSL提供的)

现在,我们将程序修改如下:

precision mediump float;
uniform vec2 u_resolution;
void main () {
    vec2 uv = gl_FragCoord.xy / u_resolution;
    gl_FragColor = vec4(uv, 0.0, 1.0);
}

注意,在最上方需要加入 precision mediump float,否则插件会报错。这是声明浮点数默认精度的声明。结果如下:

image.png

可以看到,我们的画面变的稍微有一点点的“炫”。上面我们也说过了,我们将预览窗口中的每个像素的坐标归一化了。所以反应到具体的颜色上:

image.png

  • 左下角就是 (0, 0, 0, 1)
  • 右下角是 (1, 0, 0, 1)
  • 右上角是 (1, 1, 0, 1)
  • 左上角是 (0, 1, 0, 1)

而矩形中间的的颜色则是通过插值得到。具体不过多阐述。

有了这些基础后,我们就可以进入SDF的部分了。

SDF (Signed Distance Field)

SDF——有向距离场,是一种能够描述物体内外关系的一种表示。以圆形为例:

image.png

在圆形内的一点P,距离圆形边缘的距离为d,但是由于点P在圆形的内部,我们定义它到圆形的距离为 -d

而在圆形外的一点Q,距离圆形边缘的距离为 d',但是由于点Q在圆形的外部,我们定义他到圆形的距离为 d'

简单的讲就是在图形内部的距离为负值,在图形外部的距离为正值。所以,圆形的SDF函数为:

float circle(vec2 uv, vec2 center, float d) {
    return length(uv - center) - d;
}

现在,我们就将其可视化出来。

precision mediump float;
uniform vec2 u_resolution;

float circle(vec2 uv, vec2 center, float d) {
    return length(uv - center) - d;
}
void main () {
    vec2 uv = gl_FragCoord.xy / u_resolution;

    float d = circle(uv, vec2(0.5, 0.5), 0.1);

    vec3 color = vec3(d);
    gl_FragColor = vec4(color, 1.0);
}

结果如下: image.png

我们可以看出来越靠近中心的地方颜色就深,也就表示距离越小。其实中间的值已经是负值了,但是由于颜色是UINT8类型的值,所以看不出来。

我们将内部的负值全部设为0,外部的值设为1,让这个圆看起来更清晰、直观一些。修改程序如下:

precision mediump float;
uniform vec2 u_resolution;

float circle(vec2 uv, vec2 center, float d) {
    return length(uv - center) - d;
}
void main () {
    vec2 uv = gl_FragCoord.xy / u_resolution;

    float d = circle(uv, vec2(0.5, 0.5), 0.1);
    // ++++++++++++++++++++
    if (d <= 0.0) {
        d = 0.0;
    } else {
        d = 1.0;
    }
    // ++++++++++++++++++++
    vec3 color = vec3(d);
    gl_FragColor = vec4(color, 1.0);
}

此处新增了一个if条件判断,但是在GLSL的最佳实践中,就是尽可能不要使用if 语句,能用内置函数性能更佳!(因为这些内置函数往往都是有硬件算力加成的),GLSL为我们提供了这样的一个函数,我们使用 step 函数来替代if语句。程序如下:

step generates a step function by comparing x to edge.

For element i of the return value, 0.0 is returned if x[i] < edge[i], and 1.0 is returned otherwise.

precision mediump float;
uniform vec2 u_resolution;

float circle(vec2 uv, vec2 center, float d) {
    return length(uv - center) - d;
}
void main () {
    vec2 uv = gl_FragCoord.xy / u_resolution;

    float d = circle(uv, vec2(0.5, 0.5), 0.1);
    d = 1.0 - step(0.0, d);
    vec3 color = vec3(d);
    gl_FragColor = vec4(color, 1.0);
}

image.png

OK,现在我们学会了使用SDF来表示图形,这里只使用了圆形来简单的展示了SDF的用处,还有更多的SDF函数等待大家发掘,大家可以自行查找资料。

参考资料:

Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more (iquilezles.org)

2D基本图形的Sign Distance Function (SDF)详解(上)_T-Jhon的博客-CSDN博客_sdf函数

2D基本图形的Sign Distance Function (SDF)详解(下)_T-Jhon的博客-CSDN博客_距离函数sdf

SDF组合

我们学会了一些基本的SDF之后,我们就可以将这些基本的SDF图形进行组合。在SDF中,对他们进行组合是一件非常容易的事情。主要的操作就是两个图形求交集、求并集。 假设我现在已经有了菱形、圆形 这两个SDF函数。

image.png

image.png

我们可以通过交、并运算来产生不同的图形。在SDF中,对其求交就是求两个值中的max,而求并则是求min

  • 求交(max):

image.png

  • 求并(min):

image.png

现在我们学会了使用求交、并集的方式来生成更加复杂的图形了。比如,我们可以使用这种方式来绘制云朵(代码与绘制结果如下)。

precision mediump float;
uniform vec2 u_resolution;

float circle(vec2 uv, vec2 center, float d) {
    return length(uv - center) - d;
}


float cloud(vec2 uv, vec2 center, float d) {
    float step = 1.2;
    float c1 = circle(uv, vec2(center.x - d * 0.9 * 1.0 * step, center.y), d * 0.9);
    float c2 = circle(uv, vec2(center.x - d * 0.8 * 2.0 * step, center.y), d * 0.8);
    float c3 = circle(uv, vec2(center.x, center.y), d);

    float c4 = circle(uv, vec2(center.x + d * 0.9 * 1.0 * step, center.y), d * 0.9);
    float c5 = circle(uv, vec2(center.x + d * 0.8 * 2.0 * step, center.y), d * 0.8);
    return min(c5, min(c4, min(c3, min(c1, c2))));
}

void main () {
    vec2 uv = gl_FragCoord.xy / u_resolution;

    float d = cloud(uv, vec2(0.5, 0.5), 0.05);
    d = 1.0 - step(0.0, d);
    vec3 color = vec3(d);
    gl_FragColor = vec4(color, 1.0);
}

image.png

图层叠加

我们可以通过这种方式来绘制画面中的其他元素。现在,我们还需要将他们以正确对的图层顺序叠加起来。我们以背景与云朵叠加为例来说明。首先我们先修改背景的颜色。我们重新组织代码,如下:


void main () {
    vec2 uv = gl_FragCoord.xy / u_resolution;
    //+++++++++++++++
    vec3 backCol = vec3(0.2078, 0.7765, 1.0);
    vec3 col = backCol;
    float d = cloud(uv, vec2(0.5, 0.5), 0.05);
    d = 1.0 - step(0.0, d);
    //+++++++++++++++
    vec3 cloudCol = vec3(d);
    col = mix(col, cloudCol, d);
    
    gl_FragColor = vec4(col, 1.0);
}

在上面的代码中,我们使用 backCol 来表示背景颜色,使用col 来表示最终的颜色,由于背景颜色是最下面的图层,我们可以先将背景颜色赋给最终颜色,然后我们需要将云朵的颜色叠加在背景色上。这里,我们需要使用一个内置函数:mix,该函数的意思就是线性插值。

mix performs a linear interpolation between x and y using a to weight between them. The return value is computed as follows: x⋅(1−a)+y⋅a.

这样,我们就完成了图层叠加的操作。结果如下:

image.png

我们发现背景稍微有点单调,我们想实现一个渐变色的背景应该怎么做呢?刚刚我们提高了mix函数可以实现线性插值,那么我们是不是可以利用这一点呢?答案是肯定的。例如我们想让顶部为天蓝色,底部为黄色,则可以利用mix函数,通过y坐标来进行插值。修改代码如下

vec3 backCol = vec3(0.2078, 0.7765, 1.0);
vec3 backCol2 = vec3(0.7412, 0.4353, 0.0667);
backCol = mix(backCol2, backCol, uv.y);

image.png

不过,如果我们想要天蓝色和土黄色的部分更多一些又应该怎么办呢?如下图所示:

image.png

最容易让我们想到的办法就是根据y值来进行判定,我们可以写一个 if条件来判断。不过还记得上面说过的吗?我们在GLSL中要尽可能的少使用 if,用内置函数来代替它。GLSL为我们提供了这样的一个函数:smoothstep

smoothstep performs smooth Hermite interpolation between 0 and 1 when edge0 < x < edge1. This is useful in cases where a threshold function with a smooth transition is desired. smoothstep returns 0.0 if x ≤ edge0 and 1.0 if x ≥ edge1.

举个例子:smoothstep(0.2, 0.8, d),如果 d < 0.2 则返回0,如果d > 0.8 则返回1,如果在 0.2~0.8之间,则进行插值,但是这里不再是线性插值,而是某个三次函数。这样得到的结果更加的平滑。

我们修改代码及结果如下:

vec3 backCol = vec3(0.2078, 0.7765, 1.0);
vec3 backCol2 = vec3(0.7412, 0.4353, 0.0667);
float gradient = smoothstep(0.1, 0.8, uv.y);
backCol = mix(backCol2, backCol, gradient);

image.png

我们学会了这些就可以利用这些技术来对各个形状进行绘制了,篇幅有限,后面绘制太阳、河流等就不再一一举例了。大家可以参考完整的代码(完整代码在文末)。

动画

最后,我们要让其动起来。此时我们需要用到 glsl-canvas这个插件为我们提供的一个内置变量u_time。在程序的开头添加 uniform float u_time即可。然后我们为uv坐标加上u_time的值,让uv时刻发生改变,这样我们的画面就可以动起来了。修改代码如下:

vec2 uv = gl_FragCoord.xy / u_resolution;
uv.x += u_time * 0.3;
uv.x = fract(uv.x);

Jul-23-2022 12-20-12.gif

结语

嘛,今天的教程基本就告一段落了。本文为大家介绍了:

  1. glsl-canvas 插件,该插件允许直接打开 GLSL 程序,并显示结果。
  2. 介绍了一些常用的内置函数 mix, smoothstep, min, max
  3. 介绍了SDF的定义及用法
  4. 介绍了在叠加图层的方法。

虽然文中举的例子比较简单,但是希望读者能够举一反三,最终创作出属于自己的场景。最后给出完整代码

猜你喜欢

转载自juejin.im/post/7123419366263095309