Babylon.jsをC#で動かす構想をする
- Babylon.jsユーザはUnityユーザであることが多い
- C#で書けるようにならないか(①)
- BlazorのrazorコンポーネントでR3F的なことができないか
そういうことを妄想していた
参考になりそうなリンクをぶら下げる
アイデアとしては、.NET Conf Tokyo 2022のちゅうこさんのセッションから
いきなりモチベが湧いてきたので、これを進める
このセッションを見た
前半はBlazor wasmのデバッグの話だったりするけど、
こうはんはInterpolationの話が出てきた
とりまworkload入れてdotnet newでプロジェクト作ってみようかな
それで実際に見て見る
気になるのは、例えばBabylon.jsをC#で書けたとして
その操作性をどういう風に提供するかかな
一番自然なのはBlazor用ライブラリにすることな気がする
wasm-experimentalというworkloadをインストールしてみた
❯ dotnet workload install wasm-experimental
マシンの再起動が保留中です。ワークロード操作は続行されますが、再起動が必要になる場合があります。
Downloading microsoft.net.sdk.android.manifest-8.0.100.msi.x64 (34.0.52)
microsoft.net.sdk.android.manifest-8.0.100.msi.x64 をインストールしています ...... Done
Downloading microsoft.net.sdk.ios.manifest-8.0.100.msi.x64 (17.0.8490)
microsoft.net.sdk.ios.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.sdk.maccatalyst.manifest-8.0.100.msi.x64 (17.0.8490)
microsoft.net.sdk.maccatalyst.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.sdk.macos.manifest-8.0.100.msi.x64 (14.0.8490)
microsoft.net.sdk.macos.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.sdk.maui.manifest-8.0.100.msi.x64 (8.0.3)
microsoft.net.sdk.maui.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.sdk.tvos.manifest-8.0.100.msi.x64 (17.0.8490)
microsoft.net.sdk.tvos.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.workload.mono.toolchain.current.manifest-8.0.100.msi.x64 (8.0.1)
microsoft.net.workload.mono.toolchain.current.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.workload.emscripten.current.manifest-8.0.100.msi.x64 (8.0.1)
microsoft.net.workload.emscripten.current.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.workload.emscripten.net6.manifest-8.0.100.msi.x64 (8.0.1)
microsoft.net.workload.emscripten.net6.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.workload.emscripten.net7.manifest-8.0.100.msi.x64 (8.0.1)
microsoft.net.workload.emscripten.net7.manifest-8.0.100.msi.x64 をインストールしています .... Done
Downloading microsoft.net.workload.mono.toolchain.net6.manifest-8.0.100.msi.x64 (8.0.1)
microsoft.net.workload.mono.toolchain.net6.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.workload.mono.toolchain.net7.manifest-8.0.100.msi.x64 (8.0.1)
microsoft.net.workload.mono.toolchain.net7.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading microsoft.net.sdk.aspire.manifest-8.0.100.msi.x64 (8.0.0-preview.2.23619.3)
microsoft.net.sdk.aspire.manifest-8.0.100.msi.x64 をインストールしています ..... Done
Downloading Microsoft.NET.Runtime.WebAssembly.Templates.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.WebAssembly.Templates.Msi.x64 をインストールしています .... Done
Downloading Microsoft.NETCore.App.Runtime.Mono.multithread.browser-wasm.Msi.x64 (8.0.1)
Microsoft.NETCore.App.Runtime.Mono.multithread.browser-wasm.Msi.x64 をインストールしています ........ Done
Downloading Microsoft.NET.Runtime.WebAssembly.Sdk.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.WebAssembly.Sdk.Msi.x64 をインストールしています ...... Done
Downloading Microsoft.NETCore.App.Runtime.Mono.browser-wasm.Msi.x64 (8.0.1)
Microsoft.NETCore.App.Runtime.Mono.browser-wasm.Msi.x64 をインストールしています ......... Done
Downloading Microsoft.NETCore.App.Runtime.AOT.win-x64.Cross.browser-wasm.Msi.x64 (8.0.1)
Microsoft.NETCore.App.Runtime.AOT.win-x64.Cross.browser-wasm.Msi.x64 をインストールしています ..... Done
Downloading Microsoft.NET.Runtime.MonoAOTCompiler.Task.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.MonoAOTCompiler.Task.Msi.x64 をインストールしています ..... Done
Downloading Microsoft.NET.Runtime.MonoTargets.Sdk.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.MonoTargets.Sdk.Msi.x64 をインストールしています ..... Done
Downloading Microsoft.NET.Runtime.Emscripten.3.1.34.Node.win-x64.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.Emscripten.3.1.34.Node.win-x64.Msi.x64 をインストールしています ..... Done
Downloading Microsoft.NET.Runtime.Emscripten.3.1.34.Python.win-x64.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.Emscripten.3.1.34.Python.win-x64.Msi.x64 をインストールしています ............. Done
Downloading Microsoft.NET.Runtime.Emscripten.3.1.34.Cache.win-x64.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.Emscripten.3.1.34.Cache.win-x64.Msi.x64 をインストールしています ................................................................................................................... Done
Downloading Microsoft.NET.Runtime.Emscripten.3.1.34.Sdk.win-x64.Msi.x64 (8.0.1)
Microsoft.NET.Runtime.Emscripten.3.1.34.Sdk.win-x64.Msi.x64 をインストールしています ................................................................................................................................................................................... Done
ワークロード wasm-experimental が正常にインストールされました。
すると、dotnet new で生成できるテンプレートが増えた
❯ dotnet new list wasm
これらのテンプレートは、入力: 'wasm' と一致しました
テンプレート名 短い名前 言語 タグ
--------------------------------- ---------------- ---- --------------------------------
Blazor WebAssembly アプリ blazorwasm [C#] Web/Blazor/WebAssembly/PWA
Blazor WebAssembly アプリが空です blazorwasm-empty [C#] Web/Blazor/WebAssembly/PWA/Empty
WebAssembly Browser App wasmbrowser [C#] Web/WebAssembly/Browser
WebAssembly Console App wasmconsole [C#] Web/WebAssembly/Console
とりまdotnet new wasmbrowser
コマンドでプロジェクトを作成
その中のファイル構造は以下
/
├─ bin/
├─ wwwroot/
│ ├─ index.html
│ └─ main.js
└─ Program.cs
index.htmlは至極シンプルで何の変哲もなさそう
<!DOCTYPE html>
<!-- Licensed to the .NET Foundation under one or more agreements. -->
<!-- The .NET Foundation licenses this file to you under the MIT license. -->
<html>
<head>
<title>wasm-browser-testbed</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type='module' src="./main.js"></script>
</head>
<body>
<span id="out"></span>
</body>
</html>
main.jsはJSのエントリポイントで、"./framework/dotnet.js"を読み込んでいる
それによってJS Interopを実現しているみたい
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
import { dotnet } from "./_framework/dotnet.js";
const { setModuleImports, getAssemblyExports, getConfig } = await dotnet
.withDiagnosticTracing(false)
.withApplicationArgumentsFromQuery()
.create();
setModuleImports("main.js", {
window: {
location: {
href: () => globalThis.window.location.href,
},
},
});
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const text = exports.MyClass.Greeting();
console.log(config);
await exports.TestClass.GetIntAsync().then((v) => console.log(v));
document.getElementById("out").innerHTML = text;
await dotnet.run();
Program.csも
アレンジしています
using System;
using System.Runtime.InteropServices.JavaScript;
Console.WriteLine("Hello, Browser!");
public partial class MyClass
{
[JSExport]
internal static string Greeting()
{
var text = $"Hello, World~~ {GetHRef()}";
return text;
}
[JSImport("window.location.href", "main.js")]
internal static partial string GetHRef();
}
public partial class OtherClass
{
[JSExport]
public static int Add(int a, int b)
{
return a + b;
}
}
ここが参考になりそうだ
dotnet.jsの元のソースコードがあったかも
npmパケも
C#のいんたーふぇーす、こんな感じかなぁ
using System;
using BabylonCs;
var renderCanvas = Document.getRenderCanvas();
var engine = new Engine(renderCanvas, true);
var scene = new Scene(engine);
scene.CreateDefaultCameraOrLight(true, true, true);
var box = MeshBuilder.CreateBox("box", new BoxOption { size = 0.1f }, scene);
Window.OnResize(() => engine.Resize());
engine.RunRenderLoop(() => scene.Render());
今理解したい事項は次の通りかな
- 構造的なデータをどうマーシャリングするのが良いのか
- 主にC#の中で?
- JS側でもC#オブジェクトのマーシャリングが必要かもしれない
- C#でJSType.Anyで受け取った値をAnyでJS側に戻しても、そのデータは同一のものなのか
- 例えばEngineオブジェクトとかSceneオブジェクトとか?
- 別にC#側で詳細にマーシャリングしなくてもいいデータ
例えば下記のような簡単なオブジェクトをJSで用意する
export class Person {
constructor(name, age, isChild) {
this.name = name;
this.age = age;
this.isChile = isChild;
}
}
そしてこれを生成する関数をJSImportする
setModuleImports('main.js', {
util: {
fun: (val) => val + 1,
getPerson: (name, age, isChild) => new Person(name, age, isChild)
}
});
この関数で生成されるPersonを受け取るためのC#実装を作る
public partial class Util
{
[JSImport("util.fun", "main.js")]
public static partial int GetInt(int val);
[JSImport("util.getPerson", "main.js")]
public static partial JSObject GetPerson(string name, int age, bool isChild);
}
ポイントは、JSObjectなる型で受け取ること。これは属性情報による型指定が必要ない。
そしてJSObjectからプリミティブ型のフィールド値を取得できる
var person = Util.GetPerson("Alice", 18, false);
Console.WriteLine(person.GetPropertyAsInt32("age"));
こんな感じにすればJSのオブジェクトをJSObjectにマーシャリングしつつ、いい感じにフィールドも取得できるっぽいな
ところで、JSオブジェクトからC#クラスへの変換はどうすればよいか
いい感じにキャストできるかなと思い試してみた
まず、全く同じ関数を作tu
setModuleImports('main.js', {
util: {
fun: (val) => val + 1,
getPerson: (name, age, isChild) => new Person(name, age, isChild),
getPerson2: (name, age, isChild) => new Person(name, age, isChild)
}
});
それをC#から呼び出し、今度はJSTipe.Anyで受けてみる
[JSImport("util.getPerson2", "main.js")]
[return: JSMarshalAs<JSType.Any>]
public static partial object GetPerson2(string name, int age, bool isChild);
これから得られたobjectをキャストしてみる
public static PersonAlt TryGetPerson2(string name, int age, bool isChild)
{
var personObj = GetPerson2(name, age, isChild);
return (PersonAlt)personObj;
}
また、PersonAltは次のような定義
public PersonAlt(string name, int age, bool isChild)
{
this.name = name;
this.age = age;
this.isChild = isChild;
}
すると、キャストが失敗した模様
今のところJSObjectとしてマーシャリングするのが良さそうだな
構造的データをJS→C#で受け取る方法については何となくわかったので
次は複雑なデータをJSでどうつくるか、できればTSを使ってどうやって作るかを考えたい
パッと思いついた方法としては、
JS用のコードというかTSコードをwwwrootとは別もしくは同一ディレクトリに
library用のTSプロジェクトを作成して、d.ts付きでビルドしたものをmain.jsから参照する形なら良かったりしないかな
プロジェクト構成の案
/
├─ BabylonCs/
├─ wwwroot
│ ├─ index.html
│ └─ main.js
├─ BabylonCs.JS/
│ ├─ lib/
│ ├─ src/
│ ├─ vite.config.ts
│ └─ package.json
├─ Program.cs
└─ xxx.csproj
結論、上記の構成でうまくいきそうな雰囲気がある
まずは簡単なロジックをVite libraryプロジェクトで作成し、npmパッケージを作るような感じで型定義ファイルを含めてビルドするように設定する
rollup-plugin-copyを用いて、ビルド成果物をwwwrootへビルド時にコピーする
するとwwwroot/main.js
で使用できるようになる
BabylonCs.JSでは次のようなPersonクラスにまつわるクラスを用意
export class Person {
public name: string;
public age: number;
public isChild: boolean;
public constructor(name: string, age: number, isChild: boolean) {
this.name = name;
this.age = age;
this.isChild = isChild;
}
}
/**
* Person factory
* @param name name
* @returns Person Object
*/
export const getPerson = (name: string) => new Person(name, 18, false);
/**
* persont to string
* @param person person object
* @returns print string
*/
export const printPerson = (person: Person): string =>
`{name: ${person.name}, age: ${person.age}, child: ${person.isChild}}`;
/**
* log message to p element
* @param msg log message
*/
export const logInfo = (msg: string): void => {
const logElm = document.querySelector<HTMLParagraphElement>("p#msg");
if (!logElm) {
return;
}
logElm.textContent = msg;
};
ここでキーポイントなのは、PersonのようなJS構造的データを出力する関数と入力する関数があること
これらを順番に実行することにより、JSの構造的データが保存されるのかを確かめる
logInfo
関数は、さすがに毎回コンソール見て確認するのがめんどいなと思ったので
p要素に立強いてログを出力できるようにした関数
ちなみにmain/jsではいい感じにこれらの関数を出力してみa
import { getPerson, printPerson, logInfo } from "./BabylonCs/index.js";
setModuleImports("main.js", {
util: {
getPerson: (name) => getPerson(name),
printPerson: (person) => printPerson(person),
logInfo: (msg) => logInfo(msg),
},
});
C#側はこんな感じ
#nullable enable
using System.Runtime.InteropServices.JavaScript;
public partial class Util
{
[JSImport("util.getPerson", "main.js")]
public static partial JSObject GetPerson(string name);
[JSImport("util.printPerson", "main.js")]
public static partial string PrintPerson(JSObject person);
[JSImport("util.logInfo", "main.js")]
public static partial void LogInfo(string msg);
}
つまりここまでで、Vite library + TSを使ったライブラリ開発や
値の受け渡しについてある程度理解ができた
試したいこと残タスク
- JSの関数で受け取った引数の内部状態を変化させたとき、引数を渡したほうのC#内のJSObjectの内部状態も一緒に更新されるのか
- これができればC#クラス内のJSObjectを更新する必要はなくなる
- setModuleImports関数をTS側で呼べるか
- js interop用のnpmパッケージがあるので、それを使えばできるかもしれない
JSのオブジェクト型であれば関数中で内部状態を変更した場合に元のオブジェクトの内部状態も変更される
> const a = {msg:"hello"}
undefined
> a
{ msg: 'hello' }
> const hoge = (obj)=>obj.msg="hi"
undefined
> hoge(a)
'hi'
> a
{ msg: 'hi' }
例えばMeshオブジェクトのpositionを変更したい場合、JS側では以下のような関数を登録しておくことになる
const setMeshPosition = (mesh:Mesh, pos:Vector3): void => {
mesh.position = pos;
}
これをC#側でimportする
public JSObject jsObject => _jsObject;
[JSImport]
public static partial setMeshPosition(JSObject mesh, JSObject pos);
setMeshPosition(this.jsObject, pos);
これを呼ぶことで以下のことができていると嬉しい
- C#内でJSObjectの内部状態、ここでいうpositionが変わっている
- Babylon.jsシーン内でMeshの位置が変わっている
簡単なオブジェクトで検証してみるか
と言ってもこれをイチイチ実装するのめんどくさいな……最終的にはコード生成したいところ
今気づいたこと:
rollup-plugin-copyはビルドする前にファイルをコピーするプラグインなので
普通に実行するとビルドされる前の状態がコピーされることになる
まぁ2回ビルドコマンド叩けばいいんだけどね……さすがに不毛なのでなんとかしたい
ファイルコピーのロジックを自分で書いて、Pluginとしてビルド後に実行するようにすれば解決するそうな
あとでGitHub Projectsを作ってタスクをissue管理しようかな
無事、JS側でオブジェクトの内部状態を変更したらC#側のJSObjectの内部状態も同期して変更されることを確認した
下記のコードを実行すると、名前がAliceからBobへ変更されて出力された
using System;
var jsPerson = Util.GetPerson("Alice");
Util.LogInfo(Util.PrintPerson(jsPerson));
Util.ChangeName(jsPerson, "Bob");
Util.LogInfo(Util.PrintPerson(jsPerson));
Console.WriteLine(jsPerson.GetPropertyAsString("name"));
残タスク
- setModuleImports関数をパッケージ内から呼び出す処理
- 例えばBabylonCs.JSで
setupBabylonCs()
という関数を提供して、その関数でsetModuleImports関数を受け取って実行してもいいかも
- 例えばBabylonCs.JSで
- Personクラスで本番の環境に近いクラス構成を実現してみる
- IPerson、Person、PersonImpl、など
- TS側のビルド時にコピーする処理を確立する
- GitHub Projects作る
本日の後半は、TSからC#のコード生成ができないか調査していた
なんかそれっぽいのあるなぁと思って探していたら、なんとBlazorでBabylon.jsを使う先駆者様が!
そういえば、こちらに全然進捗を投稿できていなかった
というのもタスク管理をこちらではじめたので
結構いい感じに色々実装できてきて、そろそろ本番用のプロジェクトを作成しようかなとなっている
本番用のプロジェクト構成は以下がいいかなぁ
/
├─ BabylonCS/
│ ├─ BabylonCS/
│ │ ├─ Engine.cs
│ │ ├─ Scene.cs
│ │ └─ BabylonCS.csproj
│ ├─ Example/
│ │ ├─ wwwroot/
│ │ │ ├─ BabylonCS/
│ │ │ │ ├─ index.js
│ │ │ │ ├─ index.d.ts
│ │ │ │ └─ engine.d.ts
│ │ │ ├─ main.js
│ │ │ └─ index.html
│ │ ├─ Program.cs
│ │ └─ Example.csproj
│ └─ BabylonCS.sln
├─ BabylonCS.js/
│ ├─ lib/
│ │ ├─ canvasUtil.ts
│ │ ├─ engine.ts
│ │ ├─ scene.ts
│ │ └─ index.ts
│ ├─ src/
│ │ ├─ main.ts
│ │ └─ style.css
│ ├─ index.html
│ └─ vite.config.ts
└─ README.md
そしてとうとう本番用リポジトリを作成した :tada:
JS側実装にBabylon.jsを導入して、最低限のシーンを構築できるくらいのAPIをModuleImportに渡してみた
BabylonCS.JSの中でpnpm devすると、そのAPIを使ってCubeが出るところまでを確認できた
次はこのissue
やはりC#側のHTMLなどにもcanvasが無いと始まらないのでね
とりま、C#のPlaygroundだけでBabylon.jsを動かせた
最後、これをつなぐAPIを設計してPlaygroundで動かせば勝つる......!!
C#部分も実装し、ついにBabylon.jsがC#で動いた!!!!
正真正銘C#のコードで動いている。。。
using System;
using BabylonCS;
if (!Document.TryGetRenderCanvas("renderCanvas", out var renderCanvas))
{
Console.Error.WriteLine("canvas not found");
return;
}
var engine = new Engine(renderCanvas, true);
var scene = new Scene(engine);
scene.CreateDefaultCameraOrLight(true, true, true);
MeshBuilder.CreateBox("box", 0.2f);
engine.SetupResize();
engine.RunRenderLoop(() => scene.Render());
NuGetパッケージ化をやって行きたさがあるので調べる
ここ、もう一回読むと良さそうだな
いったん、動くところと登壇までできたのでクローズしようかな
そして登壇アーカイブを個人チャンネルで公開しました