WebGL & Three.js

Three.jsでBloomをかける|EffectComposerとLayersによる選択的な発光

RenderPass・UnrealBloomPass・OutputPassでThree.jsにBloomを追加。Layersで選んだMeshだけを発光源にし、元画像の二重加算を避ける実装と、resize・DPR・白飛びの切り分けを解説します。

この記事の目次
  1. まず3つのPassを順番に追加する
  2. thresholdで発光源を絞り、strengthで光の量を変える
  3. 選択的Bloomでは、元の物体を二重に加えない
  4. basic.jsの描画経路を選択用へ置き換える
  5. 白飛びとサイズのずれを、同じ順序で切り分ける

Three.jsで物体を明るくしても、その周りに光がにじむとは限りません。Bloomを追加するには、描いた画面へ後処理をかけます。基本の順序はRenderPass → UnrealBloomPass → OutputPass。描画の入口をrenderer.render()からcomposer.render()へ切り替えます。

ただし、通常のBloomは「光らせたい物体の名前」を見ていません。しきい値を超える白い物や背景も発光源になります。まず画面全体のBloomを動かし、その後で、Layersを目印に選んだMeshだけを処理する構成へ進みます。以下はThree.js 0.186.0のWebGLRendererを使う静止画の例です。

まず3つのPassを順番に追加する

Pass この例での仕事
RenderPass SceneをCameraから描き、後処理用の画像にする
UnrealBloomPass 明るい領域から、にじむ光を作って加える
OutputPass tone mappingと表示用の色空間変換を行う

EffectComposerは追加した順にPassを実行します。この組み合わせではOutputPassを最後に置き、途中の合成を済ませてから表示用に変換します。既存のアニメーション処理がある場合も、最後の描画呼び出しをcomposerへ替えます。両方を続けて呼ぶと、画面を上書きする原因になります。

空の作業フォルダで次を実行します。下のHTMLはnpmで入れた同じ版の本体とaddonsを読み込みます。CDNを使う構成でも、この2つの版はそろえてください。

npm install three@0.186.0
python3 -m http.server 5194 --bind 127.0.0.1

index.htmlを作り、ブラウザでhttp://127.0.0.1:5194/を開きます。ファイルを直接ダブルクリックするのではなく、HTTP経由で読み込みます。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Three.js Bloom</title>
  <link rel="icon" href="data:,">
  <style>
    body { margin: 0; }
    #stage { width: 100%; height: 360px; }
    canvas { display: block; width: 100%; height: 100%; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "./node_modules/three/build/three.module.js",
      "three/addons/": "./node_modules/three/examples/jsm/"
    }
  }
  </script>
</head>
<body>
  <div id="stage"></div>
  <script type="module" src="./basic.js"></script>
</body>
</html>

同じフォルダのbasic.jsです。比較用に、左の球をオレンジ、右を白の明るい値にしています。Lightを置かなくても比較できるMeshBasicMaterialを使い、色の値は説明用に固定しました。

import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const stage = document.querySelector('#stage');
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
stage.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x07101a);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 9;
const geometry = new THREE.SphereGeometry(0.6, 40, 24);
const left = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
  color: new THREE.Color().setRGB(4, 0.35, 0.05),
}));
const right = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
  color: new THREE.Color().setRGB(2, 2, 2),
}));
left.position.x = -1.25;
right.position.x = 1.25;
left.layers.enable(1);
scene.add(left, right);
const composer = new EffectComposer(renderer);
const bloom = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.8, 0.4, 1);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(bloom);
composer.addPass(new OutputPass());

function resize() {
  const width = Math.max(1, stage.clientWidth);
  const height = Math.max(1, stage.clientHeight);
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setPixelRatio(dpr);
  renderer.setSize(width, height, false);
  composer.setPixelRatio(dpr);
  composer.setSize(width, height);
  composer.render();
}
const observer = new ResizeObserver(resize);
observer.observe(stage);
window.addEventListener('resize', resize);
resize();
// 動かす場面では更新処理の後にcomposer.render()を呼ぶ。

この例は動かない場面なので、初回とサイズ変更時に描画します。物体を動かす場合は、その更新後にcomposer.render()を呼びます。left.layers.enable(1)は後半で使う目印で、この通常Bloomにはまだ選択効果がありません。

スポンサーリンク

thresholdで発光源を絞り、strengthで光の量を変える

UnrealBloomPass(resolution, strength, radius, threshold)の末尾3項目を混同しないようにします。数値は出発点の例で、写真や実際のモデルにそのまま当てはまる最適値ではありません。

