☁️

ストレージサービス上の Unity の WebGL アプリを React で表示する

2024/12/21に公開

はじめに

以前に Unity のアプリをどうやって React で表示するかという記事を書いた。

https://zenn.dev/takanari_dev/articles/2024-01-14-deploy-unity-webgl

これはホスティングしているサーバー上の静的なところ(いわゆるpublicフォルダ)で Unity のビルド成果物が管理されることを前提としていた。
しかし、ホスティングサービスによっては容量制限があり、特に重い Unity 関連のファイルはこの制限に引っかかる可能性がある。そうなるとストレージサービスを利用するのが一つの手になる。
また、そのような構成であれば動的にファイルをやり取りできるので、Unity Playunityroom のようなサイト構築も夢ではなくなる。

この記事はそういったストレージサービス利用に対応するための続編記事である。したがって、以前の記事を読んだことを前提にして記述するので、一度目を通してから読むと良い。
本記事を書くにあたって前提を整えるためにアップデートしているので、以前読んだことがある人でももう一度見てもらうと良いかもしれない。

また、この記事は正直難しいため、もう少し具体的なモチベーションをイメージするために以下の記事も先に見てもらうと良いかもしれない。
https://zenn.dev/huyu_kotori/articles/2024-12-12-0-make-website-scalable

前回までのあらすじ

以下のようなコンポーネントを作り、ゲームが読み込まれるまではローディング中の画面を表示して、読み込まれた後ゲーム画面を表示するものを作った。
AppLoadingUnityAppの内部で使われている。

`AppLoading`コンポーネントの実装
AppLoading.tsx
import {
  Box,
  CircularProgress,
  SxProps,
  Theme,
  Typography,
} from "@mui/material";

export type AppLoadingProps = {
  sx?: SxProps<Theme>;
  loadingSize?: string | number;
  loadingProgression?: number;
  progressVariant?: "determinate" | "indeterminate";
};

export function AppLoading({
  sx,
  loadingSize = 100,
  loadingProgression,
  progressVariant = "determinate",
}: AppLoadingProps) {
  const enableProgress = loadingProgression != undefined;
  const value = 100 * (loadingProgression ?? 0);
  return (
    <Box
      sx={sx}
      width="100%"
      height="100%"
      color="#ffffff"
      display="flex"
      justifyContent="center"
      alignItems="center"
    >
      <CircularProgress
        size={loadingSize}
        color="inherit"
        variant={enableProgress ? progressVariant : "indeterminate"}
        value={value}
      />
      <Box
        sx={{
          top: 0,
          left: 0,
          bottom: 0,
          right: 0,
          position: "absolute",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <Typography variant="caption" component="div">
          {enableProgress ? `${Math.round(value)}%` : ""}
        </Typography>
      </Box>
    </Box>
  );
}
`UnityApp`コンポーネントの実装
UnityApp.tsx
import { Box, Stack, Button, SxProps, Theme } from "@mui/material";
import FullscreenIcon from "@mui/icons-material/Fullscreen";
import { Unity, useUnityContext } from "react-unity-webgl";
import { AppLoading } from "./AppLoading";

export type UnityAppProps = {
  sx?: SxProps<Theme>;
};

export function UnityApp({ sx }: UnityAppProps) {
  const { unityProvider, isLoaded, loadingProgression, requestFullscreen } = useUnityContext({
    loaderUrl: "build/myunityapp.loader.js",
    dataUrl: "build/myunityapp.data",
    frameworkUrl: "build/myunityapp.framework.js",
    codeUrl: "build/myunityapp.wasm",
  });
  function handleOnFullScreenRequest() {
    requestFullscreen(true);
  }
  return (
    <Stack gap={2}>
      <Box sx={sx} bgcolor={"#000000"} position="relative">
        <AppLoading
          sx={{
            position: "absolute",
            top: 0,
            left: 0,
            visibility: isLoaded ? "hidden" : "visible",
          }}
          loadingProgression={loadingProgression}
        />
        <Unity
          unityProvider={unityProvider}
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: "100%",
            height: "100%",
            zIndex: 1,
            visibility: isLoaded ? "visible" : "hidden",
          }}
        />
      </Box>
      <Stack width="100%" justifyContent="center" alignItems="flex-end">
        <Box>
          <Button
            variant="outlined"
            color="secondary"
            startIcon={<FullscreenIcon />}
            disabled={!isLoaded}
            onClick={() => handleOnFullScreenRequest()}
          >
            Full Screen
          </Button>
        </Box>
      </Stack>
    </Stack>
  );
}

