Three.jsでWebGLレンダラーとCSS3Dレンダラーを共存させる方法

シリーズ最終回は、個人的にThree.jsで一番面白いと思っているテクニックです。3D空間の中に「本物のHTML」を置く。画像やテクスチャとしてではなく、クリックできるボタン、スクロールできるiframe、再生できるYouTube埋め込みが、3Dシーンの中に実寸で存在する状態を作ります。

3Dポートフォリオサイトで「部屋の中のPCモニターに実際のWebページが表示されていて操作できる」やつ、あれの正体がこれです。

デモの白いパネルは実物のiframeです。ドラッグで視点を回すと、緑のトーラスがパネルの手前と奥を正しく行き来するのを確認してください。ボタンもちゃんとクリックできます。

デモを別タブで開く

CSS3DRendererとは

Three.js公式アドオンの CSS3DRenderer は、WebGLの代わりにCSSの3D transformでDOM要素を配置するレンダラーです。カメラの位置に合わせて matrix3d() を計算してくれるので、DOM要素がWebGLのオブジェクトと同じ遠近感で表示されます。

import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';

const element = document.createElement('div');
element.textContent = 'これはDOM要素です';

const cssObject = new CSS3DObject(element);
cssObject.position.set(0, 100, 0);
scene.add(cssObject); // 普通のMeshと同じようにシーンに追加できる

DOM要素なので、フォント描画は綺麗だし、リンクもフォームも動画もそのまま動きます。ただしあくまでCSSで変形されたDOMなので、WebGLのライトや影の影響は受けません。

2つのレンダラーを重ねる基本構成

WebGLのオブジェクトとCSS3DのDOMを同じ画面に出すには、レンダラーを2つ作って重ねます。HTMLとCSSはこうです。

<div id="css"></div>
<div id="webgl"></div>

<style>
  #css, #webgl {
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
  }
  #webgl {
    pointer-events: none; /* 上に重ねたWebGL側はクリックを素通しする */
  }
</style>

JS側では、同じシーンとカメラを両方のレンダラーで描画します。

const cssRenderer = new CSS3DRenderer();
document.querySelector('#css').appendChild(cssRenderer.domElement);

const webglRenderer = new THREE.WebGLRenderer({
  alpha: true,      // 背景を透過してCSS側を見せる
  antialias: true,
});
document.querySelector('#webgl').appendChild(webglRenderer.domElement);

// カメラ操作はCSS側のDOMに紐付ける
const controls = new OrbitControls(camera, cssRenderer.domElement);

function animate() {
  requestAnimationFrame(animate);
  webglRenderer.render(scene, camera);
  cssRenderer.render(scene, camera);
}

構成のポイントは3つあります。

  • WebGL側を上に重ねて alpha: true で透過させる。下のCSS層が透けて見える
  • 上のWebGL層は pointer-events: none にして、クリックやドラッグを下のDOMに通す
  • そのため OrbitControlsは cssRenderer.domElement に紐付ける(WebGL側はイベントを受け取れないので)

この3点のどれかを外すと「HTMLがクリックできない」「視点操作が効かない」のどちらかが起きます。

リサイズ処理も両方に必要です。

function resize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  webglRenderer.setSize(window.innerWidth, window.innerHeight);
  cssRenderer.setSize(window.innerWidth, window.innerHeight);
}

最大の問題: 前後関係(オクルージョン)

ここまでの構成には大きな穴があります。CSS層とWebGL層は完全に別のレイヤーなので、WebGLのオブジェクトがHTMLの奥に回り込んでも、常にどちらかが上に描かれてしまいます。3D空間としては嘘の見た目になる。

これを解決するのが、CSS3DObjectと同じサイズ・同じ位置に「見えないけど深度だけ書き込むプレーン」をWebGL側に置くというトリックです。

