昨今のWebサイトでは3D表現を利用した3Dコンテンツをしばしば見かけるようになってきました。WebGPUに対応する環境が広がっていることや、Three.jsなどのライブラリが充実していることもあり、実案件での採用が現実的になってきているからです。
しかし、いざ3Dコンテンツを作ってみると、どこか味気のないものになってしまう事があります。そんな時はエフェクトの追加をオススメです。エフェクトを追加することで、コンテンツの見栄えが派手になったり、キャラやゲームの状態が伝わりやすくなります。今回エフェクトの例として、RPGのセーブポイントや回復ポイントで使用されそうなデモを制作してみました。
※このデモはThree.js r185(WebGPU)、GSAP 3、TypeScript 7によって書かれています。
本記事では、セーブポイント風なエフェクトの作成を通して、Three.jsを使ったエフェクト作成の3つの基本ノウハウを解説します。他のエフェクト作成にも応用できるので是非ご参考ください。
制作の流れ
冒頭のデモを形作る基本的なパーツは下図のとおりです。複雑そうに見えますが、分解してみるとシンプルなパーツで構成されている事が分かります。これらに3層の魔法陣と周期的な発光を加えています。

今回は汎用性の高い以下の3つに絞って順に解説します。その他のパーツはこの3つの方法の応用で作る事ができます。
- 光の柱
- 渦
- パーティクル
ステップ1. 光の柱の作成

円柱を作る
光の柱は縦に伸びる円柱のような形をしています。Three.jsではCylinderGeometryという円柱型のジオメトリが用意されているのでこちらを使用して表現します。
// 上下に蓋のない円柱ジオメトリ
const geometry = new THREE.CylinderGeometry(
3, // 上面の半径
3, // 下面の半径
10, // 高さ
64, // 円周方向の分割数
1, // 高さ方向の分割数
true, // 上下の蓋を作らない設定
);
この時、第6引数の両端を開くかどうかにtrueを指定するように注意ください。蓋のある円柱になってしまうのを防ぐためです。
柱を光らせる
円柱に光っているかのような質感をもたせます。
発光体を表現する際は、光源に関係なく発色できるMeshBasicMaterialが最適です。
// ライトの影響を受けない光柱用マテリアル
const material = new THREE.MeshBasicMaterial({
map: texture, // 白黒の光模様
color: 0x0070e0, // テクスチャーの色づけ
transparent: true,
blending: THREE.AdditiveBlending, // 重なりを明るくする加算合成
side: THREE.DoubleSide, // 筒の内側も描画
depthWrite: false, // 奥側の半透明オブジェクトを隠さない設定
opacity: 0.5,
});
設定しているパラメーターを順に解説します。
map
mapパラメーターは使用するテクスチャーを指定できます。テクスチャーはモノクロで作成しておくとcolorパラメーターから白色部分を指定した色に変更できるため、汎用性が高く扱いやすいのでオススメです。
blending
blendingパラメーターではブレンドモードを指定できます。NoBlending(無し)、NormalBlending(半透明合成)、AdditiveBlending(加算合成)、SubtractiveBlending(減算合成)、MultiplyBlending(乗算合成)に加え、合成方法を細かく設定するCustomBlendingがあります。
今回は重なった部分を白く発光させたいためAdditiveBlendingを指定します。
side
sideパラメーターは描画面を指定できます。FrontSide(表のみ)、BackSide(裏のみ)、DoubleSide(表裏両方)の3つから選択できますが、今回は筒の中も表示させたいためDoubleSideを指定します。
depthWrite
depthWriteパラメーターは、描画した面の深度値を深度バッファーへ書き込むかどうかを指定します。今回は奥にある光も重ねて描画できるようにfalseを指定します。
depthWrite: falseにすると深度バッファーを更新しない(深度テストはする)ので、深度的には何も書かれてなかったことになり、それより奥のオブジェクトを後から描画できる(=後ろが透けて見える)ことになります。
基本的に「不透明オブジェクトは深度書き込みアリ」、「半透明オブジェクトは深度書き込みナシ」がセオリーです。
3D的に「半透明」というのは、「完全不透明でない箇所が一部でもある」ことを指します。アルファ50%の透けているガラスのようなものだけでなく、パーティクルでよくある板ポリに●を書いて丸の中はアルファ100%、外は0%というようなものも半透明扱いとする必要があります。
ジオメトリとマテリアルが完成したので、この2つを使用したメッシュを作成すれば光の柱の完成です。
// 形状と見た目を組み合わせた3Dオブジェクト
const mesh = new THREE.Mesh(geometry, material);
同じジオメトリから内側と外側の2つのメッシュを作り、わずかに大きさを変えて重ねています。それぞれのテクスチャーを逆方向へ流すことで、1枚の円柱よりも奥行きのある光にしています。
// 同じジオメトリを共有する内外2層のメッシュ
const outer = new THREE.Mesh(geometry, this._outerMaterial);
const inner = new THREE.Mesh(geometry, this._innerMaterial);
// 内側の円柱だけをX・Z方向に縮小
inner.scale.set(0.95, 1, 0.95);
// 2層をPillarオブジェクトの子要素として追加
this.add(outer, inner);
// 経過時間を基準に逆方向へ流れる内外のテクスチャー
this._outerTexture.offset.x += delta * 0.2;
this._innerTexture.offset.x -= delta * 0.3;
ステップ2. 渦の作成

