はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】TypeScriptを使ったコーディングや(P)Reactを使ったUI構築ができるOneJSの紹介と導入

はじめに

Twitterを眺めていたところ面白そうなライブラリを見つけました。

UnityなのにTypeScriptでコードを書くことができ、(P)ReactUIを記述することができるという耳を疑う話です。

ということで本当なのか確かめるために触ってみました。

実際に動作させている様子


概要

OneJSTypeScript(P)ReactといったWebで用いられるような技術をUnityでも使えるようにしてくれるライブラリです。

OneJS brings the power of Typescript, (P)React, Live-Reload, and many other beloved web techs to Unity. It is lightweight, performant, works everywhere, has 1-to-1 interop with UI Toolkit, and does not require any browser/webview embedding.

OneJSは、Typescript、(P)React、Live-Reload、その他多くの愛されるウェブ技術のパワーをUnityにもたらします。軽量で、パフォーマンスが高く、どこでも動作し、UI Toolkitと1対1の相互運用が可能で、ブラウザやウェブビューの埋め込みを必要としません。

OneJS

Do Everything in Typescript
No more waiting for C# compilation and Domain Reload. You can access the entirety of UnityEngine or any .Net assemblies in Typescript.

すべてをTypescriptで行う
C#のコンパイルやDomain Reloadを待つ必要はもうありません。TypescriptでUnityEngineの全体や.Netのアセンブリにアクセスすることができます。

原理的にはランタイムでJintを用いて実行していて、(P)ReactUI Toolkitに適応?している感じらしいです。

OneJS directly integrates with Unity's UI Toolkit, avoiding the need for a browser and simplifying access to UI Toolkit's DOM features.

OneJSはUnityのUI Toolkitと直接統合されているため、ブラウザを必要とせず、UI ToolkitのDOM機能へのアクセスを簡素化することができます。

GitHub - sebastienros/jint: Javascript Interpreter for .NET

対応プラットフォーム

  • Windows
  • Mac
  • iOS
  • Android
  • Editor
  • Standalone
  • Mono
  • Il2cpp

必要環境

  • Unity.Mathematics
  • Unity Version 2021.3+ (for stable UI Toolkit)
  • Unity Version 2022.1+ (if you need to use UI Toolkit's Vector API)

Getting Started - OneJS

実験環境

Unity 2023.1.0b7

その前に

Live Reloadを有効にするためにはPlayer SettingsResolution and Presentation/Resolution/Run In Backgroudにチェックを入れる必要があります。

Run in BackgroundをONにする

インストール方法

Asset StoreからOneJSを検索してダウンロードしてください。

下準備

  • Assets/OneJS/ScriptEngineHierarhyにドラッグ&ドロップ
  • Playボタンを押す

コンソールに以下が表示されてれば準備はOKです。

Live Reload On (entry script: index.js)
[index.js]: OneJS is good to go

以上の操作をすると、{ProjectDir}/OneJSがセットアップされます。

OneJSの作業ディレクトリ

TypeScriptを触ってことがある方なら見覚えがあるものもあると思いますが、以下のファイルが同梱されています。

  • tsconfig.json
  • .vscode/settings.json
  • index.js
  • ScriptLibフォルダ (Javascriptのライブラリが入っている)
  • Samplesフォルダ

チュートリアル

以下の状態であることを前提に進めていきます。

  • VSCodeがインストールされている
  • TypeScriptコンパイラがインストールされている

VSCode{ProjectDir}/OneJSを開いている状態で以下の操作をしていきます。

VSCodeで{ProjectDir}/OneJSを開く

Command + Shift + Bを押して、tsc: ウォッチ - tsconfig.jsonを選択します。

tsc: ウォッチ - tsconfig.json

これをすることでTypeScript (.ts)ファイルの変更を監視して自動的にtsc(トランスパイル)を実行してくれるようになります。

最初のコード

簡単に球と平面を用意して描画をしてみるスクリプト(index.tsx)を書いてみます。

// ファイル内の関数や変数等のインポート
import { Collider, PhysicMaterial, Physics, Camera, GameObject, MeshRenderer, PrimitiveType, Rigidbody, Vector3 } from "UnityEngine"
import { parseColor } from "onejs/utils/color-parser"

// カメラの設定を行う
Camera.main.transform.position = new Vector3(8, 4, -8)
Camera.main.transform.LookAt(new Vector3(0, 1, 0))

// GameObjectの生成,MeshRendererコンポーネントの色変更
const plane = GameObject.CreatePrimitive(PrimitiveType.Plane)
plane.GetComponent(MeshRenderer).material.color = parseColor("DarkGreen")

const sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere)
sphere.GetComponent(MeshRenderer).material.color = parseColor("FireBrick")
sphere.transform.position = new Vector3(0, 5, 0)