項目 例 動かしたときに見ること
threshold 1 Bloomを作る明るさの境目。上げると暗い領域は対象から外れやすい
strength 0.8 加える光の量。物体が真っ白になるなら、まず0と比較する
radius 0.4 ぼかしの広がり方。0〜1で調整する
toneMappingExposure 1 表示時の露出。発光源の選択とは別に、全体の見え方を変える

しきい値が見るのは、表示用の変換を終える前の画像の明るさです。「画面で白く見えるものだけ」とは限りません。明るい物体を残したまま発光源から外したいなら、しきい値を上げ続けるより、対象の選択を分けます。

Materialのemissiveを使う場合も、それ自体がBloomのにじみを作るわけではありません。色や発光成分の指定はMaterialの色・emissiveの設定で確認できます。ここでは、その結果にUnrealBloomPassを適用します。

選択的Bloomでは、元の物体を二重に加えない

選んだ物体だけを発光源にするには、「選択した物体の画像」と「Scene全体の画像」を分けます。ただし、Bloom後の選択画像には、にじんだ光だけでなく物体本体も残っています。それを全体画像へそのまま足すと、本体まで二重に加算されます。

この実装では、Bloom前の選択画像をSavePassで退避します。合成するのは全体画像 +(選択画像のBloom後 − Bloom前)です。少し長くなりますが、strengthを0にしたときに元画像へ戻る関係を保てます。

描画経路 順序 表示への出力
発光用 RenderPass → SavePass → UnrealBloomPass 画面には出さず、線形の画像として保持
全体用 RenderPass → 差分を足すShaderPass → OutputPass 最後に表示用へ変換する

LayersはBloomの自動判定機能ではなく、ここでは選択を記録する目印です。Cameraのlayerは変更せず、発光用の描画時だけ、対象外のMeshを黒いMaterialへ一時交換します。物体を非表示にしないため、手前の不透明な物体による遮蔽を残せます。この考え方はThree.js公式の選択的Bloom例にも使われています。

次をbloom.jsとして保存します。この黒置換は、不透明な静的Meshを想定したものです。透明なガラス、alphaTestで切り抜いた葉、特殊な頂点変形、Spriteや線を含むSceneでは、その描き方を保つ専用の置換処理が必要になります。

import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { SavePass } from 'three/addons/postprocessing/SavePass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';

export function createSelectiveBloom(renderer, scene, camera) {
  const selectedLayer = new THREE.Layers();
  selectedLayer.set(1);
  const black = new THREE.MeshBasicMaterial({ color: 0x000000 });
  const bloomComposer = new EffectComposer(renderer);
  bloomComposer.renderToScreen = false;
  const beforeBloom = new SavePass();
  const bloom = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.8, 0.4, 1);
  bloomComposer.addPass(new RenderPass(scene, camera));
  bloomComposer.addPass(beforeBloom);
  bloomComposer.addPass(bloom);

  const mix = new ShaderPass({
    uniforms: {
      tDiffuse: { value: null },
      bloomed: { value: null },
      beforeBloom: { value: null },
    },
    vertexShader: `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }`,
    fragmentShader: `
      uniform sampler2D tDiffuse;
      uniform sampler2D bloomed;
      uniform sampler2D beforeBloom;
      varying vec2 vUv;
      void main() {
        vec4 base = texture2D(tDiffuse, vUv);
        vec3 glow = max(texture2D(bloomed, vUv).rgb
          - texture2D(beforeBloom, vUv).rgb, vec3(0.0));
        gl_FragColor = vec4(base.rgb + glow, base.a);
      }`,
  });
  mix.uniforms.beforeBloom.value = beforeBloom.renderTarget.texture;
  const finalComposer = new EffectComposer(renderer);
  finalComposer.addPass(new RenderPass(scene, camera));
  finalComposer.addPass(mix);
  finalComposer.addPass(new OutputPass());

  function render() {
    const saved = new Map();
    const background = scene.background;
    const fog = scene.fog;
    try {
      scene.background = new THREE.Color(0x000000);
      scene.fog = null;
      scene.traverse((object) => {
        if (object.isMesh && !selectedLayer.test(object.layers)) {
          saved.set(object, object.material);
          object.material = black;
        }
      });
      bloomComposer.render();
    } finally {
      for (const [object, material] of saved) object.material = material;
      scene.background = background;
      scene.fog = fog;
    }
    mix.uniforms.bloomed.value = bloomComposer.readBuffer.texture;
    finalComposer.render();
  }

  function resize(width, height, dpr) {
    for (const composer of [bloomComposer, finalComposer]) {
      composer.setPixelRatio(dpr);
      composer.setSize(width, height);
    }
  }

  function dispose() {
    for (const composer of [bloomComposer, finalComposer]) {
      for (const pass of composer.passes) pass.dispose();
      composer.dispose();
    }
    black.dispose();
  }

  return { bloom, render, resize, dispose, bloomComposer, finalComposer, beforeBloom };
}