function embed(type, width, height) {
  const obj = new THREE.Object3D();

  // CSS側: 本物のDOM要素
  const element = document.createElement(type);
  element.style.width = width + 'px';
  element.style.height = height + 'px';
  const css3dObject = new CSS3DObject(element);
  obj.css3dObject = css3dObject;
  obj.add(css3dObject);

  // WebGL側: 見えないが深度バッファに書き込むプレーン
  const material = new THREE.MeshPhongMaterial({
    color: 0x000000,
    opacity: 0,
    blending: THREE.NoBlending, // ← これが肝
    side: THREE.DoubleSide,
  });
  const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry(width, height),
    material
  );
  obj.add(mesh);

  return obj;
}

肝は blending: THREE.NoBlending です。opacity: 0 だけだと「透明なので何も描かない」となりますが、NoBlendingを指定すると色は描かないのに深度バッファには書き込まれる状態になります。

その結果どうなるか。WebGLレンダラーから見ると、そこには「HTMLと同じサイズの板」が存在することになります。トーラスがその板の奥に回れば、板より奥の部分はWebGL側で描画されない=透過部分から下のCSS層(HTML)が見える。手前に来ればトーラスが板を覆う=HTMLの上に描かれる。2つの別レイヤーなのに、深度だけ共有しているような見た目が完成します。

デモでトーラスがパネルの前後を周回しているのは、このトリックが働いている証拠です。

使うときはこんな感じです。

const html = embed('iframe', 400, 300);
html.css3dObject.element.srcdoc = `
  <body>
    <h2>HTMLの実物です</h2>
    <button onclick="this.textContent='クリックできた!'">
      クリックしてみる
    </button>
  </body>`;
html.position.set(0, 160, 0);
scene.add(html);

Object3D でCSS側とWebGL側をまとめてあるので、positionrotation を動かせば両方が一緒に動きます。

ハマりどころ

スケール感の違い。 CSS3DObjectの大きさは「px」基準です。WebGL側を1ユニット=1mのスケールで作っていると、400pxのiframeは巨大になります。CSS3DObjectに scale.setScalar(0.01) を掛けるか、シーン全体をpx感覚のスケールで作るか、最初にどちらかに決めておかないと後で全部調整し直すことになります。

ズームの限界。 DOMはWebGLと違ってカメラが近づきすぎるとレンダリングが崩れたり、ブラウザによってチラつきが出たりします。OrbitControlsの minDistance / maxDistance で寄りすぎ・引きすぎを制限しておくのが安全です。

パフォーマンス。 CSS3DObjectを何十枚も置くと、ブラウザのコンポジット処理が重くなります。実際に操作が必要な画面だけ本物のHTMLにして、遠くの画面はテクスチャ(画像)で済ませる、という出し分けが現実的です。

まとめ

  • CSS3DRendererを使うと本物のHTML(iframe・ボタン・動画)を3D空間に置ける
  • WebGLと共存させるには、2つのレンダラーを重ねて同じシーン・カメラで描画する
  • クリックを通すため、WebGL層は pointer-events: none + OrbitControlsはCSS側に紐付け
  • 前後関係は「NoBlending+opacity 0の透明プレーン」で深度バッファを橋渡しする

これでシリーズは完結です。基本のシーン構築から、入力、モデル、設計、デプロイと同期、そしてHTMLとの合成まで、この7本で「Three.jsで動くものを作って公開する」一通りの流れを網羅したつもりです。どこかの誰かの制作の足しになれば嬉しいです。


Three.js解説シリーズ(全7回)

  1. 【Three.js入門】ビルドツール不要、CDNだけで始める基本の使い方
  2. レンダラー・ライト・カメラの使い方と設定まとめ
  3. キーボードとNipplejsでプレイヤーを動かす
  4. GLTFLoaderで3Dモデルをインポートする
  5. ファイル分割はDIパターンがおすすめ
  6. CloudflareへのデプロイとWebSocketでの位置同期
  7. WebGLレンダラーとCSS3Dレンダラーを共存させる(この記事)