react-three/xr を使って Web AR コンテンツを制作する

このスクラップについて
react-three/xr を使って Web AR コンテンツを制作する方法についてまとめていく。

役に立ちそうな記事

QR コードをマーカーにするのは難しいようだ

AR 以前に React Three Fiber が怪しい
React Three Fiber に触らない時間が長くなりすぎて基本的なシーンが作成できる自信がないので、まずは思い出すところから始めよう。

プロジェクト作成
pnpm create cloudflare@latest --framework react-router scrap-web-ar

React Three Fiber のインストール
Three-fiber is a React renderer, it must pair with a major version of React, just like react-dom, react-native, etc. @react-three/fiber@8 pairs with react@18, @react-three/fiber@9 pairs with react@19.
package.json を確認したところ React は 19.1.0 だったので、@react-three/fiber@9 がインストールされれば良さそうだ。
まずは何も指定しないでインストールしてみよう。
pnpm install three @types/three @react-three/fiber
下記がインストールされた、バージョン的には大丈夫そうだ。
- "@react-three/fiber": "^9.1.2",
- "@types/three": "^0.176.0",
- "three": "^0.176.0"

Biome のセットアップ
pnpm i -D -E @biomejs/biome
pnpm biome init
設定はお好みで。

最初のシーン
import { Canvas } from "@react-three/fiber";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
export default function Home() {
return (
<Canvas>
<ambientLight intensity={0.1} />
<directionalLight color="red" position={[0, 0, 5]} />
<mesh>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial />
</mesh>
</Canvas>
);
}
何やら赤い四角が出現した

AR ボタンを追加してみる
pnpm i @react-three/xr
import { Canvas } from "@react-three/fiber";
import { XR, createXRStore } from "@react-three/xr";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<Canvas>
<XR store={store}>
<mesh position={[0, 1, -1]}>
<boxGeometry />
<meshBasicMaterial color="red" />
</mesh>
</XR>
</Canvas>
</>
);
}
Enter AR ボタンと Enter XR ボタンが現れた

Enter XR ボタンについて

XR で見てみる
なんか潰れた感じのものが表示されている

Meta Quest で見てみた
真っ赤な立方体が表示された。
1 は 1 メートルなのかめちゃくちゃでかい。
光源を設定すれば良い感じになるのだろうか?
あと陰影がなくてわからないので円柱とかにしてみよう。

立方体と光源を設置
import { Canvas } from "@react-three/fiber";
import { XR, createXRStore } from "@react-three/xr";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<Canvas>
<XR store={store}>
<ambientLight intensity={0.5} />
<directionalLight position={[0, 10, 10]} intensity={1.5} />
<mesh position={[0, 1, -1]}>
<cylinderGeometry args={[250e-3, 250e-3, 500e-3]} />
<meshStandardMaterial />
</mesh>
</XR>
</Canvas>
</>
);
}
潰れているが陰影のある円柱が表示された
デプロイして VR ゴーグルのブラウザでリロードしたら、しっかり円柱が表示された。
そして 500 mm でもかなり大きい、次は 200 mm にしてみよう。
というか Canvas が小さすぎるので、まずはこれを大きくするところから始めよう。

次にやること
位置を決めて円柱を表示させることをやってみたい。

こちらのコードが参考になるようだ

useHitTest とは何か
こちらのソースコードでは useHitTest が使用されているが、もしかすると今は useXRHitTest を使う必要があるのかも知れない。
また、公式ドキュメントにあるように XRHitTest コンポーネントを使う方が良いのかも知れない。

どうしよう
何から手をつければ良いかわからなくなってきた。
こういう時は急がば回れでチュートリアルを 1 つ 1 つやって行った方が良いかも知れない。

Convert to XR
How do I move around in my scene?
シーンを動き回るには XROrigin や Teleportation が使えるようだ、これは VR 限定かな?
How can I customize the way my hands/controllers/... feel, look, or interact with the scene?
手やコントローラーの使い勝手や見た目、操作方法をカスタマイズするには Custom Hands etc. のページが参考になるようだ。
How do interactions work in XR, and how can I build more advanced interactions?
XR 空間に何かしら関与するには Intractions のページが参考になるようだ、これはぜひ学んでおいた方が良さそう。
関与できない VR 空間は幽霊になったようなものだと思う。
How can I leverage the mixed reality features of my headset, such as Plane Detection?
地面の検出などヘッドセットの MR 機能を使うには Object Detection のページが参考になるようだ。

