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)を統合的に扱えることで、分かりやすい実装にできるのではと思います。
参考リンク
- https://engineering.linecorp.com/ja/blog/line-securities-frontend-3/
- https://zenn.dev/crayfisher_zari/articles/7946414921fe42
2023.4.8
npmモジュール化しました。


コメント