webgl从0到写一个简易滤镜

2,978 阅读8分钟

本文的目标:从0开始,学习webgl,实现一个webgl的图片滤镜功能

主要流程

首先,这是一个类矩形,一张图片,进行变换。所以我们的实现流程是

以下,我们分阶段实现

阶段一:画出一个正方形

这一步,我们的主要目的是了解其基本知识及概念,从中边学边画边了解。

我们首先了解一下,webgl整个绘制过程是怎样的,它与我们日常的js逻辑有啥差异。 我们一步一步来看,什么是着色器程序,怎么创建着色器程序?

大白话就是着色器程序就是用来设定整张画布绘制的位置和绘制的颜色设置。 这就是创建着色器,到绑定到对应的程序上下文的整个流程 看代码

html
...
<canvas id="glcanvas" width="640" height="480">
    你的浏览器似乎不支持或者禁用了HTML5 <code>&lt;canvas&gt;</code> 元素.
</canvas>
...


javascript
// 顶点着色器glsl代码
 const vsSource = `
      attribute vec4 aVertexPosition;
      attribute vec4 aVertexColor;

      varying lowp vec4 vColor;

      void main() {
          gl_Position = aVertexPosition;
          vColor = aVertexColor;
      }
  `;

// 片段着色器glsl代码
  const fsSource = `
      varying lowp vec4 vColor;
      void main() {
      		gl_FragColor =  vColor;
      }
  `;

// 创建着色器,gl为上下文,type指明是顶点着色器还是片段着色器,source即为源码
  function createShader(gl, type, source) {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);

      gl.compileShader(shader);

      return shader;
  }

  function initShaderProgram(gl, vsSource, fsSource) {
      const vshader = createShader(gl, gl.VERTEX_SHADER, vsSource);
      const fshader = createShader(gl, gl.FRAGMENT_SHADER, fsSource);

      const shaderProgram = gl.createProgram(); // 创建着色器程序
      gl.attachShader(shaderProgram, vshader); // 链接着色器到对应的着色器程序中
      gl.attachShader(shaderProgram, fshader);
      gl.linkProgram(shaderProgram); // 关联着色器程序到整个绘制对象中

      return shaderProgram;
  }

// 初始化一个着色器程序
const shaderProgram = initSharderProgram(gl, vsSource, fsSource);

到这里应该是有很多疑问了,我们一步一步的解答,首先最困惑的应该是看不懂glsl。因此,需要了解一下一些基本的glsl语法概念。

基本类型

类型说明
void空类型,即不返回任何值
bool布尔类型 true,false
int带符号的整数 signed integer
float带符号的浮点数 floating scalar
vec2, vec3, vec4n维浮点数向量 n-component floating point vector
bvec2, bvec3, bvec4n维布尔向量 Boolean vector
ivec2, ivec3, ivec4n维整数向量 signed integer vector
mat2, mat3, mat42x2, 3x3, 4x4 浮点数矩阵 float matrix
sampler2D2D纹理 a 2D texture
samplerCube盒纹理 cube mapped texture

常见变量类型

uniform是外部程序传递给着色器(shader)的变量,在shader内部,相当于常量

attribute是只在顶点着色器使用的变量,用来表示一些顶点相关的信息

varying是传递顶点着色器和片段着色器的变量

精度限定符

  • 精度范围

    • 浮点数范围
      • highp (-2的62次方, 2的62次方);
      • mediump (-2的14次方, 2的14次方);
      • lowp (-2,2);
    • 整数范围
      • highp (-2的16次方, 2的16次方);
      • mediump (-2的10次方, 2的10次方);
      • lowp (-2的8次方, 2的8次方);
  • 指定默认精度

    • precision

      • 顶点着色器预定义,预定义即为默认值

         precision highp float; // 浮点数高精度
         precision highp int;  //  整型高精度
         precision lowp sampler2D; 
         precision lowp samplerCube;
        
      • 片段着色器预定义

         precision mediump int;  // 整型中精度
         precision lowp sampler2D; 
         precision lowp samplerCube;
        

        tips: 从中可以看出,片段着色器未对浮点数进行预定义,这里很可能由于一些浮点数计算精度没有定义,导致绘制失败。因此最好自己手动在片段着色器代码中设置浮点数的精度类型

