Open50

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

AR 以前に React Three Fiber が怪しい

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

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

React Three Fiber のインストール

https://r3f.docs.pmnd.rs/getting-started/introduction

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"
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

最初のシーン

app/routes/home.tsx
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>
  );
}


何やら赤い四角が出現した

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

AR ボタンを追加してみる

https://github.com/pmndrs/xr

コマンド
pnpm i @react-three/xr
app/routes/home.tsx
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 ボタンが現れた

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Meta Quest で見てみた

真っ赤な立方体が表示された。

1 は 1 メートルなのかめちゃくちゃでかい。

光源を設定すれば良い感じになるのだろうか?

あと陰影がなくてわからないので円柱とかにしてみよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

立方体と光源を設置

app/routes/home.tsx
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 が小さすぎるので、まずはこれを大きくするところから始めよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

useHitTest とは何か

https://pmndrs.github.io/xr/docs/tutorials/hit-test

https://github.com/mohitkumartoshniwal/r3f-webxr/blob/xr-hit-model/src/components/xr-hit-model/XrHitModel.jsx

こちらのソースコードでは useHitTest が使用されているが、もしかすると今は useXRHitTest を使う必要があるのかも知れない。

https://github.com/pmndrs/xr/blob/main/packages/react/xr/src/hit-test.tsx

また、公式ドキュメントにあるように XRHitTest コンポーネントを使う方が良いのかも知れない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

どうしよう

何から手をつければ良いかわからなくなってきた。

こういう時は急がば回れでチュートリアルを 1 つ 1 つやって行った方が良いかも知れない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Convert to XR

https://pmndrs.github.io/xr/docs/getting-started/convert-to-xr

How do I move around in my scene?

シーンを動き回るには XROriginTeleportation が使えるようだ、これは 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 のページが参考になるようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

vite プラグインも興味深い

https://www.npmjs.com/package/@vitejs/plugin-react

こちらはモジュールの自動読み込みに必要なようだが既に有効になっている気がする。

https://chatgpt.com/share/683ea65f-7da4-8003-927e-bee6f781ab39

ChatGPT 的には推奨されるようだが、

https://github.com/remix-run/react-router/issues/12870

を見ると React Router のプラグインとバッティングしてしまいそうなので入れない方が良さそうだ。

一方、@vitejs/plugin-basic-ssl については Cloudflare にデプロイしなくてもローカルで確認できるようになるので入れてみる価値がある。

コマンド
pnpm i -D @vitejs/plugin-basic-ssl
vite.config.ts
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(),
  ],
});

https://www.npmjs.com/package/@vitejs/plugin-basic-ssl


なんとなくうまく行かなそうな感じがする

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

結局 vite のプラグインはこの構成では不要だった

React Router v7 on Cloudflare では下記は不要だったが、他の構成ではうまくいくのかもしれない。

  • @vitejs/plugin-react
  • @vitejs/plugin-basic-ssl
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Canvas のサイズを設定してみた

app/routes/home.tsx
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>
    </>
  );
}


これだけで随分見栄えがよくなった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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 モデルなどのアセットのロード中ではないか
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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 では動かないようだ。

ただ専用のアプリを入れれば見られるようだ。

https://launch.variant3d.com/

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 にすることもできる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Store

https://pmndrs.github.io/xr/docs/tutorials/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 空間上のオブジェクトを見たり触れたりするのを検出する機能のようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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.

次のレンダリングのタイミングで解決されるようだ。

レンダリングループに合わせて何らかの処理を実行したい時に便利らしい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

State

https://pmndrs.github.io/xr/docs/tutorials/store#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.

https://developer.mozilla.org/ja/docs/Web/API/XRReferenceSpace

地面の座標を取得する時などに便利そうだ。

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.

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

遂に Intructions

https://pmndrs.github.io/xr/docs/tutorials/interactions

まずは Mesh に onClick でも設定してみよう。

app/routes/home.tsx
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 でも確認できた

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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" と設定することで有効に戻すことできる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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" }} と設定することでグラビング(よくわからないが握る操作)を無効にできるようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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 が便利なのかも知れない。

せっかくなので実験してみよう。

app/routes/home.tsx
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 じゃない場合は両方とも色が変わってしまう、どうにする方法はあるのだろうか。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Pointer Capture

ドラッグする操作を実装するのに便利なようだ。

@react-three/fiber は、ウェブの機能の一つである「ポインターキャプチャ」を活用しています。これは、あるオブジェクトに対してポインターイベントを強制的に送り続ける仕組みで、たとえそのオブジェクトがポインターと交差していなくても適用されます。これにより、複雑なグローバルステートを使わずにドラッグ操作などのインタラクションを実現できます。
通常は onPointerDown のイベントハンドラー内で、押されたポインターの pointerId を使って object.setPointerCapture を呼び出して設定します。
また、以下の例では、変換されたグループ内に mesh がない場合に限定されますが、ポインターイベントを利用したシンプルなドラッグ操作の実装方法が示されています。

app/routes/home.tsx
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>
  );
}

ドラッグしようとすると明後日の方向に行ってしまう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Object Detection

ポインタキャプチャーは一旦置いておいて物体検出をやってみよう。

https://pmndrs.github.io/xr/docs/tutorials/object-detection

app/routes/home.tsx
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 でアクセスしても何も表示されなかった。

壁は対応していないのかな?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

雲行きが怪しい

色々と頑張ってみたが結局 Quest 2 で床を表示させることはできなかった。

https://fintan.jp/page/11741/

床の情報取得についてはやっている方がいるのでできそうなものだが。。。

@react-three/xr を使ったら Web AR コンテンツを簡単に作れそうだと安易に考えていたが、どうやらそういう訳でも無さそうだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Claude Code に考えてもらう。

プロンプト
@react-three/fiber と @react-three/xr を使用して次のような Web AR コンテンツを制作したいです。

- コントローラーを使用して床の平面内に含まれる座標を選択する
- 選択した座標上に立方体を表示する

実装方法について考えてください。


それっぽいのができた?

src/ARScene.tsx
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 ドル分のトークンを消費したようだ。