こんにちは、以前は広告エンジニア、現在はデータプラットフォームエンジニアの中山です。この記事では趣味の Three.js アプリ開発を通じて得た気付き、例えば Three.js 初心者が陥りそうなトラブルやブラウザ互換問題、それらの解決方法についてご紹介させていただきます。なお、以前シナジーマーケティングでご一緒させて頂いたこともあり、TECHSCORE BLOG への記事掲載についてご快諾いただきました ^^ どうもありがとうございます。
最初に Three.js アプリをご紹介します。
新コンセプトのリバーシ

リバーシに Three.js 必要?と突っ込まれそうですが、シリンダ状にループする 3D 盤面で従来にはなかった戦略を楽しめます。加えて DMZ 概念の導入や NPC の選択肢にも幅があり(1 ~ 3)、ループ盤面の場合は回転戦術も選択できます。ルールベース(2025 年 6 月現在)の NPC 実装は一対一で戦う場合は物足りなさを感じるかもしれませんが、カオスな 4 人対決(NPC x3 + 人間)だと経験者でも苦戦すること請け合いです。よかったら電車の待ち時間に遊んでください。
- 2 人プレー(NPC x1 + 人間)通常盤面リバーシ
- 2 人プレー(NPC x1 + 人間)ループ盤面リバーシ
- 4 人プレー(NPC x3 + 人間)通常盤面リバーシ
- 4 人プレー(NPC x3 + 人間)ループ盤面リバーシ
多様なパズル

鉄板の Three.js 習作題材、ルービックキューブを発展させて多様なパズルを開発してみました。とはいえ Z3Cubing に比べればまだまだです。今後は物理的なガジェットでは実現できない方向性(変則的な回転 はその一例)を探求してゆきます。
棒人間

私は ポンチ絵を多用したパワポスライド を作ることがありますが、スライドに張り付ける著作権フリーな棒人間素材を探すのは少々面倒です。ならばいっそ自前で、と開発したのがこちらです。みなさまのスライドにも是非ご利用ください。
棒人間ギャラリー

棒人間で素材探しの課題は解決できましたが、ポージングすら面倒になり :-p 構造化したポーズデータの入出力とそれを使ったギャラリーを用意しました。イメージに近いものを探して少々整えるだけで目的の棒人間素材を入手できます。
次のステップとして、ポーズデータにラベルを付け、機械学習を利用して自然言語(例えば感情や姿勢を表す言葉)から適当なポーズを生成する棒人間を構想中です。
さて、ここからは Three.js アプリ開発を通じて得た気付きのご紹介です。
描画バッファはもぬけの殻