结合源码,我们应用概念来进行理解 首先通过attribute声明aVertexPosition aVertexColor两个变量属性来接收缓冲数据(见流程图) 通过varying声明属性vColor将数据从顶点着色器传递给片段着色器。

接下来定义一个main函数,gl_Position(专有名词)描述绘制的位置信息,再把aVertexColor属性赋值给vColor使得片段着色器能使用缓冲中的数据 同理,片段着色器程序也如此分析,gl_FragColor(专有名词)描述绘制的颜色信息

创建完着色器程序之后,我们来认识一下初始化缓冲的过程

对应的代码实现

const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

const vertice = [
    -1.0, 1.0,
    -1.0, -1.0,
    1.0, -1.0,
    1.0, 1.0,
];

gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertice), gl.STATIC_DRAW);

接下来就是绘制过程,流程图如下 上代码看看

 {
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer.position);
    gl.vertexAttribPointer(
        gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
        2, // 一个顶点坐标的位数(比如一个顶点有(x,y),就是2,(x,y,z)就是3)
        gl.FLOAT,
        false,
        0,
        0
    );
    gl.enableVertexAttribArray(
        gl.getAttribLocation(shaderProgram, 'aVertexPosition')
    );
}

{
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer.color);
    // 绑定颜色缓冲
    // 允许使用颜色属性
    gl.enableVertexAttribArray(
        gl.getAttribLocation(shaderProgram, "aVertexColor")
    );
    gl.vertexAttribPointer(
        gl.getAttribLocation(shaderProgram, "aVertexColor"),
        4, gl.FLOAT, false, 0, 0);
}


gl.useProgram(shaderProgram);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); // 设置绘制模式和绘制的顶点个数

看最后的gl.useProgram就是将当前渲染状态使用该着色器程序,最后调用gl.drawArrays绘制顶点。

这里的我们讲下绘制的过程,一般以三角形的方式进行绘制,绘制的模式也有区分

绘制模式

  • gl.TRIANGLES单个单个的三角形

  • gl.TRIANGLE_STRIP绘制带有共享边的三角形,从第二个三角形开始,每次读取一个顶点,并利用上个三角形的末尾两个顶点构成三角形。 如用四个点绘制矩形,则首个点应该与最后一个顶点成对角

  • gl.TRIANGLE_FAN绘制带有共享边的三角形。从第二个三角形开始,每次读取一个顶点,利用上个三角形的最后一个顶点和首个顶点进行构成三角形

这里可以想象一下绘制一个正方形,通过不同的绘制模式进行绘制时,顶点的排布顺序

如只有x.y坐标平面纹理[-1, 1, -1, -1, 1, 1, 1, -1] 这种绘制顶点就是gl.TRIANGLE_STRIP,而[-1, 1, -1, -1, 1, -1, 1, 1]这种方式则是gl.TRIANGLE_FAN

由此,我们的第一个正方形就出来了.

完整代码见 ->

阶段二:画出一个带图片的矩形

这里我们就要了解贴图、纹理的概念。 这里,大白话来说,就是需要讲一张图片贴在对应的顶点位置,这样就是贴图。 我们看看代码

const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    image
);

return texture;

这里我们看看参数gl.texParameteri的这几行,这是为了适应各种尺寸的图片。

简单来说,就是webgl默认的情况下只支持宽高均为2的幂次方的图片,所以为了打破默认,就必须做一些妥协。这里就是一些非二次幂宽高进行设置的缩放行为。

最后就是贴上图片,这里需要注意的是,图片需要是已经加载完成的。

完整的代码见 ->

