如何在 ThreeJS 中实现辉光效果

6,872

全局辉光

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

在游戏中,我们经常可以见到外发光的效果。典型的比如室内场景下的吊灯、电子设备屏幕、室外夜晚的路灯、车灯等等。这些场景的共性是他们提供了亮度和气氛的强烈视觉信息。在实际生活中,这些辉光是由于光线在大气或我们的眼睛中散射而造成的。但是渲染这些物体到屏幕上后,它所达到眼睛的光强是有限的。因此,需要人为地模拟这种效果,更加逼真地展示实际场景。

img

上图展示了一个使用和没使用发光的对比,我们在看这个顶灯时会真的有种明亮的感觉。所以泛光可以极大地提升场景中的光照效果。

那么如何人为地模拟这种效果呢?答案是数字图像处理,继续往下分析。

RTT 和后处理

不论渲染引擎,实现辉光最通用且效果最佳的方式就是后处理,简单来说就是不直接把主场景渲染的结果显示到屏幕上,而是将这个结果保存到一张纹理上,这个过程称为 RTT(渲染到纹理)。拿到这个纹理之后,再渲染另一个只有 Plane 的场景(可以理解为一个大平面容器),将纹理作为贴图传入 Plane 的材质中进行渲染。在渲染过程中,就可以使用数字图像处理实现一些特殊的效果。本质上,这些效果就是应用到了第一次渲染的主场景。所以这也是后处理(PostProcessing)的本质:对当前渲染结果的数字图像处理。

下面的图比较清晰地描述了全局辉光的后处理过程:

img

在 ThreeJS 官方提供了 UnrealBloom 这一后处理器,来实现全局辉光效果。我们结合它的源码,来大致分析辉光的实现流程。

渲染主场景

首先创建一个平面,用于保存后续图像处理的渲染结果。FullScreenQuad 是 ThreeJS 封装的一个平面容器,用于保存渲染结果的纹理。

this.fsQuad = new FullScreenQuad( null );

将主场景渲染到纹理

this.fsQuad.material = this.basic;
this.basic.map = readBuffer.texture;

renderer.setRenderTarget( null );
renderer.clear();
this.fsQuad.render( renderer );

rendererTarget 保存下来,作为最后混合的原始图像和阈值化输入。

阈值化 —— 提取亮色

在主场景渲染到纹理之后,第一步是阈值化。图像处理中的阈值化是针对图像中的某个像素,如果像素灰度高于某个值则设为1,低于某个值则设为0。那么在我们的原始图像中,要对阈值化进行特例化 —— 也就是说如果纹理中的灰度低于某个阈值,则颜色设为 (0,0,0),如果高于阈值,则保留原色。那么便可以得到一张只有“辉光”色彩信息的纹理,进入下一阶段。

那么阈值要怎么选取呢?阈值的选取决定着辉光像素的筛选,一般有两种方法——全局阈值和局部阈值,全局阈值的调参相对比较玄学,当然也可以结合直方图选取。局部阈值要和局部滤波器结合,比较复杂,具体就不再赘述了。

ThreeJS 中使用了 LuminosityHighPassShader 来处理阈值化:

// 1. Extract Bright Areas
this.highPassUniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.highPassUniforms[ 'luminosityThreshold' ].value = this.threshold;
this.fsQuad.material = this.materialHighPassFilter;

renderer.setRenderTarget( this.renderTargetBright );
renderer.clear();
this.fsQuad.render( renderer );

模糊 —— 高斯模糊

得到了阈值化并降采样后的纹理 rendererTarget1 后,可以进行下一步的模糊了。如果我们平时接触各种 P图软件,一定对模糊不陌生,其中使用比较多的模糊算法是高斯模糊。高斯模糊直观而言可以理解成每一个像素都取周边像素的平均值。下图中,2是中间点,周边点都是1。

imgimg

"中间点"取"周围点"的平均值,就会变成1。在数值上,这是一种"平滑化"。在图形上,就相当于产生"模糊"效果,"中间点"失去细节。高斯模糊的效果取决于模糊半径和权重分配。模糊半径直观而言就是计算周围多少个点,可以是 3*3,也可以 5*5,显然模糊半径越大,模糊效果越明显。而权重分配是指在计算平均值过程中,对每个点的权重。上述示例中我们使用了简单平均,显然不是很合理,因为图像都是连续的,越靠近的点关系越密切,越远离的点关系越疏远。因此,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小。因此我们使用正态分布曲线来做加权平均。所以高斯函数就是在二维下的正态函数分布。具体的计算过程在此不赘述,大部分库也都帮我们封装好了。

