ComposableでComponentを返す方法でUnityのWebGLをコンポーネント化する

BoskampiによるPixabayからの画像 NuxtJS
BoskampiによるPixabayからの画像
この記事は約17分で読めます。

Composableの中でComponentを返せたらと考えていたところ、まさにそのアプローチがあった!と感動したので、その方法を採用してUnityのWebGLを返すComposableを作成してみました。

環境

VueUseの利用

VueUseのcreateInjectionStateを使いたいため、まずは使い方を見ておきたいと思います。

こちらは、provideとinjectをcomposableの仕組みで使いやすくしたようなものになります。createInjectionStateを呼び出すと、provideとinjectをラップした2つのglobal state関数が返されます。そして、createInjectionStateを呼び出す際にprovideするstateを引数として渡します。

例えば、使用例は以下のようになります。今回はあまり意味がないのですが、配列に格納した文字列をカットして返すstateを作成してみます。

import { computed, ref, Ref } from "vue-demi";
import { createInjectionState } from "@vueuse/shared";

const [useProvideContentStore, useContentStore] = createInjectionState(
  (initialValue: string, limit: number = 80) => {
    // state
    const contents = ref<string[]>([]);
    contents.value.push(initialValue);

    // getters
    const summaries = computed(() =>
      contents.value.map((c) =>
        c.length <= limit ? c : c.substring(0, limit) + "...",
      ),
    );

    function addContent(body: string) {
      contents.value.push(body);
    }

    return { contents: readonly(contents), addContent, summaries };
  },
);

export { useProvideContentStore, useContentStore };

この使い方は以下のようになります。まず、provide側。

<script lang="ts" setup>
useProvideContentStore("テスト文字列です、テスト文字列です", 20);
</script>

<template>
  <div>
    <ContentSummaryChild />
  </div>
</template>

続いてinject側。

<script lang="ts" setup>
const { summaries, addContent } = useContentStore();

const append = () => {
  addContent(
    "テスト文字列です" +
      [Number(Math.random() * 10)].reduce(
        (c, i) => i + c,
        "これはテストなのです",
      ),
  );
};
</script>

<template>
  <div>
    <button type="button" @click="append">文字列追加</button>
    <ul v-for="c in summaries">
      <li>{{ c }}</li>
    </ul>
  </div>
</template>

実行すると以下のようになります。

VueUse: https://vueuse.org/

useUnityを書いてみる

useUnityでは以下のような方針で書いてみたいと思います。

  • ComponentとComponent内の外部スクリプトのロード完了状態(UnityInstance)を統合的に利用可能にする
  • provideとinjectでUnityInstanceにアクセスできるようにする
  • provideしたインスタンスで透過的にinjectができる
  • 画面のリサイズでcanvasをリサイズする

全体像は以下になります。

import { useScriptTag, useResizeObserver } from "@vueuse/core";
import defu from "defu";
import { type InjectionKey, inject, provide } from "vue-demi";

/**
 * This implementatio get hints from vueuse
 * @see https://vueuse.org/createInjectionState
 * License: https://github.com/vueuse/vueuse/blob/main/LICENSE
 */
function createInjectionState<Arguments extends Array<any>, Return>(
  composable: (...args: Arguments) => Return,
): readonly [
  useProvidingState: (...args: Arguments) => void,
  useInjectedState: () => Return | undefined,
] {
  const key: string | InjectionKey<Return> = Symbol("UnityInjectionState");
  const useProvidingState = (...args: Arguments) => {
    provide(key, composable(...args));
  };
  // If called from same vm, jnject with self
  const useInjectedState = () => injectWithSelf(key);
  return [useProvidingState, useInjectedState];
}

