使用three.js实现各种滤镜效果

3,030 阅读6分钟

前言

在three.js中实现各种滤镜效果,需要用到自定义着色器材质ShaderMaterial。

关于着色器的基础知识,我们在 用three.js写一个3D地球 一文中进行了介绍。这里就直接使用自定义着色器啦。

滤镜基础知识

WEBGL中的颜色表示法是RGBA,每一个像素点的颜色值由R、G、B、A 四个值 决定,其中每个分量的值都是范围在【0,1】之间的浮点数,例如,黑色是(0.0, 0.0, 0.0, 1.0),白色是(1.0, 1.0, 1.0, 1.0)。

three.js封装了WEBGL的许多实现细节,让开发3D应用变得简单,同时又开放了自定义着色器材质ShaderMaterial,让开发者可以自由地操作像素,实现各种定制的效果。

例如,通过自定义着色器,我们可以手动修改每个像素颜色值的四个分量值。下面我们还是以地球贴图为例。

用ShaderMaterial创建物体

我们用ShaderMaterial创建一个地球,代码如下。

//顶点着色器代码片段(在下面创建着色器材质时,作为参数传入)
var VSHADER_SOURCE = `
  varying vec2 v_Uv;    //顶点纹理坐标
  void main () {
    v_Uv = uv;
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
  }
`

//片元着色器代码片段(在下面创建着色器材质时,作为参数传入)
var FSHADER_SOURCE = `
  uniform sampler2D e_Texture;     //纹理图像
  varying vec2 v_Uv;               //片元纹理坐标
  void main () {
    gl_FragColor = texture2D( e_Texture, v_Uv );  //提取纹理图像的颜色值(纹素),并赋值给gl_FragColor
  }
`

//加载贴图纹理图像
var earthTexture = loader.load('./images/earth.png')

//创建着色器材质
var material = new THREE.ShaderMaterial({
  uniforms: {
    e_Texture: {
      value: earthTexture   //纹理图像
    }
  },
  // 指定顶点着色器
  vertexShader: VSHADER_SOURCE,
  // 指定片元着色器
  fragmentShader: FSHADER_SOURCE,
})

//创建集合体
var geometry = new THREE.SphereGeometry(20,30,30)

//创建物体
var sphere = new THREE.Mesh(geometry, material)

//将物体加到场景中
scene.add(sphere)

在上面的片元着色器代码片段中,我们通过 texture2D函数 提取纹理图像某一点的颜色值(纹素),这个函数的返回值是一个四维向量,分别代表R、G、B、A 四个分量。

获取到纹素后,将其赋值给gl_FragColor,用于指定片元的颜色。

RGBA分量操作

其实,我们在获取纹素后,可以先对 R、G、B、A 这四个分量进行一些操作,再赋值给gl_FragColor。

例如,把每个纹素的绿色(G)分量置为0,修改片元着色器代码如下:

var FSHADER_SOURCE = 
 `uniform sampler2D e_Texture;
  varying vec2 v_Uv;
  void main () {
    vec4 textureColor = texture2D( e_Texture, v_Uv );
    float green = textureColor.g * 0.0;     //修改颜色值的绿色(G)分量为0
    gl_FragColor = vec4(textureColor.r, green, textureColor.b, 1.0);
  }`

效果如下:

看,是不是已经有点滤镜的感觉了。

其实滤镜的本质,就是按照某种算法修改每个像素点的颜色值,更具体一点,就是修改 R、G、B、A 这四个分量值。

下面来实现一个常见的灰度滤镜吧。

灰度滤镜的原理和矩阵实现

基础原理和实现

灰度的原理,简单来说就是将一张彩色图片变成灰白色图片。

常见的灰度算法有三种:

  • 最大值法

  • 平均值法

  • 加权平均值法

下面要实践的是,加权平均值法。

具体的实现思路是,我们先将该图片的每个像素点的R、G、B分量值进行加权平均,然后将这个值作为每个像素点新的R、G、B分量值,具体公式如下:

其中 R、G、B 是原图片中的 R、G、B 通道的色值,V 是加权平均色值,a、b、c 是加权系数,满足 (a + b + c) = 1。

基于上面的原理,我们来实现一下。修改片元着色器代码:

var FSHADER_SOURCE = 
  `uniform sampler2D e_Texture;
  varying vec2 v_Uv;
  void main () {
    vec4 textureColor = texture2D( e_Texture, v_Uv );
    //计算加权平均值
    float w_a = textureColor.r * 0.3 + textureColor.g * 0.6 + textureColor.b * 0.1;
    gl_FragColor = vec4(w_a, w_a, w_a, 1.0);
  }`

