WebGL 处理图片,来玩

721 阅读6分钟

“咕咕咕咕咕...”

天空中传来了鸽群的声音,随之而来的是悦耳的鸽哨儿🐦

鸽子.jpeg

近日在 凹凸实验室 公众号看到一篇文章《GLSL 着色器,来玩》,你们都来玩,那我走?

玩笑话,其内容还是有趣的,对于我这种 WebGL 青瓜蛋子还是十分友好;环境搭建直接使用了 ThreeJS,这样就可以让学习繁杂的 WebGL 基础 API 变成黑盒,让新手更易学习。当然如果想用原生 WebGL 标准 API 来绘制,可以看我公众号的 WebGL 专栏

既然那么好玩,索性跟风也写个着色器的小文章来玩吧😸

小序

何为着色器,简而言之即为自定义 GPU 处理图形能力的程序,GLSL 基础语法不在本文涉及范围内,读者可自行阅读《GLSL ES 语法基础》

既然要玩,也要玩点东西出来,即要有点小成果;凹凸实验室 文章中以动态渲染不同颜色为例,那本文就以渲染图片为例,做一个可以切换图片渲染效果的 Demo 吧(可以理解为切换滤镜效果)。

Demo 所涉及 GLSL 基础知识并未超出《GLSL ES 语法基础》一文之所及,仅应用了些许数学方面的小知识,从而实现图片不同的渲染效果。

环境搭建

Demo 中并未使用任何第三方 WebGL 库,只使用了自己用 TypeScript 重写的 webgl-utils,整个项目框架使用了 webpack-react-template(两个项目 GitHub 链接会附在文末)。要处理 GLSL 文件,故需对 webpack.config.js 进行些许修改:

// webpack.config.js

const config = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.(svg|glsl)$/,
        issuer: /\.(js|ts)x?$/,
        use: [
          {
            loader: "raw-loader",
          },
        ],
      },
    ],
  },
};

仅需让 raw-loader 处理一下 GLSL 文件,顺便在 types.d.ts 中加入对 GLSL 文件的声明即可:

declare module '*.glsl' {
  export default '' as string;
}

程序主体

整个程序主体由下面几行代码组成:

const Main = ({ type }: Props) => {
  const canvas = useRef<HTMLCanvasElement>({} as HTMLCanvasElement);
  const [gl, setGL] = useState<WebGL2RenderingContext | null>(null);
  const [program, setProgram] = useState<WebGLProgram | null>(null);

  useEffect(() => {
    canvas.current.width = Math.floor(window.innerWidth * 0.6);
    canvas.current.height = Math.floor(window.innerHeight * 0.7);
    const ctx = canvas.current.getContext('webgl2');

    if (!ctx) {
      throw new Error('Failed to get WebGL2 context');
    }

    setGL(ctx);
    const p = createProgramFromSources(ctx, ShadersMap[type], [], []);
    setProgram(p);
  }, [type]);
  
  const animation = useCallback(() => {
    if (gl && program) {
      void render(gl, program, Image);
    }
  }, [gl, program]);
  
  requestAnimationFrame(() => {
    animation();
  });
  
  return (
    <canvas ref={canvas} id="canvas" />
  );
};

而最重要的 render 职责就是加载并渲染图片,WebGL 如何加载图片,大家可以阅读 《WebGL 纹理映射》。纹理映射中选用了 超级赛亚人之神 图片,这次就选 我妻善逸 ,渲染后效果如下图:

origin.png

本次主要做了:交换红蓝通道、灰白、高斯模糊以及马赛克四种效果,其中除了简单的数学知识外,还涉及到了一个重要的知识点 切换着色器。以下例子中顶点着色器都无需修改,只需对片元着色器进行修改即可。

原图

渲染原图的顶点着色器和片元着色器很简单:

// vertex-shader.glsl
#version 300 es

in vec2 a_Position;
in vec2 a_TexCoord;
uniform vec2 u_Resolution;
out vec2 v_TexCoord;

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_TexCoord = a_TexCoord;
}

上面有个小点需要注意,顶点着色器程序中将绝对的像素位置转换到了 [1,1][-1, 1] 区间内,至于为何乘的为 vec2(1,1)vec2(1, -1) 而非 vec2(1,1)vec2(1, 1) 则取决于 gl.texParameteri 的设置。

// fragment-shader.glsl
#version 300 es

precision highp float;

uniform sampler2D u_Image;
in vec2 v_TexCoord;
out vec4 outColor;

void main() {
  outColor = texture(u_Image, v_TexCoord);
}

切换红蓝通道

片元着色器中的颜色是 rgba 形式的 vec4 变量,交换红蓝通道只需按如下变量赋值即可:

// ...

void main() {
  outColor = texture(u_Image, v_TexCoord).bgra;
}

渲染效果如下:

bgra.png

