🦅

Unity で作った WebGL のアプリを React + MUI で良い感じに置く

2024/01/14に公開

はじめに

GitHub Pages を使ってサイトを公開する方法を共著で記事を書いた。
https://zenn.dev/numagotatu/articles/2024-01-07-deploy-github-pages
https://zenn.dev/numagotatu/articles/2024-01-07-deploy-github-pages-appendix

この記事を作るためにいろいろ調べていたとき「Unity で作ったちょっとしたものをどうやってデプロイするのか」という話が出てきたので、やってみた。
イメージがわかるように以下に成果物としてページリンクを貼っておく。
https://takanari.web.app/artifact/fireworks

なお、GitHub Pages を使って公開する方法は冒頭に載せた二つの記事を参考にするか、調べてみてほしい。

Unity で作った WebGL のアプリをどうやって React で扱うか

ありがたいことに React Unity WebGL という神ツールがすでにあるのでこれを使う。
https://qiita.com/GIZAGIZAHEART/items/c3b6f6035933e0d760b6
package.jsonと同じ階層にpublicディレクトリを作り、そこに Unity からビルドしたファイル群を入れる。あとはpublicディレクトリ以降のパスを React Unity WebGL 側に渡してあげれば良い。公式にある例であればpublic/buildディレクトリに入れる。

import React from "react";
import { Unity, useUnityContext } from "react-unity-webgl";

function App() {
  const { unityProvider } = useUnityContext({
    loaderUrl: "build/myunityapp.loader.js",
    dataUrl: "build/myunityapp.data",
    frameworkUrl: "build/myunityapp.framework.js",
    codeUrl: "build/myunityapp.wasm",
  });

  return <Unity unityProvider={unityProvider} />;
}

ただし、Unityでの出力設定には注意が必要で、Player Settings > Playerにある WebGLのタブを開いた中のPulishing Settings > Compression FormatDisabledにしておく。Brotliなどが設定されている場合、ビルドファイルが圧縮ファイルとなり、React Unity WebGL 側で読み込んでくれなかった。
圧縮設定を無圧縮にしておく

読み込み中はローディング画面を表示する

無圧縮のファイルを読み込んでこなければならないため、それに少し時間がかかる。また読み込んでくる間は何も表示されず、フィードバックがないためユーザー体験としては悪い。
そこで MUI<CircularProgress/>を利用して読み込んでいる間はローディング中であることを明示し、読み込み終わったら Unity の画面を表示できれば良い。
まずは以下のような<AppLoading/>を用意する。

AppLoading.tsx
import { Box, CircularProgress, SxProps, Theme } from "@mui/material";

export type AppLoadingProps = {
  sx?: SxProps<Theme>;
  loadingSize?: string | number;
};

export function AppLoading({ sx, loadingSize = 100 }: AppLoadingProps) {
  return (
    <Box
      sx={sx}
      width="100%"
      height="100%"
      color="#ffffff"
      display="flex"
      justifyContent="center"
      alignItems="center"
    >
      <CircularProgress size={loadingSize} color="inherit" />
    </Box>
  );
}

あとはローディング中は<AppLoading/>を表示するようにすれば良い。React Unity WebGL のuseUnityContextからローディングの状態を取得できるようになっていて、今回はisLoadedを使って調整する。
https://react-unity-webgl.dev/docs/advanced-examples/loading-overlay
ここで、ただ単純にisLoaded ? <AppLoading/> : <Unity/>のような構図でやってしまうとうまくいかなかった。どうやら<Unity/>コンポーネントは条件によらず常にDOM上に存在しないといけないらしい。ということで重ねて表示して、visibilityisLoadedでオンオフできれば良い。意外と"重ねて表示"に手こずったが、親要素にposition: "relative", 子要素にはposition: "absolute"で場所を指定することで成功した。

UnityApp.tsx
import { Box, SxProps, Theme } from "@mui/material";
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 } = useUnityContext({
    loaderUrl: "build/myunityapp.loader.js",
    dataUrl: "build/myunityapp.data",
    frameworkUrl: "build/myunityapp.framework.js",
    codeUrl: "build/myunityapp.wasm",
  });

  return (
    <Box sx={sx} bgcolor={"#000000"} position="relative">
      <AppLoading
        sx={{
          position: "absolute",
          top: 0,
          left: 0,
          visibility: isLoaded ? "hidden" : "visible",
        }}
      />
      <Unity
        unityProvider={unityProvider}
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: "100%",
          height: "100%",
          zIndex: 1,
          visibility: isLoaded ? "visible" : "hidden",
        }}
      />
    </Box>
  );
}

フルスクリーンモードに対応する 【2024/02/25 追記】

React Unity WebGL にはゲーム画面をフルスクリーンで表示する機能が提供されている。requestFullscreenを使えば良い。

UnityApp.tsx
- import { Box, SxProps, Theme } from "@mui/material";
+ 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 } = useUnityContext({
+   const { unityProvider, isLoaded, 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",
            }}
          />
          <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>
    );
  }

ローディング進捗を表示する 【2024/12/14 追記】

Unityを表示するまでに容量によってはそれなりに時間がかかる。グルグルが続くと仮に内部でローディングが進んでいたとしてもサイト離脱率が上がるかもしれない。
ということで、useUnityContextフックにはローディングの進捗をloadingProgressionという値で取得できるので、それを利用して進捗を表示できるようにする。

まずは<AppLoading/>コンポーネントを以下のように修正する。

AppLoading.tsx
- import { Box, CircularProgress, SxProps, Theme } from "@mui/material";
+ 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 }: AppLoadingProps) {
+ 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" />
+       <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>
    );
  }

この修正によって、loadingProgressionをパーセントで枠内中央に表示できるようになった。

あとはこれを<UnityApp/>コンポーネント側からloadingProgressionを与えるようにすれば良い。

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, requestFullscreen } = useUnityContext({
+   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>
    );
  }

Discussion