// Define composable refs and UnityComponent
const [useProvideUnityStore, useUnityStore] = createInjectionState(
  (initialValue?: any, callback?: (unityInstance: any) => void) => {
    const unity = ref<any>(initialValue);
    const loaded = ref(false);
    const loading = ref(false);
    const error = ref<Error>();
    const containerId = useState("unityContainerId", () =>
      Number(Math.random().toString().slice(3)).toString(36),
    );

    function SendMessage(gameObject: string, method: string, param?: any) {
      if (!loaded.value) {
        return;
      }

      if (param) {
        unity.value?.SendMessage(gameObject, method, param);
      } else {
        unity.value?.SendMessage(gameObject, method);
      }
    }

    function done(unityInstance: any) {
      if (loaded.value) {
        return;
      }
      unity.value = unityInstance;
      loaded.value = true;
      loading.value = false;

      if (callback) {
        callback(unityInstance);
      }
    }

    //
    // Define UnityComponent which is available in sfc's template.
    //
    const UnityComponent = defineComponent({
      name: "UnityContainer",
      props: {
        width: {
          type: String,
          required: true,
        },
        height: {
          type: String,
          required: true,
        },
        unityLoader: {
          type: String,
          required: true,
        },
        onProgress: {
          type: Function,
          default: (progress: number) => {
            console.log(unity loading... ${progress * 100}%);
          },
        },
        config: {
          type: Object,
          default: null,
        },
        resizable: {
          type: Boolean,
          default: true,
        },
      },
      emits: ["loading", "loaded", "error"],
      setup(props, context) {
        const unityLoaderLoaded = ref(false);
        const canvasWidth = ref(props.width);
        const canvasHeight = ref(props.height);
        const container = ref<HTMLElement>();
        const canvas = ref<HTMLElement>();

        const config = defu(props.config, {
          dataUrl: "Build.data",
          frameworkUrl: "Build.framework.js",
          codeUrl: "Build.wasm",
          companyName: "DefaultCompany",
          productName: "UnityApp",
          productVersion: "0.1",
          showBanner: false,
        });

        function instantiate() {
          if (
            typeof window.createUnityInstance === "undefined" &&
            unityLoaderLoaded.value
          ) {
            const _error =
              "The UnityLoader was not defined, please add the script tag ";
            error.value = new Error(_error);
            context.emit("error", _error);
            return;
          }

          if (loading.value) {
            return;
          }

          context.emit("loading");
          loading.value = true;

          const { $logger } = useNuxtApp();
          const canvas = document.querySelector(
            #unity-canvas-${containerId.value},
          );
          window
            .createUnityInstance(canvas, config, props.onProgress)
            .then((unityInstance: any) => {
              done(unityInstance);
              context.emit("loaded");
            })
            .catch((message: any) => {
              $logger.error(message);
              error.value = new Error(message);
              context.emit("error");
            });
        }

        const { load } = useScriptTag(
          props.unityLoader,
          () => {
            unityLoaderLoaded.value = true;
            instantiate();
          },
          { manual: true },
        );

        onBeforeMount(() => {
          load();
        });

        onMounted(() => {
          useResizeObserver(container, (entries) => {
            if (!props.resizable) {
              return;
            }
            const entry = entries[0];
            const { clientWidth, clientHeight } = entry.target;
            canvasWidth.value = clientWidth + "px";
            canvasHeight.value = clientHeight + "px";
          });
        });

        return { canvasWidth, canvasHeight, container, canvas };
      },
      render() {
        return h(
          "div",
          {
            id: unity-container-${containerId.value},
            class: "unity-container",
            ref: "container",
          },
          [
            h(
              "canvas",
              {
                id: unity-canvas-${containerId.value},
                class: "unity-canvas",
                style: {
                  width: this.canvasWidth,
                  height: this.canvasHeight,
                  display: "block",
                },
                ref: "canvas",
              },
              [],
            ),
            h("div", { class: "unity-extra-content" }, this.$slots.default?.()),
          ],
        );
      },
    });

    return { unity, loaded, SendMessage, containerId, UnityComponent };
  },
);

export { useProvideUnityStore, useUnityStore };

export function useUnityStoreOrThrow() {
  const store = useUnityStore();
  if (store == null)
    throw new Error(
      "Please call useProvideCounterStore on the appropriate parent component",
    );
  return store;
}

// Uses same component provide as its own injections
// Due to changes in https://github.com/vuejs/vue-next/pull/2424
// @see https://github.com/logaretm/vee-validate
// License: https://github.com/logaretm/vee-validate/blob/main/LICENSE
export function injectWithSelf<T>(
  symbol: InjectionKey<T>,
  def: T | undefined = undefined,
): T | undefined {
  const vm = getCurrentInstance() as any;

  return vm?.provides[symbol as any] || inject(symbol, def);
}

続いて利用側です。

<script lang="ts" setup>
useProvideUnityStore();
const { UnityComponent, loaded } = useUnityStoreOrThrow();
</script>

<template>
  <div class="container">
    <UnityComponent
      width="1000px"
      height="600px"
      unity-loader="/unity/Build.loader.js"
      :config="{
        dataUrl: '/unity/Build.data',
        frameworkUrl: '/unity/Build.framework.js',
        codeUrl: '/unity/Build.wasm',
      }"
      :resizable="false"
    >
      {{ loaded }}
    </UnityComponent>
  </div>
</template>

<style lang="scss">
html,
body {
  width: 100%;
  height: 100vh;
  margin: 0;
  padding: 0;
  overflow: hidden;
}

#__nuxt,
.container,
.unity-container {
  width: 100%;
  height: 100%;
}
</style>

以下補足説明です。

vueuseのcreateInjectionStateについて、ここではinjectの呼び出し部分を少し修正しています(ライセンスMIT:https://github.com/vueuse/vueuse/blob/main/LICENSE)。目的はprovideを呼び出したコンポーネント内でinjectしたstateやComponentを使うためです。ここではUnityComponentを指します。

一番下のinjectWithSelfはvee-validateの実装で使われているものです。(ライセンスMIT:https://github.com/logaretm/vee-validate/blob/main/LICENSE)。provideのコンポーネント内でもinjectを透過的に使用できるようにするためです。

今回は、以下の部分がなるほど!と思ったところです。UnityComponentを定義するところになります。

const UnityComponent = defineComponent({
  name: "UnityContainer",
  //  ...省略
})

defineComponentでコンポーネントを定義しています。template部分は、今回はrender呼び出しを使う方法で試してみました。このUnityComponentは、templateの中で通常のComponentと同じように呼び出せます。

Unity WebGLのcanvasをリサイズ可能にするためにuseResizeObserverでcanvasのラッパータグのリサイズをウォッチします。変更があればcanvasのwidthとheightを書き換えます。

loadedでUnityLoaderからのUnityInstance初期化の完了を受け取れます。

ComponentとComponentのロード完了(UnityInstance)状態(loaded)を統合的に扱えることで、分かりやすい実装にできるのではと思います。

参考リンク

 

2023.4.8

npmモジュール化しました。

Just a moment...

コメント

タイトルとURLをコピーしました