着实有些瘆人 :(

灰白图

将彩色图片转成灰白图片,只需将 rgb 三个通道的颜色值进行平均:

// ...

void main() {
  vec4 color = texture(u_Image, v_TexCoord);
  float average = (color.r + color.g + color.b) / 3.0;
  outColor = vec4(average, average, average, color.a);
}

gray.png

高斯模糊

高斯模糊(Gaussian Blur)是一种很常见的效果,通常用来降低噪声或降低细节层次,我们可以在 PhotoShop 的专业绘图软件中见到其身影。

它使用正态分布计算图像中每个像素的变换,分布不为 0 的像素组成的卷积矩阵与原图像做变换,每个像素值都是周围元素的加权平均。因为图片是二维信息,所以要使用二维正态分布,如下图:

二维正态分布.jpeg

(图片来源:images0.cnblogs.com/blog/502930…

原像素有最大的二维正态分布值,即有最大权重,故模糊后的像素最接近原像素,模糊后的整个图像还能看出原图像的影子。简单来说,高斯模糊的过程就是原图像与二维正态分布做卷积,不再展开讲(因为我也不会)🤡

#version 300 es

precision highp float;

uniform sampler2D u_Image;
uniform vec2 u_Resolution;
in vec2 v_TexCoord;
out vec4 outColor;

// 每个像素的权重
// 最中间的为原像素,权重最高
float weight[9] = float[] (
  0.0947416, 0.118318, 0.0947416,
  0.118318, 0.147761, 0.118318,
  0.0947416, 0.118318, 0.0947416
);

void main() {
  vec4 color;

  for(int i = 0; i < 9; i++) {
    vec2 coord;
    coord.x = v_TexCoord.x + float(i % 3 - 1) / u_Resolution.x;
    coord.y = v_TexCoord.y + float(int(i / 3) - 1) / u_Resolution.y;
    color = color + texture(u_Image, coord) * weight[i];
  }

  outColor = color;
}

对原像素方圆 1 像素的值做加权平均,效果如下图:

gaussian.png

仔细观察还是可以发现差别的:

compare.jpeg

马赛克

最后来讲一个令人“厌恶”的效果 —— 马赛克,好像任何东西打了码之后就会变得很邪恶😈

马赛克.jpeg

实现马赛克这个效果需引入另一个概念 —— 噪声,很容易理解就是多余不必要的干扰信息。实现也很简单,我们只需生成一个“第三者”来“插足”原图即可:

#version 300 es

precision highp float;

uniform sampler2D u_Image;
uniform vec2 u_Resolution;
in vec2 v_TexCoord;
out vec4 outColor;

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

void main() {
  vec2 st = v_TexCoord.xy / u_Resolution.xy * 20000.0;
  vec2 ipos = floor(st);
  vec3 color = vec3(random(ipos));
  vec4 tex = texture(u_Image, v_TexCoord);

  outColor = vec4(tex.rgb * 0.2 + color * 0.8, 1.0);
}

此处的“第三者”就是根据纹理像素坐标生成的 color,然后根据相应权重与原图叠加在一起:

mosaic.png

其实真正的“打码”并不是这种方式,打码一般是将指定区域内的内容做加权平均,然后让该区域内的像素展示相同的颜色。比如我们以微信截图的马赛克为例:

real-mosaic-2.png

我们使用截图工具对 cdd(臭弟弟) 打码后效果:

real-mosaic.png

会发现截图工具每次将“步兵”转成“骑兵”后,每个马赛克像素大小的大小以及位置都是相同的,并且针对于同一个图片打码后的效果也都是相同的。这就是对图片的特定大小区域内的内容做加权平均并设为相同颜色后的效果。这种效果各位可以自己尝试实现。

切换着色器🧪

上面我们编写了五种不同的片元着色器,但如何在一个程序中使用这五种不同的着色器呢?

so-easy.png

回想一下在哪儿我们用到了 Shader?是不是在 gl.attachShader 的时候?所以当我们想使用不同的 Shader 时,我们直接使用新的 programattachShader 即可(记得要 useProgram),效果如下:

switch-shaders.gif

篇末不点题

WebGL 基础 API 知识量为 1,则其所涉及的其他领域知识(诸如图形学、数学、物理等)可能为 100 甚至更多,唯有兴趣趋势才有激情和动力前进。

最近项目较忙,故停更一阵,毕竟生活、工作才是重心;后续会继如以往,分享感兴趣、有趣、有用的内容,但至于面试等相关文章,俯拾即是,不写也罢。

顺便庆祝一下公众号终于粉丝数达到了 250 🎉

fans.png

祝各位:但使心安身健,静看草根泉际☁️

课外辅导链接🔗

知识点学习📖

  1. 高斯模糊:zh.wikipedia.org/wiki/%E9%AB…
  2. 正态分布:zh.wikipedia.org/wiki/%E6%AD…
  3. 卷积:zh.wikipedia.org/wiki/%E5%8D…

GitHub Repo

  1. webgl-utils-tsgithub.com/LiJiahaoCod…
  2. webpack-react-templategithub.com/LiJiahaoCod…
  3. 本文示例源码:github.com/LiJiahaoCod…

❤️ 欢迎各位关注公众号:Refactor 🕊️