在实现过程中,我们还要考虑性能问题。如果对一个32*32的四方形区域采样,那么必须对每个点在一个纹理中采样 1024 次。但高斯方程有一个巧妙的特性是,可以把二维方程分解成两个更小的方程:一个描述水平权重,另一个描述垂直权重。我们首先用水平权重在整个纹理上进行水平模糊,然后在经改变的纹理上进行垂直模糊。利用这个特性,结果是一样的,但是可以节省非常多的性能,因为我们现在只需做32+32 次采样,不再是1024了!这个过程便是两步高斯模糊。

img

// 2. Blur All the mips progressively
let inputRenderTarget = this.renderTargetBright;
for ( let i = 0; i < this.nMips; i ++ ) {
  this.fsQuad.material = this.separableBlurMaterials[ i ];
  this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = inputRenderTarget.texture;
  this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionX;
  renderer.setRenderTarget( this.renderTargetsHorizontal[ i ] );
  renderer.clear();
  this.fsQuad.render( renderer );

  this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = this.renderTargetsHorizontal[ i ].texture;
  this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionY;
  renderer.setRenderTarget( this.renderTargetsVertical[ i ] );
  renderer.clear();
  this.fsQuad.render( renderer );

  inputRenderTarget = this.renderTargetsVertical[ i ];
}

ThreeJS 中使用了相对简单的高斯模糊过滤器,它在每个方向上只有5个样本,kernalSize 从 3 递增到 11,通过沿着更大的半径来重复更多次数的模糊,进行采样从而提升模糊的效果。

// Gaussian Blur Materials
this.separableBlurMaterials = [];
const kernelSizeArray = [ 3, 5, 7, 9, 11 ];
resx = Math.round( this.resolution.x / 2 );
resy = Math.round( this.resolution.y / 2 );

for ( let i = 0; i < this.nMips; i ++ ) {
  this.separableBlurMaterials.push( this.getSeperableBlurMaterial( kernelSizeArray[ i ] ) );
  this.separableBlurMaterials[ i ].uniforms[ 'texSize' ].value = new Vector2( resx, resy );
  resx = Math.round( resx / 2 );
  resy = Math.round( resy / 2 );
}

其中 getSeperableBlurMaterial 中是实现高斯模糊的 shader,片段着色器部分代码如下:

#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 texSize;
uniform vec2 direction;
float gaussianPdf(in float x, in float sigma) {
  return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;
}
void main() {
  vec2 invSize = 1.0 / texSize;
  float fSigma = float(SIGMA);
  float weightSum = gaussianPdf(0.0, fSigma);
  vec3 diffuseSum = texture2D( colorTexture, vUv).rgb * weightSum;
  for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
    float x = float(i);
    float w = gaussianPdf(x, fSigma);
    vec2 uvOffset = direction * invSize * x;
    vec3 sample1 = texture2D( colorTexture, vUv + uvOffset).rgb;
    vec3 sample2 = texture2D( colorTexture, vUv - uvOffset).rgb;
    diffuseSum += (sample1 + sample2) * w;
    weightSum += 2.0 * w;
  }
  gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
}

因为模糊的质量与泛光效果的质量正相关,提升模糊效果就能够提升泛光效果。有些提升将模糊过滤器与不同大小的模糊 kernel 或采用多个高斯曲线来选择性地结合权重结合起来使用。

另外,在循环过程中,我们发现每次渲染的尺寸 resx/resy 都被减少到 1/4。这种操作是降采样处理,为了降低纹理分辨率,降低后处理运算的开销,也可以使得模糊运算用更小的窗口模糊更大的范围。当然降采样也会带来走样的问题,这个的解决方法需要具体项目具体考量,这里不再赘述。

混合

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

// Composite All the mips
this.fsQuad.material = this.compositeMaterial;
renderer.setRenderTarget( this.renderTargetsHorizontal[ 0 ] );
renderer.clear();
this.fsQuad.render( renderer );