// 重力設定,Rigidbodyをアタッチ,PhysicMaterialを指定
Physics.gravity = new Vector3(0, -20, 0)
let rb = sphere.AddComponent(Rigidbody)
let pm = new PhysicMaterial()
pm.bounciness = 0.8
sphere.GetComponent(Collider).material = pm
plane.GetComponent(Collider).material = pm
動作している様子

UIを作る

PReactの要領で書いていきます。

import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"

const RadialProgress = () => {
    // DOMへの参照を取得
    const ref = useRef<Dom>();

    // useEffectは画面がレンダリングされた後に実行される
    // レンダリング前に何か処理を行いたい場合はuseLayoutEffectを利用する
    useEffect(() => {
        // ref.currentはDOMを返す
        // ref.current.veはVisualElement (UI ToolkitのHTMLElementに相当)を返す
        // VisualElementのgenerateVisualContentプロパティに、再描画する必要があるたびに実行するコールバックを設定する
        ref.current.ve.generateVisualContent = onGenerateVisualContent
    }, [])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        var painter2D = mgc.painter2D

        // ref.current.ve.resolvedStyleを用いることで、VisualElementのレンダリングスタイル/寸法を取得できます
        const resolvedStyle = ref.current.ve.resolvedStyle
        let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
        let dx = resolvedStyle.width / 2 - radius
        let dy = resolvedStyle.height / 2 - radius

        // 描画ロジックは Canvas APIに似ている
        // 参照 : https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
        painter2D.strokeColor = parseColor("#305fbc")
        painter2D.lineWidth = radius * 0.2
        painter2D.BeginPath()
        painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(360), ArcDirection.Clockwise)
        painter2D.Stroke()
        painter2D.ClosePath()
    }

    return <div ref={ref} class="w-full h-full"></div>
}

render(<RadialProgress />, document.body)

どのように描画をするか記述しているpainter2D周りについては、私自身までちゃんと理解できていないので、また後日触れられたらと思っています。

C#のイベントを受け取る

後述しますが、基本的にコアロジックはC#で記述し、TypeScriptではC#側の状態を参照してUIを変化させるという使い方になります。

その手法について大まかな流れは以下の通り。

  • プロパティとイベント(Onプロパティ名Changedという名前にする)を持つC#のコード作成
  • 生成したC#コードをゲームオブジェクトにアタッチ
  • OneJSScript EngineコンポーネントのINTEROP/Objectsに先ほどアタッチしたコンポーネントを指定し、名前をつける
  • TypeScriptrequire("指定した名前")でコンポーネントにアクセスできる
  • useEventfulState(obj, "プロパティ名")でプロパティとイベントを取得することができる
// ProgressManager.cs
using System;
using System.Threading.Tasks;
using UnityEngine;
using Random = UnityEngine.Random;

public class ProgressManager : MonoBehaviour
{
    public float Progress { get; private set; } = 1;

    // 「On[プロパティ名]Changed」というイベントも用意する
    public event Action<float> OnProgressChanged;