渦をつくる
渦は地面にドーナツ型に渦を発生させたいので、TorusGeometryというドーナツ型のジオメトリを使用します。本来は立体のドーナツ型を想定したジオメトリですが、第3引数の分割数の値を2にすることで平面のドーナツ型を作成できます。
// 平面状のドーナツ型ジオメトリ
const geometry = new THREE.TorusGeometry(
4, // ドーナツ全体の半径
1, // 管の半径
2, // 平面化するための最小分割数
100, // 円周方向の分割数
);
マテリアルの基本設定は光の柱とほぼ同じです。渦に合わせてテクスチャーや色、不透明度を調整します。
// ライトの影響を受けない渦用マテリアル
const material = new THREE.MeshBasicMaterial({
color: 0x0080ff, // テクスチャーの色づけ
map: texture, // 渦模様
transparent: true,
blending: THREE.AdditiveBlending, // 重なりを明るくする加算合成
depthWrite: false, // 奥側の半透明オブジェクトを隠さない設定
opacity: 0.1,
});
// ジオメトリとマテリアルを組み合わせた渦のメッシュ
const torus = new THREE.Mesh(geometry, material);
地面に設置する
TorusGeometryはデフォルトで縦に立っているので、90度回転させて地面と平行にします。描画面はデフォルトのFrontSideなので、表面が見えるように回転させる向きに注意してください。
// 縦向きのトーラスを地面と平行にする回転
torus.rotation.x = Math.PI / 2;
地面と平行になりましたが、このままでは地面とエフェクトが同じ位置に重なり、下図のようなチラつきが発生します。これはZファイティングと呼ばれる現象です。GPUが深度バッファーの値から前後関係を決める際、ほぼ同じ深度にある面のどちらを手前に描くか判定できず、表示される面が細かく入れ替わることで起こります。

この問題はメッシュを少しだけ浮かせる事で回避できます。
// 地面とのZファイティングを避けるわずかな位置調整
torus.position.y = 0.02;
回転させる
渦を巻いているアニメーションをつけます。フレーム間の経過時間deltaを使ってオブジェクト全体を回転させ、テクスチャーの表示位置も流します。経過時間を基準にすると、フレームレートが異なる環境でも速度を一定に保てます。
// 発光に連動する回転速度とテクスチャー速度の更新
update(delta: number, energy: number) {
// 発光時に加速する速度
const speed = 0.1 + energy * 0.4;
// 渦全体の回転
this.rotation.y -= delta * speed;
// 渦模様のスクロール
this._texture.offset.x -= delta * speed;
}
energyはエフェクト全体で共有する発光の強さです。通常時は0、強く発光するときは0.8まで値を上げます。渦はこの値が大きくなるほど回転が速くなり、演出にメリハリがつきます。
ステップ3. パーティクルの作成