Development Setup
このページに iwer/devui の説明があるのでぜひ読んでおきたい、まずは ChatGPT で要約してみよう。

vite プラグインも興味深い
こちらはモジュールの自動読み込みに必要なようだが既に有効になっている気がする。
ChatGPT 的には推奨されるようだが、
を見ると React Router のプラグインとバッティングしてしまいそうなので入れない方が良さそうだ。
一方、@vitejs/plugin-basic-ssl については Cloudflare にデプロイしなくてもローカルで確認できるようになるので入れてみる価値がある。
pnpm i -D @vitejs/plugin-basic-ssl
import { cloudflare } from "@cloudflare/vite-plugin";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import basicSsl from "@vitejs/plugin-basic-ssl";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
tailwindcss(),
reactRouter(),
tsconfigPaths(),
basicSsl(),
],
});
なんとなくうまく行かなそうな感じがする

なんか元に戻らなくなってしまった
いっそ最初から作り直すか?

下記コマンドで解決しました。
rm -rf .react-router .wrangler

結局 vite のプラグインはこの構成では不要だった
React Router v7 on Cloudflare では下記は不要だったが、他の構成ではうまくいくのかもしれない。
- @vitejs/plugin-react
- @vitejs/plugin-basic-ssl

TanStack Start on Cloudflare に浮気したい

IWER の使い方
さっき奇跡的に動けたのだがどうやったのだろうか。

オブジェクトが潰れているけど
もしかしたらカメラを設定すれば良いのだろうか?

Canvas のサイズを設定してみた
import { Canvas } from "@react-three/fiber";
import { XR, createXRStore } from "@react-three/xr";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<div className="flex justify-center">
<div className="w-[800px] h-[600px]">
<Canvas>
<XR store={store}>
<ambientLight intensity={0.5} />
<directionalLight position={[0, 10, 10]} intensity={1.5} />
<mesh position={[0, 1, -1]}>
<cylinderGeometry args={[250e-3, 250e-3, 500e-3]} />
<meshStandardMaterial />
</mesh>
</XR>
</Canvas>
</div>
</div>
</>
);
}
これだけで随分見栄えがよくなった

Shift で歩ける!
ようやくわかった。

Examples
こうやってみると色々とすごいことができるなーとワクワクする。
ソースコードは下記から閲覧できるようだ。

FAQ
ここも結構ボリュームがあるが、読んでからチュートリアルに入る方が良さそうだ。

FAQ を読んでいく
How can I read the camera position or rotation in XR?
getWorldPosition と getWorldQuaternion で取得できるようだ、これはヘッドセットの位置とクォータニオンということで良いのだろうか?
How can I change the camera position in XR?
開発者側でカメラの位置を変更できるかという質問だがこれはできないようだ。
カメラの位置や回転角を変更できるのはユーザーの頭の動きだけのようだ。
代わりに XROrigin というコンポーネントが提供され、このコンポーネントを使うことでシーンに描画されるオブジェクトの原点を制御できるようになるようだ。
Having problems accessing the camera position or rotation.
OrbitControls や CameraControls を使うとカメラの位置や回転角を取得するのに問題が生じるようだ。
これは確かにハマりそうなポイントだ。
対策としては <IfInSessionMode deny={['immersive-ar', 'immersive-vr']}>
で囲むか OrbitHandles や MapHandles コンポーネントを使うと良いようだ。
I cannot enter the XR session!
XR セッションに入れない場合は次を確認してみる。
- HTTPS かどうか
- XR コンポーネントで囲んでいるかどうか
- 3D モデルなどのアセットのロード中ではないか