問題

ストレージサービス上に Unity のビルド成果物が置いてある場合、通常はストレージサービス上のファイル URL を直接指定して取得できるわけではなく、何らかの認証をした上で一定時間内のみダウンロード可能なトークン付きまたは署名付きの URL を発行する。
したがって、Unity 関連のファイルをダウンロードしてくるための URL を取得する以下のuseGetUnityFileURLsフックを用意する必要がある。

hooks.ts
import { useEffect, useState } from "react";

// 著名つき・トークン付き URL を獲得する関数
async function getDownloadableURL(path: string){
  /* ダウンロード可能な URL を返す */
};

type UnityFiles = {
  loaderUrl: string;
  dataUrl: string;
  frameworkUrl: string;
  codeUrl: string;
};

// Unity 関連ファイルの取得可能な URL を発行するフック
function useGetUnityFileURLs(rawFileURLs: UnityFiles) {
  const [unityFiles, setUnityFiles] = useState<UnityFiles | null>(null);
  useEffect(() => {
    const getURLs = async () => {
      const files = {
        loaderUrl: await getDownloadableURL(rawFileURLs.loaderUrl),
        dataUrl: await getDownloadableURL(rawFileURLs.dataUrl),
        frameworkUrl: await getDownloadableURL(rawFileURLs.frameworkUrl),
        codeUrl: await getDownloadableURL(rawFileURLs.codeUrl),
      };
      setUnityFiles(files);
    };
    getURLs();
  }, [rawFileURLs]);
  return unityFiles; // URL が発行されるまでは null
}

例えば、Cloud Storage for Firebase を使う場合ではクライアントから App Check などの認証を通す必要がある(だたし Firebase SDK が自動的に処理するため、手動で実装する必要はない)ので、Firebase SDK のgetDownloadURLを通して取得することになる。
よって、これによるgetDownloadableURLの具体的な実装は以下のようになる。

hook.ts
import { ref, getDownloadURL } from "firebase/storage";
import { storage } from "path/to/firebase/initialized"; // initialApp や App Check が設定済み

// 著名つき・トークン付き URL を獲得する関数
async function getDownloadableURL(path: string){
  const storageRef = ref(storage, path);
  return await getDownloadURL(storageRef);
};

問題になるのは、この トークン付き URL の発行にはコンポーネントの最初の描画から URL を取得するまでの「URL がない時間」が発生することである。なので静的ファイルを読み込むときと同じようにやる気持ちで、以下のようにuseUnityContextを使ってもうまくいかない。

UnityApp.tsx
/* ... */
import { Unity, useUnityContext } from "react-unity-webgl";
import { useGetUnityFileURLs } from "./hooks";

/* ... */

export function UnityApp({ sx }: UnityAppProps) {
  const unityFiles = useGetUnityFileURLs({
    loaderUrl: "build/myunityapp.loader.js",
    dataUrl: "build/myunityapp.data",
    frameworkUrl: "build/myunityapp.framework.js",
    codeUrl: "build/myunityapp.wasm",
  });

  // 初期化時点では`unityFiles`の値は null
  const { ... } = useUnityContext({ ...unityFiles });

  /* ... */
  return ( ... );
}

だとすると以下のように書きたくなるが、React のフックはトップレベルに配置しなければならないため、条件式では囲えない。

