Closed71

Babylon.jsをC#で動かす構想をする

にー兄さんにー兄さん
  • Babylon.jsユーザはUnityユーザであることが多い
  • C#で書けるようにならないか(①)
  • BlazorのrazorコンポーネントでR3F的なことができないか

そういうことを妄想していた

にー兄さんにー兄さん

とりま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は至極シンプルで何の変哲もなさそう

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を実現しているみたい

main.js
// 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も
アレンジしています

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;
    }
}
にー兄さんにー兄さん

C#のいんたーふぇーす、こんな感じかなぁ

Program.cs
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で用意する

person.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#実装を作る

MyClass.cs
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からプリミティブ型のフィールド値を取得できる

Program.cs
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クラスにまつわるクラスを用意

BabylonCs.JS/index.ts
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

main.js
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#側はこんな感じ

Utils.cs
#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);
}
にー兄さんにー兄さん
Program.cs
var jsPerson = Util.GetPerson("Alice");
var prints = Util.PrintPerson(jsPerson);

Util.LogInfo(prints);
にー兄さんにー兄さん

つまりここまでで、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側では以下のような関数を登録しておくことになる

JS側
const setMeshPosition = (mesh:Mesh, pos:Vector3): void => {
  mesh.position = pos;
}

これをC#側でimportする

C#側
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としてビルド後に実行するようにすれば解決するそうな

https://github.com/vitejs/vite/discussions/9217

にー兄さんにー兄さん

無事、JS側でオブジェクトの内部状態を変更したらC#側のJSObjectの内部状態も同期して変更されることを確認した
下記のコードを実行すると、名前がAliceからBobへ変更されて出力された

Program.cs
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関数を受け取って実行してもいいかも
  • Personクラスで本番の環境に近いクラス構成を実現してみる
    • IPerson、Person、PersonImpl、など
  • TS側のビルド時にコピーする処理を確立する
  • GitHub Projects作る
にー兄さんにー兄さん

本日の後半は、TSからC#のコード生成ができないか調査していた
なんかそれっぽいのあるなぁと思って探していたら、なんとBlazorでBabylon.jsを使う先駆者様が!
https://github.com/canhorn/EventHorizon.Blazor.BabylonJS-poc

にー兄さんにー兄さん

おそらくこの人は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
にー兄さんにー兄さん

JS側実装にBabylon.jsを導入して、最低限のシーンを構築できるくらいのAPIをModuleImportに渡してみた

にー兄さんにー兄さん

BabylonCS.JSの中でpnpm devすると、そのAPIを使ってCubeが出るところまでを確認できた

にー兄さんにー兄さん

とりま、C#のPlaygroundだけでBabylon.jsを動かせた

最後、これをつなぐAPIを設計してPlaygroundで動かせば勝つる......!!

にー兄さんにー兄さん

C#部分も実装し、ついにBabylon.jsがC#で動いた!!!!

正真正銘C#のコードで動いている。。。

Program.cs
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());
このスクラップは3ヶ月前にクローズされました