FAQ 続き
How can I exit an XR session?
XR セッションを終了するには store.getState().session?.end()
を実行する。
Is WebGPU supported?
WebXR では基本的に WebGPU は使えないと考えて良さそうだ。
How can I put HTML in my XR scene?
XR シーンに HTML を描画する方法、これはぜひ知りたい。
スマートフォンによる AR の場合は DOM オーバーレイが使えるようだ。
一方、VR ゴーグルの場合は react-three/uikit を使う必要がある。
uikit の使い方を学ぶのはちょっと大変かも知れないが可能性があるというのはありがたいことだ。
Does it work on iOS?
WebXR は Vision Pro の Safari では動作するようだが、iOS の Safari では動かないようだ。
ただ専用のアプリを入れれば見られるようだ。
XRSpace
XRSpace コンポーネントを XROrigin コンポーネントの外に配置して XROrigin の座標を変えても XRSpace 内のオブジェクトには変更は反映されない。
変更を反映したい場合は XROrigin コンポーネント内に XRSpace コンポーネントを配置する必要がある。
一方で XRSpace を敢えて XROrigin の外に配置するのが便利なケースもある。
一例として仮想エレベーターのようなコンテンツでは昇降時にエレベーター内のオブジェクトは止まっており、外のオブジェクトは上下に動くことになるので、こういう時に便利そうだ。
onClick does not play video or allow file uploading (in certain browsers)
パフォーマンス最適化のためにフレーム内の一部のイベントはバッチにまとめられる。
対象となるイベントは PointerEvents, forwardHtmlEvents, forwardObjectEvents の 3 つ。
ファイルのアップロードのようにユーザーのアクション後に直ちに実行する必要がある場合は問題になるケースがある。
このようなケースに対応するために batchEvents を OFF にすることもできる。

Store
Store は @react-three/xr の中核を担う部分であり、次のような役割がある。
- 様々なオプションを通じて XR 体験をカスタマイズする
- 様々な機能を通じて XR 体験をコントロールする
- XR 体験の現在の状態へのアクセスを提供する
オプションを眺めてみると本当に色々とある。
興味深いのは hitTest で説明は下記の通り。
Enables or turns off hit testing, which allows the system to detect where the user's input (e.g., a tap or gaze) intersects with objects in the XR environment.
hitTest とは XR 空間上のオブジェクトを見たり触れたりするのを検出する機能のようだ。

uikit については別のスクラップで遊んでみました。

次は Functions を眺めてみよう

Functions
requestFrame 関数が面白そう。
Returns a promise that resolves with the XR frame on the next render. This function is useful for synchronizing actions or processing data in the next render cycle, especially for tasks that need to be aligned with the rendering loop.
次のレンダリングのタイミングで解決されるようだ。
レンダリングループに合わせて何らかの処理を実行したい時に便利らしい。

State
originReferenceSpace
が重要そうだ。
Refers to the XRReferenceSpace of the origin in the current session. This space is typically set at the floor level and serves as the reference point for the user's position in the XR environment.
地面の座標を取得する時などに便利そうだ。
detectedPlanes
も床や壁やテーブルを取得できるらしい、すごいな。
A read-only array of XRPlane objects representing the planes detected in the XR environment. These could include surfaces like floors, walls, and tables.

useXR
関数化したコンポーネントなどからストアにアクセスしたい場合に使うと良さそうだ。

遂に Intructions
まずは Mesh に onClick でも設定してみよう。
import { Canvas } from "@react-three/fiber";
import { XR, createXRStore } from "@react-three/xr";
import { useState } from "react";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
const [color, setColor] = useState("white");
const toggleColor = () => {
setColor((prevColor) => (prevColor === "white" ? "orange" : "white"));
};
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<div className="flex justify-center">
<div className="w-[800px] h-[600px]">
<Canvas>
<XR store={store}>
<ambientLight intensity={0.5} />
<directionalLight position={[0, 10, 10]} intensity={1.5} />
<mesh position={[0, 1, -1]} onClick={toggleColor}>
<cylinderGeometry args={[250e-3, 250e-3, 500e-3]} />
<meshStandardMaterial color={color} />
</mesh>
</XR>
</Canvas>
</div>
</div>
</>
);
}
色が変わるようになった
IWER でも確認できた

pointerEvents
The pointerEvents property corresponds to the pointerEvents property of CSS, which allows to completely disable pointer events for an element and its children. However, children can also re-enable pointer events by setting pointerEvents="auto".
pointerEvents プロパティを使うことでポインターイベントを無効にできるようだ。
親が無効になっていても子の方で pointerEvents="auto"
と設定することで有効に戻すことできる。