棒人間 や ゴム人間 や 手 では決定したポーズの画像をクリップボードにコピーする screenshot 機能を実装しています。この機能で WebGLRenderer.domElement(仕様)の toDataURL() を使っていますが、当初描画した画像を取得できずに悩んでいました。
例えばこのようなコードの場合
const w = 400; const h = 400; const renderer = new THREE.WebGLRenderer(); document.body.appendChild(renderer.domElement); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(w, h); const camera = new THREE.PerspectiveCamera(45, w / h); camera.position.set(0, 0, +1000); const scene = new THREE.Scene(); const geometry = new THREE.BoxGeometry(50, 50, 50); const material = new THREE.MeshNormalMaterial(); const box = new THREE.Mesh(geometry, material); scene.add(box); renderer.render(scene, camera); // (1) capture the canvas image right after WebGLRenderer.render() console.log(renderer.domElement.toDataURL("image/png")); setTimeout(() => { // (2) capture the canvas image in a timer event handler console.log(renderer.domElement.toDataURL("image/png")); }, 0); button.addEventListener("click", (in_ev) => { // (3) capture the canvas image in a click event handler console.log(renderer.domElement.toDataURL("image/png")); });
コードの (1) のタイミングでは toDataURL() で期待した出力が得られますが (2) や (3) のタイミングではうまくいきません。この理由は WebGLRenderer が、各フレームのレンダリング後に自動的に描画バッファを消去してしまうためでした。試しに WebGLRenderer.preserveDrawingBuffer(仕様)で描画バッファを保持する設定にしてみると
const renderer = new THREE.WebGLRenderer({ preserveDrawingBuffer: true });
非同期的に呼び出される (2) や (3) のタイミングでも期待した出力を得ることができます。ただし WebGL Specification によれば
While it is sometimes desirable to preserve the drawing buffer, it can cause significant performance loss on some platforms. Whenever possible this flag should remain false and other techniques used.
との non-normative があり、過去には WebKit で関連するバグも報告されていたため、描画バッファの設定はデフォルトの false を変更せず toDataURL() の直前で再度レンダリングすることにします。
setTimeout(() => { // (2) capture the canvas image in a timer event handler renderer.render(scene, camera); console.log(renderer.domElement.toDataURL("image/png")); }, 0); button.addEventListener("click", (in_ev) => { // (3) capture the canvas image in a click event handler renderer.render(scene, camera); console.log(renderer.domElement.toDataURL("image/png")); });
これで無事 screenshot 機能が実装できました。パワポスライドへの貼り付けをお試しください。
レイキャストの罠 3 選
Three.js アプリではタッチやマウスイベントが発生した座標と、オブジェクトとの交点を求めるために レイキャスト を使います。
Raycasting is used for mouse picking (working out what objects in the 3d space the mouse is over) amongst other things.
ここではレイキャスト関連の 3 つの罠 … あるいは失敗 … をご紹介します。
1. でしゃばる AxesHelper
棒人間 や ルービックキューブ では、touchstart や mousedown イベントが発生した座標からのレイキャストが …
Scene内のオブジェクトと交点を持つ場合- 交点を持つパーツをドラッグする
touchmoveやmousemoveでパーツを操作(例えばポーズの変更)
Scene内のオブジェクトと交点を持たない場合- その座標をドラッグする
touchmoveやmousemoveでオブジェクトを回転(実際にはオブジェクト自身の回転ではなく、オブジェクト方向を向き続けるPerspectiveCameraがtouchmoveやmousemoveイベントの反対方向に移動する)
… を共通の UX としています。しかしデバッグ目的で Scene に AxesHelper(軸を表す三色の線)を追加した際、まれに怪しい挙動になります。

この理由は AxesHelper 自身も Raycaster.intersectObject()(仕様)の対象となるためでした。それを考慮して交点をチェックするか、チェック手前で AxesHelper の影響を排除しておきましょう。
const children = scene.children.filter( (in_child) => !(in_child instanceof THREE.AxesHelper) ); const intersects = raycaster.intersectObjects(children);
2. 消える CircleGeometry
棒人間 のパーツ操作は
- パーツをドラッグしたタイミングで
Sceneに操作用のオブジェクトを追加- 対象パーツの height と同じ半径を持つ
SphereGeometry - その
SphereGeometryの中心を通り法線ベクトルがPerspectiveCameraを向いたCircleGeometry
- 対象パーツの height と同じ半径を持つ
touchmoveやmousemoveイベントが発生した座標からのレイキャストと操作用のオブジェクトの交点方向を、ドラッグしたパーツがlookAt()(仕様)する
のような仕組みになっています。デバッグ用に操作用のオブジェクトを着色し、棒人間の手を動かしている様子をご覧ください。

空間内のパーツの位置に応じて、オブジェクトの反対方向からレイキャストすることもあるのですが、その状況で怪しい挙動になります。調べたところ SphereGeometry は背面からのレイキャストと交点を持たないことがわかりました。常に PerspectiveCamera 側を向いている CircleGeometry だけに、まさに盲点でした ^^;
この場合、例えば
const geometry = new THREE.CircleGeometry(100, 32); const material = new THREE.MeshNormalMaterial({ side: THREE.DoubleSide }); const circle = new THREE.Mesh(geometry, material);
のように両面のマテリアルを使用することで対応できます。
3. 見た目と異なる SkinnedMesh
ゴム人間 や 手 では SkinnedMesh を使ってパーツを滑らかに曲げています。ここまではよいのですが、問題は曲げたパーツがレイキャストと交点を持たないことでした。それらしき情報は 仕様 に記載がありませんが … 何故だろう。
フォーラムや SkinnedMesh の 実装 を調べ、実際の頂点データは変更せずにボーンの影響を計算~描画していることは理解できました。そこでラフな代替頂点データとして SkinnedMesh.skeleton.bones を使った ExtrudeGeometry(仕様)を作り、それとレイキャストとの交点をドラッグすることでゴム人間の操作を実現できました。

AdSense で踏んだブラウザ互換問題
Three.js アプリの体裁が整ってきたところで、試しに AdSense を導入することにしました。ところが Three.js アプリと AdSense の共存で予想外のハードルに直面してしまいました。ここでは iframe に関連したブラウザ互換問題解消までの道のりをご紹介します。
Three.js アプリは初期化時とウインドウのリサイズ時、適切な座標処理とレンダリングのための設定変更 …
PerspectiveCamera.aspect(仕様)の変更PerspectiveCamera.updateProjectionMatrix()(仕様)の呼び出しWebGLRenderer.setSize()(仕様)の呼び出し
が必要になります。加えて AdSense コード を設置したサイトで、広告自動挿入時に他の要素のサイズが変更される可能性があるため、そのタイミングでも同様の処理が必要になります。例えばこれは要素の offsetHeight が変更されています。

ただし WebGLRenderer.setSize() の 実装 に WebGLRenderer.domElement の width や height への書き込みがあるため、ResizeObserver のコールバック内で呼び出すのは少々危うい感じもします(ちなみに Chromium の実装 では前回観察時からの要素サイズの変化を確認しているので、処理が無限ループに陥ることはないようです)
そこで iframe 内に WebGLRenderer.domElement を配置することで
- AdSense による広告自動挿入
- 上記に伴う
iframeのリサイズ - 上記に伴うイベントハンドラ処理
WebGLRenderer.domElementのリサイズ- 座標処理とレンダリングのための設定変更
のように対応することを考えました。
function createOuterWindow(in_document) { const iframe = in_document.createElement("iframe"); Object.assign(iframe.style, { width: "100%", height: "100%", border: "none", }); in_document.body.appendChild(iframe); return iframe.contentWindow; } const outerWin = createOuterWindow(document); const outerDoc = outerWin.document; // myCanvas : WebGLRenderer.domElement outerDoc.body.appendChild(myCanvas); outerWin.addEventListener("resize", (in_event) => { // maintain PerspectiveCamera.aspect etc console.log("resized"); });
ところが Chrome(137.0)では期待動作となるものの、Firefox(139.0)では WebGLRenderer.domElement が表示されません。そこで、処理タイミングを変えて試してみます。iframe の 仕様 によれば src や srcdoc 属性のない iframe はデフォルトの about:blank をロードするので
- If url matches about:blank and initialInsertion is true, then: Run the iframe load event steps given element.
このタイミングはどうでしょうか。
const outerWin = createOuterWindow(document); outerWin.addEventListener("load", () => { const outerDoc = outerWin.document; // myCanvas : WebGLRenderer.domElement outerDoc.body.appendChild(myCanvas); });
今度は Firefox では期待動作となるものの、Chrome ではイベントが実行されません。関連議論が stop triggering navigations to about:blank on iframe insertion にありますが、処理を次回イベントループまで遅延させることで両ブラウザともに期待動作に至ったため、いったんはこれで良しとしてコメントを残しておきます。
const outerWin = createOuterWindow(document); // asynchronous process for Firefox setTimeout(() => { const outerDoc = outerWin.document; // myCanvas : WebGLRenderer.domElement outerDoc.body.appendChild(myCanvas); }, 0);
これでようやく座標処理とレンダリングのための設定変更 … が広告自動挿入に追従できるようになりました。
ぼくのかんがえたさいきょうのアニメーション関数
AdSense の導入が一段落したところで、最後に全体的に UX をブラッシュアップしたいと思います。Three.js アプリでの WebGLRenderer の描画は全体的にアニメーション表現を採用していますが、どうせなら通常の HTML 要素の描画(例えばダイアログ表示)でも同様の UX を採用したいですよね。とはいえ CSS の @keyframes 定義などアニメーションに関する記述を分散させたくありません。JavaScript コードのみでシンプルに一元的に管理できないかと考えた末の実装がこちらです。
function autoTransition1(in_elem, in_shorthand, in_start, in_end) { return new Promise((in_resolve) => { let [prop, , , delay = "0s"] = in_shorthand.split(/\s+/); // convert from CSS to CSSOM prop = prop.replace(/-([a-z])/g, (in_match, in_letter) => in_letter.toUpperCase() ); delay = delay.includes("ms") ? parseFloat(delay) : parseFloat(delay) * 1000; in_elem.style["transition"] = in_shorthand; in_elem.style[prop] = in_start; // automatically start the transition in the next event loop setTimeout(() => (in_elem.style[prop] = in_end), delay); const callback = (in_ev) => { if (in_ev.propertyName === prop) { in_elem.removeEventListener("transitionend", callback); in_resolve(); } }; in_elem.addEventListener("transitionend", callback); }); } function autoTransition2(in_elem, in_shorthand, in_start, in_end) { (async () => await autoTransition1(in_elem, in_shorthand, in_start, in_end))(); } autoTransition2(element, "color 1.5s ease-out", "blue", "white");
CSS Transitions の shorthand と transition-property の開始値と終了値を指定することで要素に関するアニメーションを実行します。

おわりに
ここまで読んでいただきどうもありがとうございます。見出しにはしませんでしたが、他にも細かい失敗がいろいろとありました。例えば Three.js の多くのクラスには clone() メソッドが実装されていますが、クラスを継承した新しいクラスを実装した際、コンストラクタの I/F を変更していたことが理由で clone() したインスタンスの怪しい挙動に悩まされた、などは恥ずかしい限りです。
というわけで、私の Three.js アプリ開発を通じて得た気付き(失敗?)が皆さまにとって有益な情報になれば何よりです。
データ活用の課題解決はお任せください。
元シナジーマーケティング。現在は LINEヤフー株式会社所属