前端视觉交互——全屏泛光

少女dtysky

世界Skill

时刻2018.09.09

Code: github.com/dtysky/paradise/tree/master/src/collection/GlobalBloomgithub.com/dtysky/paradise/tree/master/src/collection/GlowEmissiveMap

Demo: paradise.dtysky.moe/effect/global-bloomparadise.dtysky.moe/effect/glow-emissive-map

原理

本次的效果是全局辉光(Bloom),又称泛光。它其实是一种作用于特定区域的外发光效果。

外发光

在游戏中,我们经常可以见到外发光的效果。典型的比如在室内场景的吊灯、电子设备的屏幕、室外夜晚的路灯,还有车灯等。这些场景的共性是它们提供了亮度和气氛的强烈视觉信息。当我们观看屏幕等物体时,它们到达眼睛的光强是有限的,所以我们便通过其周围的辉光/泛光/光晕来辨别它们。在现实中,这些辉光是由于光线在大气或我们眼睛中产生散射而造成的。而利用数字图像处理的方法,我们可以比较简单得模拟这种效果。

RTT和后处理

一个最通用并且效果最佳的实现辉光的方法是后处理,它要求我们并不直接把主场景渲染的结果显示到屏幕上,而是将这个结果保存到一张纹理上,这个过程称为RTT(渲染到纹理)。拿到这个纹理之后,我们再渲染另一个只有一个全屏的Plane的场景,将前面保存的纹理作为贴图传入这个Plane的材质中进行渲染,在这个渲染的过程中我们可以添加一些特殊的效果,本质上,这些效果其实是应用于第一次渲染的主场景的图像的,所以这其实是一种对当前渲染结果的数字图像处理,这也就是后处理的本质。

对于这里要说的全局辉光,下面这张图比较清晰得描述了整个过程:

首先我们将主场景渲染到一张纹理上,而后将其应用到后处理专用场景中Plane的材质中,我们不断地重复RTT和后处理过程,最终得到了一张模糊后的纹理,最终我们将这个纹理和最初主场景的原始渲染纹理进行alpha混合,便可以得到最终的结果。下面让我们细致地分析一下整个过程。

渲染主场景

首先我们将主场景渲染到纹理:

this.rendererTarget0 = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight);
this.renderer.render(this.scene, this.camera, this.rendererTarget0, true);

我们将this.rendererTarget0保存下来,作为最后混合的原始图像和阈值化的输入。

阈值化

当将主场景渲染到纹理后,我们要做的第一步就是阈值化。阈值化是图像处理中的一个概念,其本质是是将灰度高于某个值的像素颜色设为1,反之设为0,这是针对灰度图像的阈值化。而在本例中,我们对这个阈值化进行特例化——我们设定一个阈值,将纹理中灰度低于这个阈值的像素设为(0, 0, 0),而灰度高于此阈值的像素,则保留,如此一来我们便可以得到一张只有“辉光”处色彩信息的纹理。将这张纹理保留下来,进行下一阶段:

这里有两个注意点,其一是阈值的选取,其二是降采样

阈值的选取决定着辉光像素的筛选,一般有两种方法——全局阈值和局部阈值,全局阈值又有玄学调参、直方图选取等方式,局部阈值要和局部滤波器结合,这些内容在我大学时的论文中有详细分析,详见FPGA/图像处理】点操作—阈值化FPGA/图像处理】局部滤波器-局部阈值化

降采样,则是指降低纹理分辨率,降低后处理运算的开销。一般来讲是将要后处理的纹理降低到屏幕大小的四分之一,这样也可以使得模糊运算用更小的窗口模糊更大的范围。当然降采样也会带来走样的问题,这个的解决方法需要具体项目具体考量,这里不再赘述。

下面来看看代码:

this.rendererTarget1 = new THREE.WebGLRenderTarget(window.innerWidth / 2, window.innerHeight / 2);