pointerEventsType
The pointerEventsType property allows to blacklist or whitelist pointer events for specific pointer types. For instance, setting pointerEventsType={{ deny: "grab" }} prevents triggering pointer events from grabbing the object or any of its children.
pointerEventsType プロパティを使うことで特定のイベントを除去したり、逆に特定のイベントに限定することができるようだ。
例えば pointerEventsType={{ deny: "grab" }}
と設定することでグラビング(よくわからないが握る操作)を無効にできるようだ。

pointerEventsOrder
The pointerEventsOrder allows to overwrite the sorting order, similar to how renderOrder allows to overwrite the rendering order in threejs. The default pointer events order is 0. Setting it to a value greater than 0 will ensure it is intersected before anything with a lower pointer events order. Setting pointerEventsOrder is helpful for building an interactive x-ray object that is always rendered above anything else and should, therefore, always be interacted with first. For instance, this can be used to build controls that are overlayed over the object that they control.
pointerEventsOrder プロパティを使うことで何かの順序を上書きできるようだ。
ポインターイベントのデフォルト値は 0 であり、0 より大きくすることで他のものより優先されるようになるようだ。
XR 空間ではポインターの直線が 3 次元的に飛んでいくので、複数のオブジェクトを貫通するケースがあるのかも知れない。
このような時にどのオブジェクトのイベントハンドラを実行するのかを決めるのに pointerEventsOrder が便利なのかも知れない。
せっかくなので実験してみよう。
import { Canvas } from "@react-three/fiber";
import { XR, createXRStore } from "@react-three/xr";
import { type ComponentProps, useState } from "react";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<div className="flex justify-center">
<div className="w-[800px] h-[600px]">
<Canvas>
<XR store={store}>
<ambientLight intensity={0.5} />
<directionalLight position={[0, 10, 10]} intensity={1.5} />
<TogglableCylinder position={[0, 1, -1]} />
<TogglableCylinder position={[0, 1, -2]} pointerEventsOrder={1} />
</XR>
</Canvas>
</div>
</div>
</>
);
}
function TogglableCylinder(props: ComponentProps<"mesh">) {
const [color, setColor] = useState("white");
const toggleColor = () => {
setColor((prevColor) => (prevColor === "white" ? "orange" : "white"));
};
return (
<mesh onClick={toggleColor} {...props}>
<cylinderGeometry args={[250e-3, 250e-3, 500e-3]} />
<meshStandardMaterial color={color} />
</mesh>
);
}
奥の方のオブジェクトが優先される
ちなみに IWER じゃない場合は両方とも色が変わってしまう、どうにする方法はあるのだろうか。

次は Pointer Capture から始めよう
なんか面白そうだ。

Pointer Capture
ドラッグする操作を実装するのに便利なようだ。
@react-three/fiber は、ウェブの機能の一つである「ポインターキャプチャ」を活用しています。これは、あるオブジェクトに対してポインターイベントを強制的に送り続ける仕組みで、たとえそのオブジェクトがポインターと交差していなくても適用されます。これにより、複雑なグローバルステートを使わずにドラッグ操作などのインタラクションを実現できます。
通常は onPointerDown のイベントハンドラー内で、押されたポインターの pointerId を使って object.setPointerCapture を呼び出して設定します。
また、以下の例では、変換されたグループ内に mesh がない場合に限定されますが、ポインターイベントを利用したシンプルなドラッグ操作の実装方法が示されています。
import { Canvas } from "@react-three/fiber";
import { XR, createXRStore } from "@react-three/xr";
import { useRef } from "react";
import type { Mesh } from "three";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<div className="flex justify-center">
<div className="w-[800px] h-[600px]">
<Canvas>
<XR store={store}>
<ambientLight intensity={0.5} />
<directionalLight position={[0, 10, 10]} intensity={1.5} />
<DraggableCube />
</XR>
</Canvas>
</div>
</div>
</>
);
}
function DraggableCube() {
const isDraggingRef = useRef(false);
const meshRef = useRef<Mesh>(null);
return (
<mesh
ref={meshRef}
onPointerDown={(e) => {
if (isDraggingRef.current) {
return;
}
isDraggingRef.current = true;
meshRef.current?.position.copy(e.point);
}}
onPointerMove={(e) => {
if (!isDraggingRef.current) {
return;
}
meshRef.current?.position.copy(e.point);
}}
onPointerUp={(e) => {
isDraggingRef.current = false;
}}
>
<boxGeometry />
</mesh>
);
}
ドラッグしようとすると明後日の方向に行ってしまう。

