女朋友想学WebGL修图,安排!

2,739 阅读3分钟

前言

看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。

之前讲了简单的webgl的原理与点的绘制、以及webgl在vscode需要注意的点,本文将接着介绍如何做个简单的修图功能,由于篇幅有限,只讲基本的语法、多边形绘制、缓冲区、帧缓存、纹理uv等。

预览

chrome-capture-2023-2-30.gif

canvas也可以更简单的实现,getImageData可以得到点的集合,然后putImageData绘制就行了。但是一些复杂的算法,例如高斯模糊、雕刻效果,貌似就没有webgl灵活了。

createBuffer 缓冲区

缓冲区你可以理解canvas的save()保存状态,但是这里我们一般是点的集合,这里我不会讲具体的api细节,但是知道具体的代码流程就行,就是创建buffer及数据 -> 绑定数据 -> 如何加载

let bufferOrigin = gl.createBuffer()
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOrigin);

gl.enableVertexAttribArray(positionAttributeLocation);  // 告诉缓冲区怎么加载
gl.vertexAttribPointer(positionAttributeLocation, size, type, normalize, stride, offset);

Program 对象

const canvas = document.querySelector("#canvas");
image.width = 540
image.height = 720

canvas.style.width = 540 + 'px'
canvas.style.height = 720 + 'px'

const gl = canvas.getContext("webgl");
if (!gl) {
  return;
}
const program = webglUtils.createProgramFromScripts(gl, ["vertex-shader-2d", "fragment-shader-2d"]);

image加载的dom对象,设置宽高,webglUtils是封装的方法,其实就是之前的初始化的着色器,返回program程序对象。

shader 着色器

先看看着色器源码

<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position;  // attribute在顶点着色器处理
attribute vec2 a_texCoord; // 纹理参数
uniform vec2 u_resolution; // 页面的坐标
attribute vec4 a_composeColor; // 纹理增强的向量
varying vec4 v_composeColor; 

void main() {
   // 屏幕坐标 -> 裁剪坐标
   vec2 zeroToOne = a_position / u_resolution;
   vec2 zeroToTwo = zeroToOne * 2.0;

   vec2 clipSpace = zeroToTwo - 1.0;

   gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
   v_composeColor = a_composeColor;
   v_texCoord = a_texCoord;  
}
</script>

attribute类型用于顶点着色器的属性,一般可以在后期动态添加一些控制,但是uniform是只能静态编译的时候就决定了,所以一般用于控制材质、光照等确定的值。为了能控制到片元着色器,那么一定要使用varying这个类型,一般通过变量传递给片元着色器做动态的渲染,所以一般会配合attriubute + varying

<script id="fragment-shader-2d" type="x-shader/x-fragment">
  precision mediump float;

uniform sampler2D u_image;
uniform vec2 u_textureSize;
uniform float u_kernel[9];
uniform float u_kernelWeight;
varying vec2 v_texCoord;
varying vec4 v_composeColor;

void main() {
   vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
   // 卷积内核的前置处理,u_kernel我们传递的核心数据
   vec4 colorSum =
       texture2D(u_image, v_texCoord + onePixel * vec2(-1, -1)) * u_kernel[0] +
       texture2D(u_image, v_texCoord + onePixel * vec2( 0, -1)) * u_kernel[1] +
      //.....
    // 计算最终的颜色结果
   gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;
}
</script>

这里返回的结果gl_FragColor就是最终绘制的颜色。注意颜色范围是0到1需要做个转换,这里的最核心的也就是卷积的算法

卷积

卷积就是一个 3×3 的矩阵, 矩阵中的每一项代表当前处理的像素和周围8个像素的乘法因子, 相乘后将结果加起来除以内核权重(内核中所有值的和或 1.0 ,取二者中较大者)

image.png

像素矩阵 * 修改矩阵 = 赋值于内核也就是中心位置

这也就是我们能处理模糊、锐化等特效的原理, 下面是简单的计算

// 将周围八个点相加用于平均数相除
function computeKernelWeight(kernel) {
  const weight = kernel.reduce(function(prev, curr) {
    return prev + curr;
  });
  return weight <= 0 ? 1 : weight;
}
 gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;