this.quad.material = this.thresholdMaterial;
this.thresholdMaterial.uniforms.tDiffuse.value = this.rendererTarget0.texture;
this.renderer.render(this.finalScene, this.finalCamera, this.rendererTarget1, true);
uniform float threshold;
uniform sampler2D tDiffuse;

varying vec2 vUv;

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  float gray = color.r * .299 + color.g * .587 + color.b * .114;
  float th_color = gray > threshold ? 1. : 0.;

  if (gray > threshold) {
    gl_FragColor = color;
  } else {
    gl_FragColor = vec4(0., 0., 0., 1.0);
  }
}

可见,这里我通过阈值threshold来控制哪些像素和辉光像素。

分步模糊

得到了阈值化并降采样后的纹理rendererTarget1后,便可以进行下一步的模糊了。模糊本质上是一种利用卷积计算的局部滤波器,这一点可以参考我之前对均值滤波器的分析:【FPGA/图像处理】局部滤波器-均值滤波器。一般而言,此种模糊使用的都是高斯模糊,但我这里为了代码简单实例,用了一个XJB搞的均值滤波器。

如果看完了上面的文章,读者应该了解到局部滤波器本质上都是在一个窗口中完成的,窗口的大小一般为3x3、5x5等,而处理,则是以当前像素为中心取得的窗口中所有像素做一个卷积运算,当然在此DEMO的做法中是全部相加然后除以一个玄学值N,这相当于乘了一个所有元素都是1/N的卷积。

一般而言,这种操作对于每个像素都要做一个双重循环,毕竟要遍历每一行的每一列嘛,这样一来,如果是一个n x n的窗口,运算的总时间复杂度是O(n^2)(GPU运算先天并行,不考虑纹理大小)。这里我们就可以想到,能不能拆分行和列的两次模糊,来降低时间复杂度呢?当然是可以的,这也就是所谓的分布模糊

分布模糊的想法很简单,就是以空间换时间,我们先对每个像素进行横向的模糊,渲染到一个纹理,然后再输入这个纹理,对每个像素进行纵向的模糊。这样一来,我们仍然可以得到最后的模糊结果,而时间复杂度却降到了O(n)

让我们看看代码:

this.rendererTarget2 = new THREE.WebGLRenderTarget(window.innerWidth / 2, window.innerHeight / 2);

this.quad.material = this.localFilterMaterial;
this.localFilterMaterial.uniforms.vDirection.value = false;
this.localFilterMaterial.uniforms.tThreshold.value = this.rendererTarget1.texture;
this.renderer.render(this.finalScene, this.finalCamera, this.rendererTarget2, true);

this.localFilterMaterial.uniforms.vDirection.value = true;
this.localFilterMaterial.uniforms.tThreshold.value = this.rendererTarget2.texture;
this.renderer.render(this.finalScene, this.finalCamera, this.rendererTarget1, true);
#define WINDOW_SIZE 5.
#define DOUBLE_WINDOW_SIZE 10.

uniform float stepSize;
uniform bool vDirection;
uniform sampler2D tThreshold;

varying vec2 vUv;

void main() {
  vec4 blur = texture2D(tThreshold, vUv);

  if (vDirection) {
    for (float i = 1.; i <= WINDOW_SIZE; i += 1.) {
      blur += texture2D(tThreshold, vUv + vec2(0., i * stepSize));
      blur += texture2D(tThreshold, vUv - vec2(0., i * stepSize));
    }
  } else {
    for (float i = 1.; i <= WINDOW_SIZE; i += 1.) {
      blur += texture2D(tThreshold, vUv + vec2(i * stepSize, 0.));
      blur += texture2D(tThreshold, vUv - vec2(i * stepSize, 0.));
    }
  }

  blur.rgb = blur.rgb / DOUBLE_WINDOW_SIZE;

  gl_FragColor = blur;
}

这里我用vDirection控制是横向模糊还是纵向模糊,用stepSize控制窗口中每个像素的uv偏移,用WINDOW_SIZE定义窗口大小。通过这两次运算,便得到了模糊后的高光纹理图像。

辉光和色调映射

