OpenGL+GLSL书写自己的特效

好久没有写技术方面的文章了,想想还是应该有些技术输出。无论是对自己的技术总结,还是给他人学习作为参考,都是一件有价值的事情。 这篇文章不讲解OpenGL的环境搭建和代码实现,仅讲解GLSL的学习思路与具体实现案例。代码仓库将会在文章的最后贴出。

顶点着色器

在写特效过程中,顶点着色器通常只传递"fragCoord"(顶点坐标)。在复杂的三维顶点着色器中,会涉及到坐标转换如MVP(Medel+View+Projection)变换,最终将世界坐标转换到裁剪空间。由于我们是二维平面,所以不需要这一部分,直接按照标准化设备坐标(NDC)传入。 顶点数据如下:

const float vertices[] = {
        // 第一个三角形
        -1.0f, 1.0f, 0.0f, 0.0f,  // 左上
        -1.0f, -1.0f, 0.0f, 1.0f, // 左下
        1.0f, -1.0f, 1.0f, 1.0f,  // 右下

        // 第二个三角形
        -1.0f, 1.0f, 0.0f, 0.0f, // 左上
        1.0f, -1.0f, 1.0f, 1.0f, // 右下
        1.0f, 1.0f, 1.0f, 0.0f   // 右上
    };

通用的顶点着色器如下:

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoord;

out vec2 TexCoord;

void main()
{
    gl_Position = vec4(aPos, 0.0f, 1.0f);
    TexCoord = aTexCoord;
}

第一行是版本声明,这里说明我们使用GLSL3.30核心版本,330 是广泛兼容的版本,大多数现代设备都支持。不同版本直接有些差距,比如:

这个着色器输出"TexCoord",我们在后续着色器都可以使用,这里有些基础知识需要了解一下: 这里的顶点着色器处理的是顶点数据,我们为了绘制一个矩形定义了六个顶点数据(两个三角形),也就是说这个顶点着色器会处理6个顶点数据,后续的片段着色器则是会处理由这两个三角形构成的所有像素点如1280*720(所以很多计算放到到顶点着色器去计算是一个比较大的性能优化)。

片段着色器

光栅化

数据经过一系列处理(光栅化),最终到达片段着色器,这里将决定"某个像素点最终颜色"。那么一开始在顶点着色器的数据,到了片段着色器中会是怎么样的呢?答案是光栅化会将你的输出参数"线性插值"处理,不了解插值的同学,可以先了解一下"线性插值"、"双线性插值"这些概念。说白了,你输出的值,会在光栅化被"平滑"。

光栅化

GLSL的语法与C非常相似,如果熟悉c语言的话上手会非常快。只不过并行计算的思维会和日常c开发不太一样。

以下展示最简单的案例(展示textCoord):

#version 330 core
out vec4 FragColor;

in vec2 TexCoord;

void main()
{
    FragColor = vec4(TexCoord, 0.0f, 1.0f);
}

GLSL关键词、内置函数、变量可参考:GLSL中文手册

GLSL不同版本会有些差距,如内置函数、变量支持程度不同。330 core需要定义输出颜色(四分量向量分别代表RGBA),这里将最终颜色的RG分量使用纹理坐标最终产生出来的结果:

渐变

为什么会产生渐变呢?这就是上文"光栅化"的结果,顶点着色器输出的属性TexCoord被"线性插值"所致。

外部参数

有些开关(bool),或者参数需要外部程序传入,比如亮度、模糊程度、开关之类的,可以通过外部参数传入。我们可以通过传递参数实现我们的动画,比如随时间变化的颜色:

#version 330 core
out vec4 FragColor;

in vec2 TexCoord;
uniform float uTime;

void main()
{
    // 循环uTime
    FragColor = vec4(TexCoord, 0.5f + sin(uTime) * 0.5f, 1.0f);
}

// c++中
shader.setFloat("uTime", (float)glfwGetTime());

这里使用了一点点数学:由于最终上屏颜色在0-1之间,我们让B分量随时间扰动。使用0.5f + sin(uTime) * 0.5f保证分量在0-1之间周期摆动,当然也可以使用mod(uTime, 1.0f)来只取小数。

最终效果:

纹理的部分,可以看代码仓库的其它代码。其实到这里,实现特效的基础就讲完了,接下来,让我们找个特效实现吧!

剪映抖动效果

fragShader实现如下:

#version 330 core
in vec2 TexCoord;
uniform sampler2D inputTexture;

uniform float time;
uniform float strength;
uniform float speed;

out vec4 FragColor;

float getFps() { 
    return 15.0 * speed / 100.0 + 15.0; 
}

float getIntensity() { 
    return 0.04 * strength / 100.0; 
}

vec2 getDirection() {
  vec2 directions[5];
  directions[0] = vec2(-1.0, -1.0);
  directions[1] = vec2(-1.0, 1.0);
  directions[2] = vec2(1.0, 1.0);
  directions[3] = vec2(1.0, -1.0);
  directions[4] = vec2(0.0, 0.0);

  int id = int(floor(getFps() * time));
  id = id % 10 + 1;
  if (id > 5) {
    id = 10 - id;
    if (id == 0) {
      id = 5;
    }
  }
  return directions[id] * getIntensity();
}

void main() {
  vec2 coord = TexCoord + getDirection();
  FragColor = texture(inputTexture, coord);
}
// c++ uniform参数输入
shader.setFloat("time", (float)glfwGetTime());
shader.setFloat("strength", 50.0f);
shader.setFloat("speed", 50.0f);

代码解析

核心实现逻辑是按照纹理偏移去做,也就是对uv添加一个offset。这里可以这样理解:uv(0.0f,0.0f)+(0.1f,0.1f)->uv(0.1f,0.1f),那么原本左上角显示的图片(0.0f,0.0f)的像素值变成了(0.1f,0.1f)的像素值,等同图片往左上角移动。所以U添加是图片往左、V添加是图片往上移动。

偏移演示

那么抖动有两个问题:

针对问题1:引入FPS,在15-30之间,通过外部参数传入。int(floor(getFps() * time))一秒内,只会生成固定fps个数(时间是连续的,会按照顺序的到0、1、2...fps、fps+1...)。

针对问题2:预先定义五个偏移方向(包含不偏移),通过取余、镜像id = 10 - id保证按照固定顺序取方向与周期性。

优化思考:将位置偏移计算代码挪到顶点着色器中,仅需6次计算。

最终效果

仓库地址:shaderTest