はじめに
Twitter
を眺めていたところ面白そうなライブラリを見つけました。
https://t.co/WDCxtukXWW
— su10@ハイパーカジュアルゲーム開発 (@su10_dev) May 10, 2023
UnityなのにC#じゃなくてTypeScriptを使って開発できるらしい。UIもReactで作れるぽい。すげー
UnityなのにTypeScript
でコードを書くことができ、(P)React
でUI
を記述することができるという耳を疑う話です。
ということで本当なのか確かめるために触ってみました。
概要
OneJS
はTypeScript
や(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の相互運用が可能で、ブラウザやウェブビューの埋め込みを必要としません。
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)React
をUI 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機能へのアクセスを簡素化することができます。
対応プラットフォーム
- 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)
実験環境
Unity 2023.1.0b7
その前に
Live Reload
を有効にするためにはPlayer Settings
のResolution and Presentation/Resolution/Run In Backgroud
にチェックを入れる必要があります。
インストール方法
Asset Store
からOneJS
を検索してダウンロードしてください。
下準備
Assets/OneJS/ScriptEngine
をHierarhy
にドラッグ&ドロップPlay
ボタンを押す
コンソールに以下が表示されてれば準備はOKです。
Live Reload On (entry script: index.js) [index.js]: OneJS is good to go
以上の操作をすると、{ProjectDir}/OneJS
がセットアップされます。
TypeScript
を触ってことがある方なら見覚えがあるものもあると思いますが、以下のファイルが同梱されています。
tsconfig.json
.vscode/settings.json
index.js
ScriptLib
フォルダ (Javascript
のライブラリが入っている)Samples
フォルダ
チュートリアル
以下の状態であることを前提に進めていきます。
VSCode
がインストールされているTypeScript
コンパイラがインストールされている
VSCode
で{ProjectDir}/OneJS
を開いている状態で以下の操作をしていきます。
Command + Shift + B
を押して、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#
コードをゲームオブジェクトにアタッチ OneJS
のScript Engine
コンポーネントのINTEROP/Objects
に先ほどアタッチしたコンポーネントを指定し、名前をつけるTypeScript
でrequire("指定した名前")
でコンポーネントにアクセスできる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
に先ほどアタッチしたコンポーネントを指定して名前をつけてあげます。
このとき注意してほしいのは、ゲームオブジェクトを紐づけるのではなくコンポーネントを紐づけてください。やり方は何個かありますが、Hierarchy
より対象ゲームオブジェクトを右クリックして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の変更が他のロジックなどに影響を及ばさないように設計してください。
さいごに
まだOneJS
のチュートリアルが終わっただけですが、もっとしっかり使おうと思ったら身につけなければならないことも多いでしょう。
ただ公式の動画でFortnite
やOverwatch
のUI
を作ってみたというのもある通り、結構応用の幅も広く良いライブラリだと思います。
www.youtube.com
暇があればまた使い方の記事を書きたいと思います。