UnityApp.tsx
  /* ... */
  import { Unity, useUnityContext } from "react-unity-webgl";
  import { useGetUnityFileURLs } from "./hooks";

  /* ... */

  export function UnityApp({ sx }: UnityAppProps) {
    const unityFiles = useGetUnityFileURLs({
      loaderUrl: "build/myunityapp.loader.js",
      dataUrl: "build/myunityapp.data",
      frameworkUrl: "build/myunityapp.framework.js",
      codeUrl: "build/myunityapp.wasm",
    });
-   const { ... } = useUnityContext({ ...unityFiles });
+   if (unityFiles != null) { // この書き方は許容されない
+     const { ... } = useUnityContext({ ...unityFiles });
+   }

    /* ... */
    return ( ... );
  }

したがって、これを解決するには少し発想を変える必要がある。

解決

ここから上記の問題を解決するためにUnityAppコンポーネントを改変していく。このとき、ロジックの説明のためにしばしばスタイリングを設定している props などを省略して書くので注意すること。

useUnityContextを別のコンポーネントで閉じる

useUnityContextの引数に与える値(UnityConfig)が適用されるのは、コンポーネントが初めて DOM 上に生成された(レンダリングされた)時である。
であれば、useGetUnityFileURLsでダウンロード可能な URL を取得したタイミングで初めて「useUnityContextを内部に持ったコンポーネント」を生成できれば良い。

これをUnityPlayerコンポーネントと呼ぶことにする。

UnityPlayer.tsx
import { Unity, useUnityContext, UnityConfig } from "react-unity-webgl";

export type UnityPlayerProps = UnityConfig & {
  style?: React.CSSProperties;
};

export function UnityPlayer(props: UnityPlayerProps) {
  const { style, ...unityProps } = props;
  const { unityProvider, isLoaded } = useUnityContext(unityProps);
  return (
    <Unity
      unityProvider={unityProvider}
      style={{ ...style, visibility: isLoaded ? "visible" : "hidden" }}
    />
  );
}

あとは、UnityAppコンポーネントから以下のように使えば、少なくともやりたいことはできそうである。

UnityApp
/* ... */
import { UnityPlayer } from "./UnityPlayer";
/* ... */

export function UnityApp({ sx }: UnityAppProps) {
  const unityFiles = useGetUnityFileURLs({ /* 省略 */ });
  /* ... */
  return (
    /* ... */
      {unityFiles ? <UnityPlayer { ...unityFiles } /> : null}
    /* ... */
  );
}

ところが、ここで困るのはuseUnityContextが提供していた値や関数の数々がUnityPlayerコンポーネントの内部に移動したため使えなくなることである。例えば、AppLoadingの表示等を制御するloadingProgressionや、ゲーム画面をフルスクリーン表示にするためのrequestFullScreenといったものが受け取れなくなる。

UnityApp.tsx
/* ... */
import { UnityPlayer } from "./UnityPlayer";
/* ... */

export function UnityApp({ sx }: UnityAppProps) {
  const unityFiles = useGetUnityFileURLs({ /* 省略 */ });

  function handleOnFullScreenRequest() {
    requestFullscreen(true); // requestFullscreen?
  }

  return (
    /* ... */
      <AppLoading loadingProgression={loadingProgression} /> // loadingProgression?
      {unityFiles ? <UnityPlayer { ...unityFiles } /> : null}
    /* ... */
      <Button
        disabled={!isLoaded} // isLoaded?
        onClick={() => handleOnFullScreenRequest()}
      >
        Full Screen
      </Button>
    /* ... */
  );
}

ということで、次はUnityPlayerからuseUnityContextが提供するものをうまく外から使えるようにしたい。

useUnityContextが提供する機能をコンポーネントの外へ出す

useEffectuseImperativeHandleといった React 機能を使って、UnityPlayerのコンポーネント外部へ値や関数を渡せるようにする。

useEffectを用いて loading 状態を外部に通知する