ShaderPassへ渡すuniformsは作成時に複製されるため、RenderTargetのtexture参照は作成後に代入しています。bloomedも毎回、描画が終わったreadBuffer.textureを参照します。内部バッファの名前を固定して決めつけないためです。

try / finallyでMaterial・背景・fogを復元するのは、途中で描画に失敗してもSceneを黒い状態のまま残さないためです。背景の画像やfogまで発光源にしないよう、発光用経路では外しています。この例ではfogを使っておらず、霧による発光の減衰まで再現する構成ではありません。

basic.jsの描画経路を選択用へ置き換える

basic.jsの先頭へ次のimportを追加します。

import { createSelectiveBloom } from './bloom.js';

const composer = new EffectComposer(renderer);から3つのaddPassまでのブロックを、次へ置き換えます。Sceneや球を作る前半はそのまま使います。

const selective = createSelectiveBloom(renderer, scene, camera);

resize()内では、rendererのサイズ設定に続くcomposerの3行を次へ替えます。以後、Sceneを更新して描画する際もselective.render()を呼びます。

selective.resize(width, height, dpr);
selective.render();

最初の例では左の球にlayers.enable(1)を付けてあるため、左だけが発光源になります。右へ切り替える操作は次のとおりです。

left.layers.disable(1);
right.layers.enable(1);
selective.render();

enable(1)は既存のlayer 0を残します。set(1)でlayer 0を外すと、初期状態のCameraから物体が見えなくなります。また、Groupへ印を付けるだけでは子Meshのlayerは変わりません。モデル全体を選ぶ場合は対象Groupをたどり、子Meshごとにenable(1)を呼びます。

選択していない右の球の近くまで、左のオレンジの光が広がることはあります。選択が制限するのはどこからBloomを作るかで、できた光が重なる画面上の場所ではありません。

白飛びとサイズのずれを、同じ順序で切り分ける

最初にBloomを外した画像を保存し、同じCamera・色・露出で比較します。下の順序なら、複数の設定を同時に変えて原因を見失うことを減らせます。

症状 確認すること
球が真っ白になる strengthを0にして本体の色を見る。元から白いなら色・tone mapping・露出を確認
白い背景までにじむ thresholdと、発光用経路の背景を確認。選択印だけで自動除外されると思わない
strengthが0でも本体が明るくなる 選択画像の本体を二重加算していないか。SavePassのtexture参照と差分合成を確認
選択した物体が消える layers.setで通常描画用のlayerまで外していないか
縮小後にぼける・位置が合わない Camera・renderer・両composerに同じ論理寸法を渡しているか
端末の解像度が高いと重い DPRの上限、対象サイズ、選択用の追加描画を確認。実機で必要な品質まで下げる

composer.setSize(width, height)へ渡すのはCSS上の幅・高さです。DPRはsetPixelRatio()へ渡し、幅へもう一度掛けないようにします。SavePassもcomposerのサイズ変更に追従します。ここではDPRを最大2にしていますが、端末に合う性能を保証する値ではありません。

掲載実装のブラウザ確認では、strength 0、選択なし、選択した球を不透明な箱で完全に隠した状態を、Bloomなし画像と比較しました。各色チャンネルの差は確認した条件で0〜1(8bit値)でした。選択の切り替え、サイズ変更、Materialの復元も確認しています。透明物体や実機GPUの速度まで確認した結果ではありません。

画面を破棄するときは、登録したResizeObserverとイベントを外し、選択用のPass・RenderTargetを解放します。SceneのgeometryやMaterialも、その画面だけが所有しているものを別途解放してください。

observer.disconnect();
window.removeEventListener('resize', resize);
selective.dispose();
renderer.dispose();

まず通常Bloomでにじみを確認し、発光源を選ぶ必要が生まれたら選択用へ進むと、調整する場所が分かります。色を変える処理と、描画後の光を足す処理を分けておくことが、白飛びを追いやすくする出発点です。

スポンサーリンク