    // Unity新機能のAwaitable使ってますが、async/awaitと同じノリです
    private async Awaitable Start()
    {
        // waitTime間隔で、Progressを変更する
        while (true)
        {
            var waitTime = Random.Range(1, 5);
            await Awaitable.WaitForSecondsAsync(waitTime, destroyCancellationToken);   
            ChangeProgress();
        }
    }

    private void ChangeProgress()
    {
        // Progressを0.2未満だけ変更させる
        var p = Random.Range(0, 1f);
        while (Mathf.Abs(p - Progress) < 0.2f) {
            p = Random.Range(0, 1f);
        }
        Progress = p;
        OnProgressChanged?.Invoke(Progress);
    }
}

このコードをゲームオブジェクトにアタッチし、Script EngineコンポーネントのINTEROP/Objectsに先ほどアタッチしたコンポーネントを指定して名前をつけてあげます。

Script Engineコンポーネントで紐づける

このとき注意してほしいのは、ゲームオブジェクトを紐づけるのではなくコンポーネントを紐づけてください。やり方は何個かありますが、Hierarchyより対象ゲームオブジェクトを右クリックしてProperties...を選択するといけます。

Properties...を選択すると、ウィンドウが増える

最後にTypeScript側でコードを記述します。

import { useEventfulState } from "onejs"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"

const RadialProgress = ({ progress }: { progress: number }) => {
    // DOMへの参照を取得
    const ref = useRef<Dom>()

    // useEffectは画面がレンダリングされた後に実行される
    // レンダリング前に何か処理を行いたい場合はuseLayoutEffectを利用する
    useEffect(() => {
        // ref.currentはDOMを返す
        // ref.current.veはVisualElement (UI ToolkitのHTMLElementに相当)を返す
        // VisualElementのgenerateVisualContentプロパティに、再描画する必要があるたびに実行するコールバックを設定する
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        const resolvedStyle = ref.current.ve.resolvedStyle
        const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
        ref.current.style.fontSize = minSize * 0.3
    }, [])

    // progressが変更されるたびに、再描画を行う
    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        ref.current.ve.MarkDirtyRepaint()
    }, [progress])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        var painter2D = mgc.painter2D

        // ref.current.ve.resolvedStyleを用いることで、VisualElementのレンダリングスタイル/寸法を取得できます
        const resolvedStyle = ref.current.ve.resolvedStyle
        let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
        let dx = resolvedStyle.width / 2 - radius
        let dy = resolvedStyle.height / 2 - radius

        // 描画ロジックは Canvas APIに似ている
        // 参照 : https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
        painter2D.strokeColor = parseColor("#305fbc")
        painter2D.lineWidth = radius * 0.2
        painter2D.BeginPath()
        painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(progress * 360), ArcDirection.Clockwise)
        painter2D.Stroke()
        painter2D.ClosePath()
    }

    return <div ref={ref} class="w-full h-full flex justify-center items-center text-[#305fbc]">{Math.round(progress * 100)}</div>
}

const App = () => {
    // ProgressManagerにアクセスする
    const pman = require("pman")

    // Progressプロパティを受け取る
    const [progress, _] = useEventfulState(pman, "Progress")

    // propsとしてprogressをRadialProgressコンポーネントに渡す
    return <RadialProgress progress={progress} />
}

render(<App />, document.body)

肝となるのはAppコンポーネントの中に記述した以下の箇所ですかね。ここでC#側の状態を受け取っています。

// ProgressManagerにアクセスする
const pman = require("pman")

// Progressプロパティを受け取る
const [progress, _] = useEventfulState(pman, "Progress")

またprogressが更新されるたびに再描画が走るように以下のコードでしています。

// progressが変更されるたびに、再描画を行う
    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        ref.current.ve.MarkDirtyRepaint()
    }, [progress])

OneJSのtween.jsライブラリを利用する