Object Detection
ポインタキャプチャーは一旦置いておいて物体検出をやってみよう。
import { Canvas } from "@react-three/fiber";
import {
XR,
XRPlaneModel,
XRSpace,
createXRStore,
useXRPlanes,
} from "@react-three/xr";
export function meta() {
return [{ title: "Scrap Web AR" }];
}
const store = createXRStore();
export default function Home() {
return (
<>
<button type="button" onClick={() => store.enterAR()}>
Enter AR
</button>
<div className="flex justify-center">
<div className="w-[800px] h-[600px]">
<Canvas>
<XR store={store}>
<ambientLight intensity={0.5} />
<directionalLight position={[0, 10, 10]} intensity={1.5} />
<RedWalls />
</XR>
</Canvas>
</div>
</div>
</>
);
}
function RedWalls() {
const wallPlanes = useXRPlanes("wall");
return (
<>
{wallPlanes.map((plane) => (
<XRSpace space={plane.planeSpace}>
<XRPlaneModel plane={plane}>
<meshBasicMaterial color="red" />
</XRPlaneModel>
</XRSpace>
))}
</>
);
}
なんか赤い壁のようなものが表示されるようになった
ちなみに Meta Quest でアクセスしても何も表示されなかった。
壁は対応していないのかな?

Semantic label
- wall
- ceiling
- floor
- wall art
- window
- door
- table
などがあるようだ。

雲行きが怪しい
色々と頑張ってみたが結局 Quest 2 で床を表示させることはできなかった。
床の情報取得についてはやっている方がいるのでできそうなものだが。。。
@react-three/xr を使ったら Web AR コンテンツを簡単に作れそうだと安易に考えていたが、どうやらそういう訳でも無さそうだ。

Origin
オリジンとはユーザーの足の位置のようだ。

Claude Code に考えてもらう。
@react-three/fiber と @react-three/xr を使用して次のような Web AR コンテンツを制作したいです。
- コントローラーを使用して床の平面内に含まれる座標を選択する
- 選択した座標上に立方体を表示する
実装方法について考えてください。
それっぽいのができた?
import { Canvas } from '@react-three/fiber'
import { XR, Interactive, createXRStore } from '@react-three/xr'
import { useState, useRef } from 'react'
import * as THREE from 'three'
interface Cube {
id: string
position: [number, number, number]
}
const store = createXRStore()
function ARContent() {
const [cubes, setCubes] = useState<Cube[]>([])
const planeRef = useRef<THREE.Mesh>(null!)
const handlePlaneSelect = (event: { intersection: THREE.Intersection; intersections: THREE.Intersection[]; target: THREE.Object3D }) => {
if (event.intersections && event.intersections.length > 0) {
const intersection = event.intersections[0]
const position = intersection.point
const newCube: Cube = {
id: Date.now().toString(),
position: [position.x, position.y + 0.1, position.z]
}
setCubes(prev => [...prev, newCube])
}
}
return (
<>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} />
{/* Interactive floor plane */}
<Interactive onSelect={handlePlaneSelect}>
<mesh
ref={planeRef}
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, 0]}
>
<planeGeometry args={[10, 10]} />
<meshStandardMaterial
color="gray"
transparent
opacity={0.3}
side={THREE.DoubleSide}
/>
</mesh>
</Interactive>
{/* Render placed cubes */}
{cubes.map((cube) => (
<mesh key={cube.id} position={cube.position}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshStandardMaterial color="red" />
</mesh>
))}
</>
)
}
export default function ARScene() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<button
onClick={() => store.enterAR()}
style={{
position: 'absolute',
top: '20px',
left: '20px',
zIndex: 1000,
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}
>
Enter AR
</button>
<Canvas>
<XR store={store}>
<ARContent />
</XR>
</Canvas>
</div>
)
}
ちなみにこのタスクの実行で 0.52 ドル分のトークンを消費したようだ。