有了原始的渲染纹理和模糊后的辉光纹理,我们便可以进行最后的混合了:

this.quad.material = this.bloomMaterial;
this.bloomMaterial.uniforms.tDiffuse.value = this.rendererTarget0.texture;
this.bloomMaterial.uniforms.tBlur.value = this.rendererTarget1.texture;
this.renderer.render(this.finalScene, this.finalCamera);
uniform sampler2D tDiffuse;
uniform sampler2D tBlur;
uniform float toneExp;

varying vec2 vUv;

void main() {
  vec3 diffuseColor = texture2D(tDiffuse, vUv).rgb;
  vec3 blurColor = texture2D(tBlur, vUv).rgb;

  vec3 result = diffuseColor + blurColor;

  result = vec3(1.) - exp(-result * toneExp);

  gl_FragColor = vec4(result, 1.);
}

可见我分别对两个纹理进行了采样,而后将它们简单地混合到了一起,当然我这里没有用alpha混合而是简单的色彩混合,这和我前面模糊时的做法有关。由于是色彩混和,所以相加得到的结果result中的色彩值可能会超过0 ~ 1的色域,也就是说,我们得到的是一个高动态范围的结果。这就需要我们用Tone Mapping(色调映射)方法,来将高动态的渲染结果归一化到有限动态范围中,这也是HDR的原理之一。

色调映射本身是一个大话题,其发展历史和种类都十分繁多,在这里我们使用的是一种简单高效效果又不错的方法,即公式result = vec3(1.) - exp(-result * toneExp);,这个公式利用定义域在-∞ ~ 0的指数函数来进行映射,配合一个参数toneExp来调整曲线。

至此,我们便得到了最终的结果:

非后处理实现

对于少量模型的局部辉光也是可行的,但技术和上面的描述完全不同。它本质上是创建一个Billboard,上面渲染着平滑的“辉光纹理”,而后让这个Billboard跟随着要进行辉光的模型。但这种方法通常还是比较适用于2D物体,对于要时长变换视角的3D物体比较困难,适用面比较窄吧。

更可控

一般的辉光流程按照上一章的全局辉光-后处理+色调映射即可实现,不过那个实现方法有个弊端,就是阈值化这一步,辉光部分的筛选是程序决定的,很大程度上不可控,在现实应用中,我们需要更可控的方法来操纵辉光达到想要的效果。

自发光贴图

决定最终渲染风格的是美术人员,所以我们当然希望美术能决策整个场景,甚至将这种决策细化到单个模型。方法也很简单,和其他很多效果一样,我们可以建立一张纹理,通过采样这个纹理来决定辉光的强度和颜色,借此来取代阈值化的过程。

比如在这个DEMO中,我渲染了一个宇宙战舰(Unity免费模型),为了让战舰的特定部分辉光,我直接用EmissiveMap,即自发光纹理作为辉光的来源,由于战舰整体分为三个部分,我这里只给出一个部分的EmissiveMap作为实例:

整体的渲染流程和标准的全局辉光并无区别,在第一步还是正常地渲染整个场景,只是在筛选辉光像素这一部分,我修改了模型的材质,这个材质只以emissiveemissiveMap为基础色,渲染的结果即为标准流程中阈值化的结果:

// init
this.scene.traverse(node => {
  if (!(node instanceof THREE.Mesh)) {
    return;
  }

  const material = node.material as THREE.MeshStandardMaterial;
  const {emissive, emissiveMap} = material;

  this.originMaterials[node.uuid] = material;
  this.emissiveMaterials[node.uuid] = new THREE.MeshBasicMaterial({color: emissive, map: emissiveMap});
});

// loop
this.scene.traverse(node => {
  if (!(node instanceof THREE.Mesh)) {
    return;
  }

  if (this.originMaterials[node.uuid]) {
    node.material = this.emissiveMaterials[node.uuid];
  }
});
this.renderer.render(this.scene, this.camera, this.rendererTarget1, true);

接下来的流程和标准辉光流程一致。最终渲染效果如下:

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。