这样,我们就完成了我们任务的前两个步骤,已经可以展示图片了,现在就是要实现一个简易滤镜

阶段三:滤镜处理

我们开始思考,滤镜到底是什么?滤镜的实现方案有什么? 我的理解里,滤镜就是转换图片的颜色,对每像素的色值进行调整,达到整体美感。

滤镜的实现方案有什么? 日常想到的就是简单(线性or次方计算),稍微复杂一点就是卷积计算(线性代数开始折磨)。然而,日常我们更常见的是LUT(Look Up Table),颜色查找表。

这里的实现原理抽象来看,就是得到每像素的颜色值,查表,将转化后的值替换当前的色值。详细原理我们可以看下张鑫旭老师的文章,现在我们已经决定用这个方案了!

话不多说,开始干活。

const vsSource = `
    attribute vec4 aVertexPosition;
    attribute vec2 aTexCoord;

    varying  vec2 vTexcoord;

    void main() {
    gl_Position = aVertexPosition;
    vTexcoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
    }`;

const fsSource = `
    precision lowp float; // 必须指明float的精度,因为计算过程中片段着色器的精度没有默认

    varying vec2 vTexcoord;

    uniform sampler2D uSample;
    uniform sampler2D uSample1;

    uniform lowp float intensity;

    void main() {
        lowp vec4 textureColor = texture2D(uSample, vTexcoord);

        lowp float blueColor = textureColor.b * 63.0;

        lowp vec2 quad1;
        quad1.y = floor(floor(blueColor) / 8.0);
        quad1.x = floor(blueColor) - (quad1.y * 8.0);

        lowp vec2 quad2;
        quad2.y = floor(ceil(blueColor) / 8.0);
        quad2.x = ceil(blueColor) - (quad2.y * 8.0);

        lowp vec2 texPos1;
        texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
        texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);

        lowp vec2 texPos2;
        texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
        texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);

        lowp vec4 newColor1 = texture2D(uSample1, texPos1);
        lowp vec4 newColor2 = texture2D(uSample1, texPos2);

        lowp vec4 newColor = mix(newColor1, newColor2, fract(blueColor));
        gl_FragColor = mix(textureColor, vec4(newColor.rgb, textureColor.w), 1.0);
    }
`;

笔者只给了这部分代码,这里主要是对LUT算法(查找转换颜色)的实现,目前也已经有其他相当成熟的相关滤镜算法

关于查找表,在本次操作过程中,也就是一张图片的纹理,拿到对应位置的rgb后,与原有图片进行混合即可。查找表的数据作为一个常量uniform,通过外部变量传入该程序。具体落实就是放在gl.useProgram的后面 gl.drawArray的前面

...
gl.useProgram(shaderProgram);

const textureUnitIndex = 1; // 用单元 1.
// 获取常量的位置信息
const uImageLoc = gl.getUniformLocation(
    shaderProgram, "uSample1");
var filterTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + textureUnitIndex);
gl.bindTexture(gl.TEXTURE_2D, filterTexture);

// 设置可以渲染任意尺寸的图片纹理
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
// 设置纹理的图片
gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    filter
);

gl.uniform1i(uImageLoc, textureUnitIndex); // 设置查找表

gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);

看效果,我最亲爱的侄子(第一张是原图,后三张是滤镜后的效果Å)

嘿嘿,哪张最好看我要去邀功了😄

完整代码 附带查找表->

总结

总体来说,本次目标基本达成。在学习过程中,我们可以意识到:

  1. 主要工作是花在学习绘制webgl的思维方式,只有了解思维方式,才能更好的理解每一步的处理。
  2. 线性代数有点难,在学习过程中,还了解了一下各种旋转,虽然现在有各种库,但为了了解,还是自己手动算了几次旋转,为了解一个计算过程,一张草稿纸都写满了,不知道的同学还以为我在准备考研。。

推荐学习资料:

webglfundamentals.org/webgl/lesso…

learnopengl-cn.readthedocs.io/zh/latest/0…