Three.js から WebXR Device API をひもとく

renderer.xr.enabled = true の下で何が起きているか

鹿児島.mk 7周年LT大大会

2026-08-07 タスク(くすたん) @qst_exe

Three.js から WebXR Device API をひもとく

自己紹介

タスク・くすたん(@qst_exe)
1991年 鹿屋市出身 / アプリ開発やってます

  • 鹿児島.mk コミュニティオーナー
  • ヘッジホッグ.exe合同会社 代表社員 CEO
  • 株式会社ユニマル ロックスター
  • 一般社団法人 鹿屋青年会議所 志を紡ぐ総務兼事務局 運営幹事
  • 鹿屋青年会議所 太鼓部 部員
  • 公益社団法人 日本青年会議所 九州地区 鹿児島ブロック協議会 地域グループ 地域デザイン委員会 運営幹事
  • 日本青年会議所 IT部会 会員
  • VR・メタバース体験会イベント実行委員会 委員長
タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

Three.js を XR 対応させる、たった3行

// ① XR モードを有効に
renderer.xr.enabled = true;

// ② 没入モードに入るボタンを置く
document.body.appendChild(VRButton.createButton(renderer));

// ③ 描画ループを回す
renderer.setAnimationLoop(() => renderer.render(scene, camera));

既存の Three.js のコードに、これを足すだけ


じゃあ、何が "enabled" になったの?

タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

WebXR Device API とは

  • VR と AR の両方を扱う標準群
    (旧 WebVR は VR 専用。AR は拡張現実モジュールが担当)
  • VR ヘッドセット / AR ヘッドセット・メガネ / スマホの AR を同じ API で扱う
  • 実験的機能。安全なコンテキスト(HTTPS)でのみ動作

やっていることの本質は

左右それぞれの目の位置を計算して描き、機器に渡す

タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

生API:起動

if (!(await navigator.xr.isSessionSupported("immersive-vr"))) return;

const session = await navigator.xr.requestSession("immersive-vr");

const gl = canvas.getContext("webgl", { xrCompatible: true });
session.updateRenderState({
  baseLayer: new XRWebGLLayer(session, gl)
});

const refSpace = await session.requestReferenceSpace("local-floor");
session.requestAnimationFrame(onXRFrame);
  • requestSession()ユーザー操作の中でしか通らない → だから VRButton
  • baseLayer を渡すと、描画先が canvas から XR 用フレームバッファに変わる
タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

生API:ループの登録先が変わる

// 通常の描画:モニタのリフレッシュに合わせて呼ばれる
window.requestAnimationFrame(render);

// 没入セッション中:XR機器のリフレッシュに合わせて呼ばれる
session.requestAnimationFrame(onXRFrame);

function onXRFrame(time, frame) {
  frame.session.requestAnimationFrame(onXRFrame);  // ← 次のフレームも session に登録
  // ...
}
  • ディスプレイの 60Hz ではなく、XR機器の表示周期で回る
  • setAnimationLoop() が必要なのは、これが理由
タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

生API:1フレームで2回描く

function onXRFrame(time, frame) {
  const pose = frame.getViewerPose(refSpace);
  if (!pose) return;                         // ← 見失う瞬間が実機にある

  for (const view of pose.views) {           // ← 通常2回(左目・右目)
    const vp = session.renderState.baseLayer.getViewport(view);
    gl.viewport(vp.x, vp.y, vp.width, vp.height);   // 描く場所を左右に切り替え
    // view.projectionMatrix / view.transform で描画
  }
}
  • pose = 今フレームの頭の位置と向き。ここから左右の目が決まる
  • ループを1周するたびに、描く場所が左半分→右半分に切り替わる
タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

1つの視点が、2つになる

アニメーションのフレームごとに、各目につき 1 回ずつシーンを 2 回レンダリングすることで、両眼視……をシミュレートできる

図・引用:MDN Web Docs(CC BY-SA 2.5)

タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

答え合わせ:何が enabled になったのか

生API Three.js
requestSession()(要ユーザー操作) VRButton.createButton()
updateRenderState({ baseLayer }) renderer.xr.enabled = true
session.requestAnimationFrame renderer.setAnimationLoop()
pose.views(通常2つ) renderer.xr.getCamera().cameras(通常2つ)
  • まず問いの答え:renderer.xr.enabled = true の正体は
    描画先(baseLayer)が XR 用に差し替わること
  • そして隠れていたもう一つ:
    1個のつもりのカメラが、XRでは2個(左右の目)になっている
タスク(くすたん) | @qst_exe
Three.js から WebXR Device API をひもとく

実装で踏んだハマりどころ

  1. HTTPS必須
    ローカル検証と本番で挙動が割れる

  2. requestSession() はユーザー操作起点でないと通らない
    非同期処理を挟むと失敗することがある

  3. posenull を返す瞬間がある
    トラッキング喪失。会場の照明や床の材質で普通に起きる

  4. 実機でないと検証できない
    ブラウザだけでは分からない。結局かぶって確かめるしかない


隠れているものが分かると、壊れたときに原因を追える

タスク(くすたん) | @qst_exe