滤镜

 const kernelsFilter = {
      sharpness: {
        name: '锐度',
        data: [
          0, -1, 0,
          -1, 5, -1,
          0, -1, 0
        ],
      },
      gaussianBlur: {
        name: '高斯模糊',
        data: [
          0, 1, 0,
          1, 1, 1,
          0, 1, 0
        ],
      },
      edgeDetect2: {
        name: '反相',
        data: [
          -1, -1, -1,
          -1, 8, -1,
          -1, -1, -1
        ],
      },
      emboss: {
        name: '浮雕效果',
        data: [
          -2, -1, 0,
          -1, 1, 1,
          0, 1, 2
        ],
      },
    };
    
    // 向量乘积的滤镜
    const composeFilter = {
      light: {
        name: '曝光',
        data: new Float32Array([1.2, 1.2, 1.2, 1])
      },
      langmanmeigui: {
        name: '浪漫玫瑰',
        data: new Float32Array([1.1, 1, 1, 1])
      },
      // ....
    }

将上面的参数传入对上面的着色器,然后通过卷积赋值于gl_FragColor,这样简单的修图工具就大功告成了。

texcoord 纹理

const texcoordLocation = gl.getAttribLocation(program, "a_texCoord");
// ... 
gl.vertexAttribPointer(texcoordLocation, size2, type2, normalize2, stride2, offsetVal2);

这里用缓冲区处理,本质图片也就是4个点的矩形,因为每个点其实对应像素和位置, 下面是创建纹理的标准代码,将image传入到textImage2D,然后将缓冲区绑定到这样我们就可以绘制纹理了

 // webgl创建纹理,并设置基本纹理参数,载入image图片
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

//定义纹理处理能力
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, image);

帧缓冲

如何给图片施加多种状态的叠加效果,也就是图片 -> 纹理一 -> 纹理一 + 纹理二 -> 画布,那么我们需要用到帧缓冲,其实就是通过不断的bindTexture来覆盖之前的状态。

// 绘制帧缓冲
function drawFrames () {
  const originTexture = createAndSetupTexture(gl)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  let textures = []
  let frameBuffers = []
  const kernelsFilterList = ['gaussianBlur', 'emboss', 'boxBlur',
  'gaussianBlur', 'boxBlur', 'gaussianBlur', 'boxBlur', 'gaussianBlur'] //叠加效果的数组

  for (let i = 0; i < kernelsFilterList.length; i++) {
    let texture = createAndSetupTexture(gl)
    textures.push(texture)

    gl.texImage2D(
      gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0,
      gl.RGBA, gl.UNSIGNED_BYTE, null);

    var fBuffer = gl.createFramebuffer()
    frameBuffers.push(fBuffer)
    gl.bindFramebuffer(gl.FRAMEBUFFER, fBuffer);
    // 绑定纹理到帧缓冲
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
  }
  gl.bindTexture(gl.TEXTURE_2D, originTexture);

  for (var i = 0; i < kernelsFilterList.length; i++) {
    setFramebuffer(frameBuffers[i], image.width, image.height);
    drawWithKernel(kernelsFilterList[i]);
    // 叠加
    gl.bindTexture(gl.TEXTURE_2D, textures[i]);
  }

  // 绘制
  setFramebuffer(null, canvas.width, canvas.height);
  drawWithKernel("normal");
   
  function setFramebuffer (fbo, width, height) {
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 绑定帧缓存
    gl.uniform2f(resolutionLocation, width, height); // 设置到裁剪坐标
    gl.viewport(0, 0, width, height); // 将裁剪坐标自适应到屏幕坐标 
  }
}

总结

通过基本的语法、纹理使用、帧缓存等,我们对webgl的基本的2d图形处理有了一定的认知,正常在绘制三角形,四边形,圆形,我们都可以使用缓存区,最后drawArrays绘制,在一些图形的渲染需要保存之前的状态的时候,我们可以使用帧缓存处理。关于当前页面的优化,当前的修图页面应该将各种调色分到不同的glsl文件,同样我们也可以做裁剪,上传图片编辑并下载。

如果觉得文章对你有帮助,不要忘了一键三连 👍

附录

  1. 内卷年代,是该学学WebGL了 - 掘金 (juejin.cn)
  2. 为什么我的WebGL开发这么丝滑 🌊 - 掘金 (juejin.cn)
  3. 立体感十足的数据可视化:我的WebGL 3D环状图制作分享 - 掘金 (juejin.cn)

本文正在参加「金石计划」