粒をつくる
パーティクルにはSpriteというオブジェクトを使用します。Spriteは必ずカメラに正面を向くという特徴のある平面オブジェクトで、パーティクルを表現する場合に、テクスチャーの見え方が均一になるため非常に有効です。このような手法を一般的にビルボードと呼びます。Spriteを生成する場合のマテリアルはSpriteMaterialを使用します。
// Viteによる画像URLの取得
import imageParticle from "../img/particle.png";
// スプライト用テクスチャーの読み込み
const texture = new THREE.TextureLoader().load(imageParticle);
// 色画像として扱うためのsRGB指定
texture.colorSpace = THREE.SRGBColorSpace;
// 粒子で共有するマテリアルのひな形
const material = new THREE.SpriteMaterial({
map: texture, // 粒子の形状
transparent: true,
blending: THREE.AdditiveBlending, // 重なりを明るくする加算合成
depthWrite: false, // 奥側の粒子を隠さない設定
});
// テクスチャーを共有しつつ色と不透明度を個別化するマテリアル複製
const sprite = new THREE.Sprite(material.clone());
これで一粒分のパーティクルを作成できました。丸型と十字型の2種類のテクスチャーを用意し、マテリアルを粒ごとに複製して色や透明度を個別に変えています。
湧き出させる
パーティクルが下から湧き出るような演出を作っていきます。下から上へと上がる動きと、フェードイン・フェードアウトを組み合わせて表現します。
GSAPは、JavaScriptでアニメーションを作成・制御するためのライブラリです。GSAPには、複数のアニメーションを時間軸に並べ、実行する順序や開始タイミングをまとめて管理できる「タイムライン」機能があります。ここではタイムラインを使い、パーティクルが上昇する動きと、透明度を変えるフェードイン・フェードアウトを連動させます。
// 粒子ごとに異なる上昇時間
const duration = THREE.MathUtils.randFloat(6, 10);
// 周回ごとに変わる到達高度
const getHeight = () => THREE.MathUtils.randFloat(5, 8);
gsap
// 関数ベースの値を周回ごとに再評価する無限ループ
.timeline({ repeat: -1, repeatRefresh: true })
// 地面付近から上空への移動
.fromTo(
sprite.position,
{ y: 0.5 },
{ y: getHeight, duration, ease: "expo.in" },
0,
)
// 上昇開始時の透明化
.set(sprite.material, { opacity: 0 }, 0)
// 上昇に合わせたフェードイン
.to(
sprite.material,
{ opacity: 1, duration: duration - 1, ease: "expo.in" },
0,
)
// 上空で消えるフェードアウト
.to(
sprite.material,
{ opacity: 0, duration: 1, ease: "power4.in" },
duration - 1,
)
// 粒子ごとに異なる再生開始位置
.progress(Math.random());
真上に上がって消えていくアニメーションができました。progress()の初期値をランダムにすると、複数のパーティクルが時間差で再生され、湧き出るような演出になります。さらに横方向の揺らぎや細かな明滅を入れると、より自然な見栄えになります。
3つの要素を組み合わせて仕上げる
ここまでに紹介した3つの要素を組み合わせるだけでもエフェクトは作れますが、さらに魔法陣、発動時のパーティクル、ブルーム効果を加えています。
魔法陣は、SVGテクスチャーを貼ったPlaneGeometryを3枚重ねて作っています。各レイヤーの大きさと回転方向を変え、光の柱や渦と同じようにAdditiveBlendingを指定するのがポイントです。
表示直後には、光の柱を地面の下から引き上げ、魔法陣と地面のエフェクトを展開する初回モーションを再生します。その後は個別の動きに加えて、エフェクト全体の発光の強さを表すenergyと、粒の輝きを表すsparkleをGSAPで変化させ、回転速度、色、透明度、ライト、パーティクルを連動させています。次のタイムラインでは、光が強まる途中で発動用のパーティクルを放出し、その後ゆっくりと元の状態へ戻しています。
// 複数のエフェクトを連動させる共有値
const motion = { energy: 0, sparkle: 0.2 };
// 発光と休止を繰り返すタイムライン
const pulse = gsap.timeline({ delay: 0.2, repeat: -1, repeatDelay: 1 });
pulse
// 発光の蓄積
.to(motion, {
energy: 0.8,
sparkle: 1,
duration: 1.2,
ease: "power3.inOut",
})
// 最大発光の直前に発動粒子を放出
.call(() => particleEmitter.emitWave(), [], "-=0.5")
// 通常状態へ戻る余韻
.to(motion, {
energy: 0,
sparkle: 0.2,
duration: 3,
ease: "power1.out",
});
最後に、WebGPU版のポストプロセスでブルーム効果をかけます。ブルーム効果とは、明るい部分をぼかして元の画面に重ね、光が周囲へにじむように見せる処理です。strengthプロパティーは発光の強さ、radiusプロパティーはにじみの広がりを表します。energyの値が大きくなるほど両方の値を上げることで、セーブポイントの明滅とブルーム効果の変化を揃えています。
// シーンを描画するパス
const scenePass = pass(scene, camera);
// 後続処理へ渡すカラー情報
const sceneColor = scenePass.getTextureNode();
// 明部を抽出してぼかすブルーム
const bloomPass = bloom(sceneColor, 1, 0.5, 0.3);
// WebGPU用のポストプロセス
const postProcessing = new THREE.RenderPipeline(renderer);
// 元の描画とブルームの合成
postProcessing.outputNode = sceneColor.add(bloomPass);
// アニメーションループ内でのブルーム更新
bloomPass.strength.value = 1 + energy * 0.3;
bloomPass.radius.value = 0.5 + energy * 0.1;
まとめ
専用のエフェクト制作ツールを使用する事なく、Three.jsとGSAPを使ったコードでセーブポイント風なエフェクトを作成できました。今回紹介した基本ノウハウは、セーブポイントだけでなく、波動や爆発などのさまざまなエフェクトを作成する際に応用できます。ぜひ、みなさんも色々なエフェクト作成に挑戦してみてください。
※この記事が公開されたのは10年前ですが、今月9月に内容をメンテナンスしています。