useUnityContextによって与えられるisLoadedloadingProgressionの変更を検知して、親のコンポーネントにそれを通知できれば良い。
ということで、UnityPlayer側でuseEffectを駆使して以下のように実装すると props に追加したonLoading関数を通して親がローディング状態を監視できるようになる。

UnityPlayer.tsx
+ import { useEffect } from "react";
  import { Unity, useUnityContext, UnityConfig } from "react-unity-webgl";

+ export type LoadingState = {
+   loadingProgression: number;
+   isLoaded: boolean;
+ };

  export type UnityPlayerProps = UnityConfig & {
    style?: React.CSSProperties;
+   onLoading?: (state: LoadingState) => void;
  };

  export function UnityPlayer(props: UnityPlayerProps) {
-   const { style, ...unityProps } = props;
+   const { onLoading, style, ...unityProps } = props;
-   const { unityProvider, isLoaded } = useUnityContext(props);
+   const { unityProvider, loadingProgression, isLoaded } = useUnityContext(unityProps);

+   useEffect(() => {
+     if (onLoading) onLoading({ loadingProgression, isLoaded });
+   }, [onLoading, loadingProgression, isLoaded]);

    return (
      <Unity
        unityProvider={unityProvider}
        style={{ ...props.style, visibility: isLoaded ? "visible" : "hidden" }}
      />
    );
  }

あとはUnityAppから以下のようにonLoadingからLoadingStateを受け取って監視すれば良い。

UnityApp.tsx
+ import { useState } from "react";
  /* ... */
- import { UnityPlayer } from "./UnityPlayer";
+ import { UnityPlayer, LoadingState } from "./UnityPlayer";
  /* ... */

  export function UnityApp({ sx }: UnityAppProps) {
    const unityFiles = useGetUnityFileURLs({ /* 省略 */ });

+   const [loadingState, setLoadingState] = useState<null | LoadingState>(null);
+   const isLoaded = loadingState?.isLoaded ?? false;

    function handleOnFullScreenRequest() {
      requestFullscreen(true); // requestFullscreen?
    }

    return (
      /* ... */
-       <AppLoading loadingProgression={loadingProgression} />
+       <AppLoading loadingProgression={loadingState?.loadingProgression} /> // OK!
-       {unityFiles ? <UnityPlayer { ...unityFiles } /> : null}
+       {unityFiles ? (
+         <UnityPlayer
+           { ...unityFiles }
+           onLoading={setLoadingState}
+         />
+       ) : null}
      /* ... */
        <Button
          disabled={!isLoaded} // OK!
          onClick={() => handleOnFullScreenRequest()}
        >
          Full Screen
        </Button>
      /* ... */
    );
  }

useImperativeHandleを用いて関数を外部から使えるようにする

useImperativeHandleは親コンポーネントにrefとして参照されたときにクラスメソッドのようなものを公開できる機能である。
これを用いることでuseUnityContextが提供するrequestFullscreenを親コンポーネントで使えるようになる。

UnityPlayer.tsx
- import { useEffect } from "react";
+ import { useEffect, Ref, useImperativeHandle } from "react";
  import { Unity, useUnityContext, UnityConfig } from "react-unity-webgl";

  export type LoadingState = {
    loadingProgression: number;
    isLoaded: boolean;
  };

+ export type UnityPlayerHandle = {
+   requestFullscreen: (enabled: boolean) => void;
+ };

  export type UnityPlayerProps = UnityConfig & {
+   ref?: Ref<UnityPlayerHandle>;
    style?: React.CSSProperties;
    onLoading?: (state: LoadingState) => void;
  };

  export function UnityPlayer(props: UnityPlayerProps) {
    const { ref, onLoading, style, ...unityProps } = props;
-   const { unityProvider, loadingProgression, isLoaded } = useUnityContext(unityProps);
+   const { unityProvider, loadingProgression, isLoaded, requestFullscreen } = useUnityContext(unityProps);

    useEffect(() => {
      if (onLoading) onLoading({ loadingProgression, isLoaded });
    }, [onLoading, loadingProgression, isLoaded]);

+   useImperativeHandle(
+     ref,
+     () => ({ requestFullscreen }),
+     [requestFullscreen]
+   );

    return (
      <Unity
        unityProvider={unityProvider}
        style={{ ...props.style, visibility: isLoaded ? "visible" : "hidden" }}
      />
    );
  }

