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