compositeMaterial 就是最终混合所有纹理的材质,实现如下:

// ...
float lerpBloomFactor(const in float factor) {
  float mirrorFactor = 1.2 - factor;
  return mix(factor, mirrorFactor, bloomRadius);
}
void main() {
  gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
                                  lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
                                  lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
                                  lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
                                  lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
}

下面是 ThreeJS 提供的一个 demo,可以调节四个参数看看会有什么不同的影响。

部分辉光

上面的效果看起来很不错对吧?但是当我们实际使用起来,就遇到问题了。有时候我们只希望某个物体发光,但是在阈值化这步过程中采用了全局阈值的方式,那么就会导致其他不希望有辉光效果的物体出现了辉光。

ThreeJS 也提供了一个 demo 来解决这个问题。

主要的思路是:

  • 创建辉光图层,将辉光物体添加在该图层上,用于区分辉光物体和非辉光物体
const BLOOM_LAYER = 1;
const bloomLayer = new THREE.Layers();
bloomLayer.set(BLOOM_LAYER);

Three中为所有的几何体分配 1个到 32 个图层,编号从 0 到 31,所有几何体默认存储在第 0 个图层上,我们可以任意设置 BLOOM_LAYER 的值。

  • 准备两个后处理器 EffectComposer,一个 bloomComposer 产生辉光效果,另一个 finalComposer 用来正常渲染整个场景
 const renderPass = new THREE.RenderPass(scene, camera);
 // bloomComposer效果合成器 产生辉光,但是不渲染到屏幕上
 const bloomComposer = new THREE.EffectComposer(renderer);
 bloomComposer.renderToScreen = false; // 不渲染到屏幕上
 bloomComposer.addPass(renderPass);

// 最终真正渲染到屏幕上的效果合成器 finalComposer 
const finalComposer = new THREE.EffectComposer(renderer);
finalComposer.addPass(renderPass);
  • 将除辉光物体外的其他物体材质转为黑色(即保证阈值化过程中不保留这部分信息)
const materials = {};
function darkenNonBloomed( obj ) {
  if ( obj.isMesh && bloomLayer.test( obj.layers ) === false ) {
    materials[ obj.uuid ] = obj.material;
    obj.material = darkMaterial;
  }
}
  • 在 bloomComposer 中利用 UnrealBloomPass 实现辉光,但不需要渲染到屏幕上
 const bloomPass = new UnrealBloomPass(
    new THREE.Vector2(renderer.domElement.offsetWidth, renderer.domElement.offsetHeight), 1, 1, 0.1,
  );
 bloomComposer.addPass(bloomPass);
  • 再将转为黑色材质的物体还原为初始材质
const darkMaterial = new THREE.MeshBasicMaterial( { color: "black" } );
function restoreMaterial( obj ) {
  if ( materials[ obj.uuid ] ) {
  	obj.material = materials[ obj.uuid ];
  	delete materials[ obj.uuid ];
  }
}
  • 利用 finalComposer 渲染,finalComposer 将加入两个通道,一个是 bloomComposer 的渲染结果,另一个则是正常的渲染结果。
const shaderPass = new ShaderPass(
  new THREE.ShaderMaterial({
    uniforms: {
    	baseTexture: { value: null },
    	bloomTexture: { value: bloomComposer.renderTarget2.texture },
    },
  	vertexShader: vs,
 		fragmentShader: fs,
  	defines: {},
  }),
  'baseTexture',
); // 创建自定义的着色器Pass,详细见下
shaderPass.needsSwap = true;
finalComposer.addPass(shaderPass);

其中 shaderPass 的作用即使将两种 baseTexture 和 bloomTexture 混合在一起:

// vertextshader
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}

// fragmentshader
uniform sampler2D baseTexture;
uniform sampler2D bloomTexture;
varying vec2 vUv;
void main() {
  gl_FragColor = ( texture2D( baseTexture, vUv ) + vec4( 1.0 ) * texture2D( bloomTexture, vUv ) );
}
  • 最终的渲染循环函数调用:
function animate(time) {
  // ...
  // 实现局部辉光
  // 1. 利用 darkenNonBloomed 函数将除辉光物体外的其他物体的材质转成黑色
  scene.traverse(darkenNonBloomed);
  // 2. 用 bloomComposer 产生辉光
  bloomComposer.render();
  // 3. 将转成黑色材质的物体还原成初始材质
  scene.traverse(restoreMaterial);
  // 4. 用 finalComposer 作最后渲染
  finalComposer.render();

  requestAnimationFrame(animate);
}

辉光所带来的问题

解决了一个问题,新的问题又出现了

黑色材质处理

在使用过程中,我们还发现了一个问题,使用了 TransformConstrol 后出现了奇怪的现象,这个组件是用于拖拽控制对象的。我们还原一个最简单的场景,左边的蓝色 box 没有使用辉光,右边的黄色 box 使用了辉光。正常渲染如图左。但是使用 TransformConstrol 之后,在拖动物体时,却出现了图右的现象。

看起来是 TransformControl 的材质渲染受到了影响。在实现部分辉光效果的过程中,我们将辉光和非辉光物体区分,并先将非辉光物体使用黑色材质渲染,其中替代的黑色材料使用了 MeshBasicMaterial ,初略看起来没什么问题,但如果我们在场景中加入其他类型的材质呢? TransformConstrol 的实现使用了 LineBasicMaterial ,也就是渲染非辉光物体时使用了 MeshBasicMaterial 作用于 Line,所以也就导致了渲染出现的现象。概括而言就是某个物体使用了 A 材质,在 darkenNonBloomed 过程中使用了黑色的 B 材质进行渲染,而非黑色的 A 材质渲染,那么肯定会导致最终渲染出错。

所以在 darkenNonBloomed 过程中,要具体分析材质的原型,然后分别创建相应的黑色材质并保存:

const materials = {};
const darkMaterials = {};
export const darkenNonBloomed = (obj) => {
  const material = obj.material;
  if (material && bloomLayer.test(obj.layers) === false) {
    materials[obj.uuid] = material;
    if (!darkMaterials[material.type]) {
      const Proto = Object.getPrototypeOf(material).constructor;
      darkMaterials[material.type] = new Proto({ color: 0x000000 });
    }
    obj.material = darkMaterials[material.type];
  }
};

这一步以后,我们可以发现 TransformControl 就渲染正常了,同时可以测试在场景中加入其他 Line Points 之类的物体,都没有受到影响。

demo

透明度失效

有时候,我们希望通过设置容器的背景色来实现整体效果,所以会把 renderer 的透明度设为0。

renderer.setClearAlpha(0);

但使用了 UnrealBloomPass 后,我们发现整体背景背景的透明度设置却不生效了。

image-20211103175318581

分析源码,可以知道 UnrealBloomPass 会影响渲染器的alpha通道(见第一节的高斯模糊部分代码)

gl_FragColor = vec4(diffuseSum/weightSum, 1.0);

最终的着色器颜色被处理为 vec4(diffuseSum/weightSum, 1.0),alpha 通道始终为1。要解决这个问题,只有修改源码:

for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
  float x = float(i);
  float w = gaussianPdf(x, fSigma);
  vec2 uvOffset = direction * invSize * x;
  vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);
  vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);
  diffuseSum += (sample1.rgb + sample2.rgb) * w;
  alphaSum += (sample1.a + sample2.a) * w;
  weightSum += 2.0 * w;
}
gl_FragColor = vec4(diffuseSum/weightSum, alphaSum/weightSum);

其中第 23 行即是对两个 sample 的 alpha 通道取平均计算。

性能

在实际使用过程中,我们发现辉光后处理还是非常影响性能的。首先辉光后处理本身就需要大量图像处理计算,而且要执行好几遍(在 ThreeJS 中有 5 次高斯模糊计算),此外我们为了实现部分辉光效果,又手动加入了辉光物体与非辉光物体的区分,因此整体性能肯定就高不了。

在性能这部分,我们只是凭直观感觉,如加了辉光后的 FPS 下降不少。但具体是怎么影响的,还没有深入研究。而且辉光作为渲染中重要的一种效果,肯定是需要用的,因此解决性能问题还需深入探讨。目前的思路是摈弃 ThreeJS 提供的 UnrealBloom,根据第一节所述的基本原理,通过自定义 Shader 实现效果,并且可以根据实际场景细粒度控制(主要过程即在于阈值化这一步,可以通过局部阈值化实现)。但具体还没有开始实现,占坑。