あとはUnityAppからuseRefを使ってUnityPlayerを参照すると、UnityPlayerHandleで定義された関数を扱えるようになる。

UnityApp.tsx
- import { useState } from "react";
+ import { useState, useRef } from "react";
  /* ... */
- import { UnityPlayer, LoadingState } from "./UnityPlayer";
+ import { UnityPlayer, LoadingState, UnityPlayerHandle } from "./UnityPlayer";
  /* ... */

  export function UnityApp({ sx }: UnityAppProps) {
    const unityFiles = useGetUnityFileURLs({ /* 省略 */ });

+   const unityPlayerRef = useRef<UnityPlayerHandle>(null);
    const [loadingState, setLoadingState] = useState<null | LoadingState>(null);
    const isLoaded = loadingState?.isLoaded ?? false;

    function handleOnFullScreenRequest() {
-     requestFullscreen(true);
+     unityPlayerRef.current?.requestFullscreen(true); // OK!
    }

    return (
      /* ... */
        <AppLoading loadingProgression={loadingState?.loadingProgression} /> // OK!
        {unityFiles ? (
          <UnityPlayer
            { ...unityFiles }
+           ref={unityPlayerRef}
            onLoading={setLoadingState}
          />
        ) : null}
      /* ... */
        <Button
          disabled={!isLoaded} // OK!
          onClick={() => handleOnFullScreenRequest()}
        >
          Full Screen
        </Button>
      /* ... */
    );
  }

これで、useUnityContextの提供する機能をUnityPlayerの外部に公開することができた。

最終形

最終的にはUnityPlayerにもう少しuseUnityContextが提供する関数を外部化して以下のようになった。
なお、今回の変更でAppLoadingコンポーネントに対して何もしていないので省略する。

`hooks.ts`
import { useEffect, useState } from "react";

// 著名つき・トークン付き URL を獲得する関数
async function getDownloadableURL(path: string){
  /* ダウンロード可能な URL を返す */
};

type UnityFiles = {
  loaderUrl: string;
  dataUrl: string;
  frameworkUrl: string;
  codeUrl: string;
};

// Unity 関連ファイルの取得可能な URL を発行するフック
function useGetUnityFileURLs(rawFileURLs: UnityFiles) {
  const [unityFiles, setUnityFiles] = useState<UnityFiles | null>(null);
  useEffect(() => {
    const getURLs = async () => {
      const files = {
        loaderUrl: await getDownloadableURL(rawFileURLs.loaderUrl),
        dataUrl: await getDownloadableURL(rawFileURLs.dataUrl),
        frameworkUrl: await getDownloadableURL(rawFileURLs.frameworkUrl),
        codeUrl: await getDownloadableURL(rawFileURLs.codeUrl),
      };
      setUnityFiles(files);
    };
    getURLs();
  }, [rawFileURLs]);
  return unityFiles; // URL が発行されるまでは null
}
`UnityPlayer.tsx`
import { useEffect, Ref, useImperativeHandle } from "react";
import { Unity, useUnityContext, UnityConfig } from "react-unity-webgl";
import { EventSystemHook } from "react-unity-webgl/distribution/types/event-system-hook";
import { ReactUnityEventParameter } from "react-unity-webgl/distribution/types/react-unity-event-parameters";

export type LoadingState = {
  loadingProgression: number;
  isLoaded: boolean;
};

export type UnityPlayerHandle = EventSystemHook & {
  requestFullscreen: (enabled: boolean) => void;
  sendMessage: (
    gameObjectName: string,
    methodName: string,
    parameter?: ReactUnityEventParameter
  ) => void;
  takeScreenshot: (dataType?: string, quality?: number) => string | undefined;
};

