如何在 ThreeJS 中實現輝光效果

ES2049發表於2022-02-28

全域性輝光

全域性輝光(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。

<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了!這個過程便是兩步高斯模糊。

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,可以調節四個引數看看會有什麼不同的影響。

<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 中調節引數來檢視效果。

限制

菲涅爾反射是根據法線和視線的夾角來計算最終照明的光強的,所以對於立方體稜柱這種平整模型,由於模型的每個面的法線都一致,所以無法達到想要的效果。如果一定要使用這種效果,可以考慮曲面細分平滑或者利用法線貼圖來修改頂點法線。

參考

作者:ES2049 / timeless
文章可隨意轉載,但請保留此原文連結。

非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章