内发光

边缘发光效果是在三维场景里非常常见的一种效果,目的是为了凸显场景中的某个物体,边缘发光分为内发光和外发光,顾名思义,外发光就是边缘光从边缘向外外扩散逐渐衰减的效果,而内发光是边缘向内扩散逐渐衰弱的效果。前述的辉光效果即是外发光效果,那么内发光效果该如何实现呢?

内发光效果与外发光的最大区别是”边缘“和”内部“这两个关键词,因为是在模型内部发光,所以完全可以针对模型自身的材质去实现,不必使用后处理。这一点在性能上显然是有优势的。当然最重要的还是它本身效果的适用范围。

现象

在现实生活中,边缘轮廓发光的最常见的例子就是平静而深远的水面。当我们站在湖边看着湖面时,会发现在脚下的湖面中的水是透明的,反射并不强烈,而望向远处时,却发现水并不透明,只能看到反射的结果。也就是说,当视线和观察物体表面的夹角越小时,反射越明显。

img

菲涅尔反射

上面的这种现象在光学中叫做“菲涅尔反射”。其本质上是由光从一种介质传播到另一种介质中的反射和折射造成的。一般来讲,对于金属外的绝大多数介质,光总在法线入射时反射比最小,即反光最少,而在和法线垂直的方向入射时,其反射比达到最大(不透射)。

所以我们可以在计算机中很简单地模拟这种现象,只要有模型上某顶点的法线和当前摄像机的视线,便可以通过很小的计算量计算出光强,从而得到这种轮廓边缘内发光的效果。看图说话:

img

当物体表面的法线平行于屏幕的时候,也就是Camera基本水平看向这个表面的时候,此时的反射光应该是最强的。

常用的菲涅尔近似等式有:Fschlick(v,n)=F0+(1F0)(1vn)F_{schlick}(v,n) = F_0 + (1-F_0)(1-v \cdot n)

其中 F0F_0 是反射系数,用于控制反射强度,vv 是视角方向,nn 是法线方向。

另一个等式:FEmpricial(v,n)=max(0,min(1,bias+scale×(1vn)power))F_{Empricial}(v,n) = \max(0, \min(1, bias + scale \times (1-v\cdot n)^{power}))

那么首先在顶点着色器中计算视角和法线:

uniform vec3 view_vector; // 视角
varying vec3 vNormal; // 法线
varying vec3 vPositionNormal;
void main() {
  vNormal = normalize( normalMatrix * normal ); // 转换到视图空间
  vPositionNormal = normalize(normalMatrix * view_vector);
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}

片段着色器则应用上述公式,计算出透明度变化:

uniform vec3 glowColor;
uniform float b;
uniform float p;
uniform float s;
varying vec3 vNormal;
varying vec3 vPositionNormal;
void main() {
  float a = pow(b + s * abs(dot(vNormal, vPositionNormal)), p );
  gl_FragColor = vec4( glowColor, a );
}

在渲染过程中,如果摄像机的视角发生改变,则要实时更新:

function render() {
  const newView = camera.position.clone().sub(controls.target);
  customMaterial.uniforms.view_vector.value = newView;

  renderer.render( scene, camera );
  requestAnimationFrame(render);
}

渲染结果:

更细致的控制

仅有朴素的效果肯定是不够的,我们还希望对发光的效果有这更细致的控制。比如反光的范围、方向、光强增速等。当然这些参数在公式中都已经反映了:bias 值决定了颜色最亮值的位置,power 决定了光强变化速度及方向,scale 控制了发光的方向和范围。

我们可以在 demo 中调节参数来查看效果。

限制

菲涅尔反射是根据法线和视线的夹角来计算最终照明的光强的,所以对于立方体棱柱这种平整模型,由于模型的每个面的法线都一致,所以无法达到想要的效果。如果一定要使用这种效果,可以考虑曲面细分平滑或者利用法线贴图来修改顶点法线。

参考

作者:ES2049 / timeless

文章可随意转载,但请保留此原文链接。

非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com