乐趣区

关于前端:如何在-ThreeJS-中实现辉光效果

全局辉光

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

在游戏中,咱们常常能够见到外发光的成果。典型的比方室内场景下的吊灯、电子设备屏幕、室外夜晚的路灯、车灯等等。这些场景的共性是他们提供了亮度和氛围的强烈视觉信息。在理论生存中,这些辉光是因为光线在大气或咱们的眼睛中散射而造成的。然而渲染这些物体到屏幕上后,它所达到眼睛的光强是无限的。因而,须要人为地模仿这种成果,更加真切地展现理论场景。

上图展现了一个应用和没应用发光的比照,咱们在看这个顶灯时会真的有种亮堂的感觉。所以泛光能够极大地晋升场景中的光照成果。

那么如何人为地模仿这种成果呢?答案是数字图像处理,持续往下剖析。

RTT 和后处理

不管渲染引擎,实现辉光最通用且成果最佳的形式就是后处理,简略来说就是不间接把主场景渲染的结果显示到屏幕上,而是将这个后果保留到一张纹理上,这个过程称为 RTT(渲染到纹理)。拿到这个纹理之后,再渲染另一个只有 Plane 的场景(能够了解为一个大立体容器),将纹理作为贴图传入 Plane 的材质中进行渲染。在渲染过程中,就能够应用数字图像处理实现一些非凡的成果。实质上,这些成果就是利用到了第一次渲染的主场景。所以这也是后处理(PostProcessing)的实质:对以后渲染后果的数字图像处理。

上面的图比拟清晰地形容了全局辉光的后处理过程:

在 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。

<img src=”https://img.alicdn.com/imgextra/i1/O1CN014VEwhT1Gjriw6Ka2R_!!6000000000659-2-tps-395-330.png” alt=”img” style=”zoom: 50%;” /><img src=”https://img.alicdn.com/imgextra/i4/O1CN01xomOdi202mnMblmtf_!!6000000006792-2-tps-394-347.png” alt=”img” style=”zoom:50%;” />

“ 两头点 ” 取 ” 四周点 ” 的平均值,就会变成 1。在数值上,这是一种 ” 平滑化 ”。在图形上,就相当于产生 ” 含糊 ” 成果,” 两头点 ” 失去细节。高斯含糊的成果取决于含糊半径和权重调配。含糊半径直观而言就是计算四周多少个点,能够是 3*3,也能够 5*5,显然含糊半径越大,含糊成果越显著。而权重调配是指在计算平均值过程中,对每个点的权重。上述示例中咱们应用了简略均匀,显然不是很正当,因为图像都是间断的,越凑近的点关系越亲密,越远离的点关系越疏远。因而,加权均匀更正当,间隔越近的点权重越大,间隔越远的点权重越小。因而咱们应用正态分布曲线来做加权均匀。所以高斯函数就是在二维下的正态函数散布。具体的计算过程在此不赘述,大部分库也都帮咱们封装好了。

在实现过程中,咱们还要思考性能问题。如果对一个 32*32 的四方形区域采样,那么必须对每个点在一个纹理中采样 1024 次。但高斯方程有一个奇妙的个性是,能够把二维方程分解成两个更小的方程:一个形容程度权重,另一个形容垂直权重。咱们首先用程度权重在整个纹理上进行程度含糊,而后在经扭转的纹理上进行垂直含糊。利用这个个性,后果是一样的,然而能够节俭十分多的性能,因为咱们当初只需做 32+32 次采样,不再是 1024 了!这个过程便是两步高斯含糊。

// 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,能够调节四个参数看看会有什么不同的影响。

<img src=”https://img.alicdn.com/imgextra/i3/O1CN010mbAuP1bEoo1s5gsZ_!!6000000003434-2-tps-1866-1382.png” style=”zoom: 33%;” />

局部辉光

下面的成果看起来很不错对吧?然而当咱们理论应用起来,就遇到问题了。有时候咱们只心愿某个物体发光,然而在阈值化这步过程中采纳了全局阈值的形式,那么就会导致其余不心愿有辉光成果的物体呈现了辉光。

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

<img src=”https://img.alicdn.com/imgextra/i2/O1CN015QGmmv25MXxosgWdv_!!6000000007512-2-tps-1558-1232.png” style=”zoom:33%;” />

次要的思路是:

  • 创立辉光图层,将辉光物体增加在该图层上,用于辨别辉光物体和非辉光物体
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 之类的物体,都没有受到影响。

<img src=”https://img.alicdn.com/imgextra/i3/O1CN01zx2hYW1NMgzjbrknz_!!6000000001556-2-tps-670-466.png” style=”zoom: 50%;” />

demo

透明度生效

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

renderer.setClearAlpha(0);

但应用了 UnrealBloomPass 后,咱们发现整体背景背景的透明度设置却不失效了。

<img src=”https://img.alicdn.com/imgextra/i4/O1CN013kgx8y1wmLjcPOXpQ_!!6000000006350-2-tps-994-866.png” alt=”image-20211103175318581″ style=”zoom:50%;” />

剖析源码,能够晓得 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 src=”https://img.alicdn.com/imgextra/i2/O1CN01Pf0Mwf1T5BY5JytmS_!!6000000002330-2-tps-603-1011.png” alt=”img” style=”zoom: 33%;” />

菲涅尔反射

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

所以咱们能够在计算机中很简略地模仿这种景象,只有有模型上某顶点的法线和以后摄像机的眼帘,便能够通过很小的计算量计算出光强,从而失去这种轮廓边缘内发光的成果。看图谈话:

<img src=”https://pic1.zhimg.com/80/v2-ae113a0ea06b474d2dc49724518b2290_1440w.jpg” alt=”img” style=”zoom:50%;” />

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

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

其中 $F_0$ 是反射系数,用于管制反射强度,$v$ 是视角方向,$n$ 是法线方向。

另一个等式:$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);
}

渲染后果:

<img src=”https://img.alicdn.com/imgextra/i1/O1CN01k2lhtB1BsxVXqjND4_!!6000000000002-2-tps-942-698.png” style=”zoom:33%;” />

更粗疏的管制

仅有奢侈的成果必定是不够的,咱们还心愿对发光的成果有这更粗疏的管制。比方反光的范畴、方向、光强增速等。当然这些参数在公式中都曾经反映了:bias 值决定了色彩最亮值的地位,power 决定了光强变动速度及方向,scale 管制了发光的方向和范畴。

咱们能够在 demo 中调节参数来查看成果。

限度

菲涅尔反射是依据法线和眼帘的夹角来计算最终照明的光强的,所以对于 立方体 棱柱 这种平坦模型,因为模型的每个面的法线都统一,所以无奈达到想要的成果。如果肯定要应用这种成果,能够思考曲面细分平滑或者利用法线贴图来批改顶点法线。

参考

  • http://paradise.dtysky.moe/ef…
  • http://paradise.dtysky.moe/ef…
  • https://learnopengl-cn.github…
  • https://zhuanlan.zhihu.com/p/…
  • https://github.com/mbalex99/t…

作者:ES2049 / timeless
文章可随便转载,但请保留此原文链接。

十分欢送有激情的你退出 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com。

退出移动版