export type UnityPlayerProps = UnityConfig & {
  ref?: Ref<UnityPlayerHandle>;
  style?: React.CSSProperties;
  onLoading?: (state: LoadingState) => void;
};

export function UnityPlayer(props: UnityPlayerProps) {
  const { ref, onLoading, style, ...unityProps } = props;
  const {
    unityProvider,
    loadingProgression,
    isLoaded,
    requestFullscreen,
    sendMessage,
    takeScreenshot,
    addEventListener,
    removeEventListener,
  } = useUnityContext(unityProps);

  useEffect(() => {
    if (onLoading) onLoading({ loadingProgression, isLoaded });
  }, [onLoading, loadingProgression, isLoaded]);

  useImperativeHandle(
    ref,
    () => ({
      requestFullscreen,
      sendMessage,
      takeScreenshot,
      addEventListener,
      removeEventListener,
    }),
    [
      requestFullscreen,
      sendMessage,
      takeScreenshot,
      addEventListener,
      removeEventListener,
    ]
  );

  return (
    <Unity
      unityProvider={unityProvider}
      style={{ ...style, visibility: isLoaded ? "visible" : "hidden" }}
    />
  );
};
`UnityApp.tsx`
import { Box, Stack, Button, SxProps, Theme } from "@mui/material";
import FullscreenIcon from "@mui/icons-material/Fullscreen";
import { Unity, useUnityContext } from "react-unity-webgl";

import { useGetUnityFileURLs } from "./hooks";
import { AppLoading } from "./AppLoading";
import { UnityPlayer, LoadingState, UnityPlayerHandle } from "./UnityPlayer";

export type UnityAppProps = {
  sx?: SxProps<Theme>;
};

export function UnityApp({ sx }: UnityAppProps) {
  const unityFiles = useGetUnityFileURLs({
    loaderUrl: "build/myunityapp.loader.js",
    dataUrl: "build/myunityapp.data",
    frameworkUrl: "build/myunityapp.framework.js",
    codeUrl: "build/myunityapp.wasm",
  });

  const unityPlayerRef = useRef<UnityPlayerHandle>(null);
  const [loadingState, setLoadingState] = useState<null | LoadingState>(null);
  const isLoaded = loadingState?.isLoaded ?? false;

  function handleOnFullScreenRequest() {
    unityPlayerRef.current?.requestFullscreen(true);
  }

  return (
    <Stack gap={2}>
      <Box sx={sx} bgcolor={"#000000"} position="relative">
        <AppLoading
          sx={{
            position: "absolute",
            top: 0,
            left: 0,
            visibility: isLoaded ? "hidden" : "visible",
          }}
          loadingProgression={loadingState?.loadingProgression}
        />
        {unityFiles ? (
          <UnityPlayer
            {...unityFiles}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: "100%",
              zIndex: 1,
            }}
            ref={unityPlayerRef}
            onLoading={setLoadingState}
          />
        ) : null}
      </Box>
      <Stack width="100%" justifyContent="center" alignItems="flex-end">
        <Box>
          <Button
            variant="outlined"
            color="secondary"
            startIcon={<FullscreenIcon />}
            disabled={!isLoaded}
            onClick={() => handleOnFullScreenRequest()}
          >
            Full Screen
          </Button>
        </Box>
      </Stack>
    </Stack>
  );
}

終わりに

実は記事を書いていて「ひょっとすると<Suspense/>を使うともう少し賢く実装できるのではないか」と思った。これはこれで時間がかかりそうなので、一旦はここで終わろうと思う。
もしできそうなら、そのときはこの記事に追記するか新しく記事を書きたい。

脚注
  1. *.loader.jsは Unity がビルド時に生成するもので、*.data*.framework.js*.wasmファイルをダウンロードしてきてキャッシュしたり、IndexDBのセットアップをしてくれたりするスクリプトである。 ↩︎

不遊小鳥

Discussion