お知らせ

【10秒】タイトルを読むだけ。巻く

【15秒】 - 上のリストは読み上げない。「こういうのをやってます」で一息に流す - 口に出すのは VR・メタバース体験会 委員長 の1行だけ → 後半の「実機で踏んだハマりどころ」の説得力がここで決まる

【25秒】 - 「今みなさんが書いてる Three.js に足すだけ」と言う - 1行目を指して問いを立てる。これが発表全体の背骨 - 言い回し:「"enabled" って言いますけど、じゃあ何が有効になったのか——って話なんです」と自問して本題へ - 「答えはこの後にあります」と宣言して次へ

【15秒】前提の確認。主張ではないので巻く - 対応端末は「VRもARも同じAPIです」と一言。Quest / Vision Pro を例に出してもいい - HTTPS必須だけ口に出す(⑩ハマりどころの伏線)

【45秒】強調は2点だけ 1. requestSession はユーザー操作起点必須 → VRButton の存在理由 (非同期を挟むと「ユーザー操作の文脈」が切れて失敗することも。⑩の伏線) 2. baseLayer で描画先が差し替わる → これまでは canvas のフレームバッファに描いていた → XR ではヘッドセットに送るための専用フレームバッファに描く → そのバッファが左右に分かれている(⑧の図につながる) - 参照空間(local-floor = 床が原点)は「座標の基準を決める」の一言で流す - 種類の説明には踏み込まない(時間が溶ける)

【35秒】1つ目の山場。「登録先が変わる」ことだけを言う - 「今まで rAF は window に登録してました。XRでは session に登録します」 - なぜ? ヘッドセットは90Hzや120Hzで動く。モニタの60Hzでは合わない - Three.js で setAnimationLoop に書き換えさせられる理由がこれ - ここで「⑨の答え合わせ」の3行目を先に回収している 【正確さのメモ】 - 呼び出す主体はどちらもブラウザ(UA)。session は登録の窓口 MDN: "the next time the browser is ready to paint the session's virtual environment" - 「XR機器のネイティブリフレッシュレートで処理される」は MDN / XRSession.visibilityState の visible の説明に明記あり - 【質疑対策】「没入中に window.rAF は止まる?」と聞かれたら → デバイス次第。MDN は「動作する保証はない」としか書いていない。 有線接続のPC+ヘッドセットでは動き続けることがある(MDNのサンプルにも 二重描画を防ぐガードの例がある)。「止まる」と断定しないこと

【45秒】2つ目の山場。ここが発表の核心 - getViewerPose = 「今どこを向いてる?」を毎フレーム聞いている - pose が null を返すことがある(トラッキング喪失)→ ⑩の伏線、ここで軽く触れる - for が回るたび viewport が左半分・右半分に切り替わる → 次のスライドの図に直結する - 「1個のつもりのカメラが2個」のオチはまだ言わない。⑨まで取っておく

【20秒】ここで絵にして、コードで置いていかれた人を回収する一枚 - 「さっきの for ループ、あれが左右の目を1回ずつ描いてました」 - 図:1枚の描画領域が左右に分かれ、それぞれの目に配られる(2560→1280+1280) - MDNの一文で「自分の解釈ではなく公式にそう書いてある」ことを示す - この「2個」が、次の答え合わせのオチに直結する

【30秒】問いをここで回収する - まず「enabled=true の正体は baseLayer(描画先)の差し替え」と言い切る - そのうえで「そして隠れてたのがカメラ2個」とオチを置く - ArrayCamera は必要なら口頭で補足 - 最終行が本日の持ち帰り。間を置いて言う 【質疑対策】「views は必ず2つ?」と聞かれたら - 没入型ヘッドセットでは典型的に2つ(MDN: "A typical headset provides a viewer pose with two views") - ただし inline モードなら1つ、視野が広ければそれ以上もあり得る。フレームごとに変わる可能性もある - なので実装では「2つ前提」で書かず、必ず views を回すのが正しい (出典: MDN / XRView の Usage notes)

【40秒】ここが発表の防御ライン、そして締め - 3つ目で「体験会の会場設営では毎回これを気にしている」と一言添える - 4つ目が実感の総括。「エミュレータもあるけど、最後は実機」 → 体験会を運営していて何度もかぶって確かめている、と言えると強い - 教科書の注意書きではなく、自分が見たものとして話す - 最後の一行が本日の持ち帰り。間を置いて言い切ってから告知へ

【15秒】 - 「今日の話を実際に触れる場が、明後日あります」で締める - 8/9 VR・メタバース体験会(よかど鹿児島)/ 9/19 Blue & Blaze(垂水市文化会館)の2件を告知 - このスライドは質疑の間ずっと表示しておく ===== 合計 280秒(4分40秒)/ 5分枠に対し余白20秒 ===== 10+15+25+15+45+35+45+20+30+40+15 (①タイトル10 ②自己紹介15 ③3行25 ④WebXRとは15 ⑤起動45 ⑥ループ登録先35 ⑦2回描く45 ⑧図20 ⑨答え合わせ30 ⑩ハマりどころ+締め40 ⑪お知らせ15)