tween.jsライブラリを入れることで、値をスムーズに変化させることができます。レンダリングが行われても状態を保存しないといけないのでuseRefを利用して対応します。

import { useEventfulState } from "onejs"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Easing, Tween, update } from "tweenjs"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"

const RadialProgress = ({ progress }: { progress: number }) => {
    // DOMへの参照を取得
    const ref = useRef<Dom>()
    const labelRef = useRef<Dom>()

    // tweenの現在値(中間値)を記録しておく, useRefだとレンダリングを跨いで保存される
    const prev = useRef(progress)

    // useEffectは画面がレンダリングされた後に実行される
    // レンダリング前に何か処理を行いたい場合はuseLayoutEffectを利用する
    useEffect(() => {
        // ref.currentはDOMを返す
        // ref.current.veはVisualElement (UI ToolkitのHTMLElementに相当)を返す
        // VisualElementのgenerateVisualContentプロパティに、再描画する必要があるたびに実行するコールバックを設定する
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        const resolvedStyle = ref.current.ve.resolvedStyle
        const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
        ref.current.style.fontSize = minSize * 0.3
    }, [])

    // progressが変更されるたびに、再描画を行う
    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        ref.current.ve.MarkDirtyRepaint()
     
        new Tween(prev).to({ current: progress }, 300)
            .easing(Easing.Quadratic.InOut).onUpdate(() => {
                ref.current.ve.MarkDirtyRepaint()
                labelRef.current.ve.text = Math.round(prev.current * 100)
            }).start()
     }, [progress])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        var painter2D = mgc.painter2D

        // ref.current.ve.resolvedStyleを用いることで、VisualElementのレンダリングスタイル/寸法を取得できます
        const resolvedStyle = ref.current.ve.resolvedStyle
        let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
        let dx = resolvedStyle.width / 2 - radius
        let dy = resolvedStyle.height / 2 - radius

        // 描画ロジックは Canvas APIに似ている
        // 参照 : https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
        painter2D.strokeColor = parseColor("#305fbc")
        painter2D.lineWidth = radius * 0.2
        painter2D.BeginPath()
        painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(prev.current * 360), ArcDirection.Clockwise)
        painter2D.Stroke()
        painter2D.ClosePath()
    }

    return <div ref={ref} class="w-full h-full flex justify-center items-center text-[#305fbc]"><label ref={labelRef} /></div>
}

const App = () => {
    // ProgressManagerにアクセスする
    const pman = require("pman")

    // Progressプロパティを受け取る
    const [progress, _] = useEventfulState(pman, "Progress")

    // propsとしてprogressをRadialProgressコンポーネントに渡す
    return <RadialProgress progress={progress} />
}

render(<App />, document.body)

function animate(time) {
    requestAnimationFrame(animate)
    update(time)
}
requestAnimationFrame(animate)

詳細は公式ドキュメントをご覧ください。(筆者はここらへんで力尽きてしまいました...)
Tutorial 101 - OneJS

気をつけること

このライブラリはランタイムでJintを用いて実行しているため、実行速度はそんなに早くないです。ですのでUIだけ適応するといった使い方をしないとフレームレートが全く出ません。

Jint is a Javascript interpreter for .NET which can run on any modern .NET platform as it supports .NET Standard 2.0 and .NET 4.6.2 targets (and up).

GitHub - sebastienros/jint: Javascript Interpreter for .NET

また公式ドキュメントにも記載されていますが、UIの変更が他のロジックなどに影響を及ばさないように設計してください。

UIが他に依存する

Why OneJS - OneJS

さいごに

まだOneJSのチュートリアルが終わっただけですが、もっとしっかり使おうと思ったら身につけなければならないことも多いでしょう。

ただ公式の動画でFortniteOverwatchUIを作ってみたというのもある通り、結構応用の幅も広く良いライブラリだと思います。
www.youtube.com

暇があればまた使い方の記事を書きたいと思います。