这里的加权系数分别为 a = 0.3, b = 0.6, c = 0.1,是因为人的视觉对 R、G、B 三色的敏感度不一样,所以三种颜色值的权重不一样,一般来说绿色最高,红色其次,蓝色最低。你可以修改成其他值,看看效果。

矩阵实现

如果要调整加权系数或者实现其他滤镜,就要再一次修改片元着色器代码。这样太麻烦了。

下面我们将借助矩阵来优化代码。

由于GLSL ES中的矩阵最多是四维的,所以,这里就这对 R、G、B 三个量进行矩阵变换,透明度alpha单独设置。

js代码修改如下:

//着色器材质
var material = new THREE.ShaderMaterial({
  uniforms: {
    e_Texture: {
      value: earthTexture
    },
    t_Matrix: {
      value: grayMatrix    //变换矩阵由js传入
    }
  },
  // 顶点着色器
  vertexShader: VSHADER_SOURCE,
  // 片元着色器
  fragmentShader: FSHADER_SOURCE
})

片元着色器的代码也做相应的修改:

var FSHADER_SOURCE = 
 `uniform sampler2D e_Texture;
  uniform mat4 t_Matrix;     //接收变换矩阵
  varying vec2 v_Uv;
  void main () {
    vec4 textureColor = texture2D( e_Texture, v_Uv );
    //变换矩阵乘以 vec4(R,G,B,1)    --->vec4(R,G,B,1) 是齐次坐标,原本是n维的向量用一个n+1维向量来表示
    //vec4(R,G,B,1)第四个分量不是透明度
    vec4 transColor = t_Matrix * vec4(textureColor.r, textureColor.g, textureColor.b, 1.0); 
    //设置透明度
    transColor.a = 1.0;
    gl_FragColor = transColor;
  }`

按照矩阵与向量的乘法,灰度滤镜的变换矩阵应该是下面这样:

但在js中传入的矩阵却是下面这样,OpenGL API接受的矩阵要求是列主序的,如果一个OpenGL的应用使用的是行主序的矩阵,那么在将矩阵传给OpenGL API前,需要先转换为列主序。我们这里就手动转置了。

参考资料:OpenGL中矩阵的行主序与列主序

使用矩阵,可以很好地抽象和复用。一个矩阵就代表一种变换,只要乘上这个矩阵,就意味着做了同样的变换,而当你要修正某种变换,就只用修改它对应的变换矩阵。

几种滤镜效果的实现

实现下面几种滤镜效果,其实就是使用了几种不同的矩阵。(值得注意的是,下面的变换矩阵也都是转置过后的列主序矩阵。要推导的话请将下面的矩阵先转置回行主序)

反色

实现反色,就是 R、G、B 每个分量对 1 取补。

变换矩阵:

var oppositeMatrix = new Float32Array([
   -1, 0, 0, 0,
   0, -1, 0, 0,
   0, 0, -1, 0,
   1, 1, 1, 1
])

亮度

R、G、B 每个分量都乘上一个值p; p < 1 调暗,p = 1 原色, p > 1 调亮。

变换矩阵:(传入参数p,动态生成变换矩阵)

function genBrightMatrix (p) {
    return new Float32Array([
      p, 0, 0, 0,
      0, p, 0, 0,
      0, 0, p, 0,
      0, 0, 0, 1
    ])
}

对比度

R、G、B 每个分量都乘上一个值p,然后加上 0.5 * (1 - p); p = 1 原色, p < 1 减弱对比度,p > 1 增强对比度。

基于矩阵的实现:(传入参数p,动态生成变换矩阵)

function genContrastMatrix (p) {
    var d = 0.5 * (1 - p)
    return new Float32Array([
      p, 0, 0, 0,
      0, p, 0, 0,
      0, 0, p, 0,
      d, d, d, 1
    ])
}

饱和度

传入参数p,计算方法如下。p = 0 完全灰度化,p = 1 原色,p > 1 增强饱和度。

function genSaturateMatrix (p) {
    var rWeight = 0.3 * (1 - p)
    var gWeight = 0.6 * (1 - p)
    var bWeight = 0.1 * (1 - p)
    return new Float32Array([    rWeight + p, rWeight, rWeight, 0,    gWeight, gWeight + p, gWeight, 0,    bWeight, bWeight, bWeight + p, 0,    0, 0, 0, 1    ])
}

今天就到这里啦,这篇文章主要探讨了一些基础滤镜的原理和矩阵的初级用法。更多酷炫滤镜的算法在后面的文章再慢慢解锁,下次见!

参考

极客时间《跟月影学可视化》