ストレージサービス上の Unity の WebGL アプリを React で表示する
はじめに
以前に Unity のアプリをどうやって React で表示するかという記事を書いた。
これはホスティングしているサーバー上の静的なところ(いわゆるpublic
フォルダ)で Unity のビルド成果物が管理されることを前提としていた。
しかし、ホスティングサービスによっては容量制限があり、特に重い Unity 関連のファイルはこの制限に引っかかる可能性がある。そうなるとストレージサービスを利用するのが一つの手になる。
また、そのような構成であれば動的にファイルをやり取りできるので、Unity Play や unityroom のようなサイト構築も夢ではなくなる。
この記事はそういったストレージサービス利用に対応するための続編記事である。したがって、以前の記事を読んだことを前提にして記述するので、一度目を通してから読むと良い。
本記事を書くにあたって前提を整えるためにアップデートしているので、以前読んだことがある人でももう一度見てもらうと良いかもしれない。
また、この記事は正直難しいため、もう少し具体的なモチベーションをイメージするために以下の記事も先に見てもらうと良いかもしれない。
前回までのあらすじ
以下のようなコンポーネントを作り、ゲームが読み込まれるまではローディング中の画面を表示して、読み込まれた後ゲーム画面を表示するものを作った。
AppLoading
はUnityApp
の内部で使われている。
`AppLoading`コンポーネントの実装
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`コンポーネントの実装
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
フックを用意する必要がある。
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
の具体的な実装は以下のようになる。
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
を使ってもうまくいかない。
/* ... */
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 のフックはトップレベルに配置しなければならないため、条件式では囲えない。
/* ... */
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
コンポーネントと呼ぶことにする。
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
コンポーネントから以下のように使えば、少なくともやりたいことはできそうである。
/* ... */
import { UnityPlayer } from "./UnityPlayer";
/* ... */
export function UnityApp({ sx }: UnityAppProps) {
const unityFiles = useGetUnityFileURLs({ /* 省略 */ });
/* ... */
return (
/* ... */
{unityFiles ? <UnityPlayer { ...unityFiles } /> : null}
/* ... */
);
}
ところが、ここで困るのはuseUnityContext
が提供していた値や関数の数々がUnityPlayer
コンポーネントの内部に移動したため使えなくなることである。例えば、AppLoading
の表示等を制御するloadingProgression
や、ゲーム画面をフルスクリーン表示にするためのrequestFullScreen
といったものが受け取れなくなる。
/* ... */
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
が提供する機能をコンポーネントの外へ出す
useEffect
やuseImperativeHandle
といった React 機能を使って、UnityPlayer
のコンポーネント外部へ値や関数を渡せるようにする。
useEffect
を用いて loading 状態を外部に通知する
useUnityContext
によって与えられるisLoaded
やloadingProgression
の変更を検知して、親のコンポーネントにそれを通知できれば良い。
ということで、UnityPlayer
側でuseEffect
を駆使して以下のように実装すると props に追加したonLoading
関数を通して親がローディング状態を監視できるようになる。
+ 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
を受け取って監視すれば良い。
+ 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
を親コンポーネントで使えるようになる。
- 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
で定義された関数を扱えるようになる。
- 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/>
を使うともう少し賢く実装できるのではないか」と思った。これはこれで時間がかかりそうなので、一旦はここで終わろうと思う。
もしできそうなら、そのときはこの記事に追記するか新しく記事を書きたい。
-
*.loader.js
は Unity がビルド時に生成するもので、*.data
や*.framework.js
、*.wasm
ファイルをダウンロードしてきてキャッシュしたり、IndexDBのセットアップをしてくれたりするスクリプトである。 ↩︎
Discussion