Fresh上でBabylon.jsを動かす - Havokを使った物理シミュレーションを非ブラウザ環境で

Babylon.jsを、Freshで動かしたいということを少し前から考えていた。
以前トライした時には、Preactとのつなぎ込みをうまくできずに断念した。

しかし、Fresh(Deno) で Bootstrap 5 をプラグインで使うを書き進めていたら、これを転用できると思い至った。

なのでやってみる。

この実装にあたり、DenoコミュニティとBabylon.jsコミュニティにいくつか質問を投げ込ませていただいた。感謝。

実装物はこちら。

github - Octo8080X/fresh-babylon-physics

最終的には、以下のような画面が完成します。

とりあえず動かしてみたい場合はこちら。

fresh-babylon-physics.deno.dev

参考

実装

Havokを使った物理シミュレーションをブラウザでできることは確認できているので、今回はバックエンド処理で試す。

処理の流れとしては次のようにする。

---
config:
  theme: base
  themeVariables:
    primaryColor: "#ffffff"
    primaryTextColor: '#888888'
    lineColor: "#ffffff"
    secondaryColor: "#ffffff"
    tertiaryColor: "#ffffff"
---
sequenceDiagram
participant browser as ブラウザ
participant server as サーバー(Deno)

    browser ->> server: html, js 他取得リクエスト 
    server -->> browser: 要求レスポンス返却
    browser ->> server: 物理シミュレーション処理リクエスト 
    server -->> browser: 物理シミュレーション結果返却
    browser ->> browser: 結果に基づいてBabylon.jsレンダリング
    browser ->> browser: 結果に基づいてグラフレンダリング

サーバーサイド 物理シミュレーション処理API

型情報の記述が少々長いですが、以下のような実装です。

routes/api/sim.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import { FreshContext } from "$fresh/server.ts";
import { NullEngine } from "npm:@babylonjs/core/Engines/index.js";
import * as BABYLON from "npm:@babylonjs/core/index.js";
import HavokPhysics from "npm:@babylonjs/havok";

const havok = await HavokPhysics(false);
const engine = new NullEngine(); // <= ポイント

interface SimulateResultLogState {
position: {
x: number;
y: number;
z: number;
};
rotation: {
x: number;
y: number;
z: number;
};
}
export type SimulateResultObjectsKeys = "ball" | "box" | "ground";

export type SimulateResultLog = {
[key in SimulateResultObjectsKeys]: SimulateResultLogState;
};
export type SimulateResultLogs = SimulateResultLog[];

export interface SimulateResultObjectSphere {
type: "sphere";
params: { diameter: number };
}

export interface SimulateResultObjectBox {
type: "box";
params: { height: number; width: number; depth: number };
}
export interface SimulateResultObjectGround {
type: "ground";
params: { width: number; height: number };
}

type SimulateResultObject =
| SimulateResultObjectSphere
| SimulateResultObjectBox
| SimulateResultObjectGround;

export type SimulateResultObjects = {
[key in SimulateResultObjectsKeys]: SimulateResultObject;
};

export interface SimulateResult {
log: SimulateResultLogs;
objects: SimulateResultObjects;
}

function simulate(x: number, z: number) {
const scene = new BABYLON.Scene(engine);
const plugin = new BABYLON.HavokPlugin(false, havok); // <= ポイント

scene.enablePhysics(
new BABYLON.Vector3(0, -9.8, 0),
plugin,
);

new BABYLON.ArcRotateCamera(
"Camera",
0,
0.8,
100,
BABYLON.Vector3.Zero(),
scene,
);

const ground = BABYLON.MeshBuilder.CreateGround(
"Ground",
{ width: 10, height: 10 },
scene,
);

new BABYLON.PhysicsAggregate(
ground,
BABYLON.PhysicsShapeType.BOX,
{ mass: 0, friction: 10 },
scene,
);

const ballMesh = BABYLON.MeshBuilder.CreateSphere(
"ball",
{ diameter: 1.0 },
scene,
);

ballMesh.position = new BABYLON.Vector3(x, 10, z);

new BABYLON.PhysicsAggregate(
ballMesh,
BABYLON.PhysicsShapeType.SPHERE,
{ mass: 1, friction: 0.1 },
scene,
);

const boxMesh = BABYLON.MeshBuilder.CreateBox(
`box`,
{ height: 1, width: 1, depth: 1 },
scene,
);

boxMesh.position = new BABYLON.Vector3(0, 0.5, 0);

new BABYLON.PhysicsAggregate(
boxMesh,
BABYLON.PhysicsShapeType.BOX,
{ mass: 1, friction: 0.1 },
scene,
);

const log = [];

const objects = {
ball: {
type: "sphere",
params: { diameter: 1.0 },
},
box: {
type: "box",
params: { height: 1, width: 1, depth: 1 },
},
ground: {
type: "ground",
params: { width: 10, height: 10 },
},
};

for (let i = 0; i < 300; i++) {
scene.getPhysicsEngine()?.setTimeStep(1);

const logData = {
ball: {
position: {
x: ballMesh.position.x,
y: ballMesh.position.y,
z: ballMesh.position.z,
},
rotation: {
x: ballMesh.rotation.x,
y: ballMesh.rotation.y,
z: ballMesh.rotation.z,
},
},
box: {
position: {
x: boxMesh.position.x,
y: boxMesh.position.y,
z: boxMesh.position.z,
},
rotation: {
x: boxMesh.rotation.x,
y: boxMesh.rotation.y,
z: boxMesh.rotation.z,
},
},
ground: {
position: {
x: ground.position.x,
y: ground.position.y,
z: ground.position.z,
},
rotation: {
x: ground.rotation.x,
y: ground.rotation.y,
z: ground.rotation.z,
},
},
};

log.push(logData);
scene.render();
}

return { log, objects };
}

export const handler = (req: Request, _ctx: FreshContext): Response => {
const url = req.url;
const search = (new URL(url)).search;
let x = 0;
let z = 0;
if (search) {
const params = new URLSearchParams(search);
x = Number(params.get("x"));
z = Number(params.get("z"));
}

const result = simulate(x, z);
return new Response(JSON.stringify(result));
};

ボールを落とすX座標とZ座標を引数に、ボールと箱、地面(平面)のサイズなどの情報と、300ステップの座標と回転のログを取得する。

非ブラウザ環境で実行するにあたって、NullEngine を使うのがポイント。
非ブラウザだからレンダリングするわけでもないので、カメラ要らなそうですが「必要」。
というわけで、適当なカメラを設定する。

new BABYLON.HavokPlugin(false, havok) の部分、第一引数をfalseにするのはこの実装では必須。
これを設定すると、scene.render() を呼び出すたびに、一定時間経過したものとして処理してくれる。

ブラウザの場合、engine.runRenderLoopの中で適当な間隔を持ちscene.render()を含む処理を呼び出す。
この場合、実際の経過時間を踏まえた物理演算になるがAPIの動作として不適切。
なぜなら、例えば4秒間の物理シミュレーションをするために4秒要するため。
これを回避するため、new BABYLON.HavokPlugin(false, havok) とする。
この実装で300ステップ約4秒程度の物理シミュレーションを1秒切って処理できる。

フロント側実装

フロント側の実装は、以下の用意する。

  • Canvasをマウントさせる(SSRされる)HTML(jsx)
  • APIリクエストと、UIのハンドリングをする islands(js)
  • API から帰ってきた物理シミュレーション内容に基づくBabylon.jsを含むプラグイン(js)

以下紹介。

Canvasをマウントさせる(SSRされる)HTML(jsx)

routes/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import BabylonSimulationControl from "../islands/BabylonSimulationControl.tsx";
import IconBrandGithub from "tabler_icons_tsx/tsx/brand-github.tsx";

export default function Home() {
return (
<div class="px-4 py-4 mx-auto bg-[#EFEFFF] min-h-screen">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<h1 class="text-3xl font-bold p-2">
Babylon.js Physics simulation by 'Havok' on Fresh!
</h1>
<BabylonSimulationControl />
<div class="md:flex">
<div
id="babylonMount"
class="w-1/2 min-h-[400px] min-w-[400px] bg-white p-1"
>
</div>
<div
id="chartMount"
class="w-1/2 min-h-[400px] min-w-[400px] bg-white p-1"
>
</div>
</div>
<div class="max-w-screen-md mt-1 px-3 flex flex-col items-center justify-center text-center bg-white">
{/* ただのリンクなので省略*/}
</div>
</div>
</div>
);
}

babylonMount chartMount の2つdivは、Canvasのマウント先とします。

APIリクエストと、UIのハンドリングをする islands(js)

islands/BabylonSimulationControl.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/// <reference lib="dom" />
import type { Signal } from "@preact/signals";
import Chart, { ChartConfiguration } from "npm:chart.js/auto";
import { useEffect, useState } from "preact/hooks";
import { SimulateResult, SimulateResultLog } from "../routes/api/sim.ts";
import { tailwind } from "$fresh/plugins/tailwind.ts";

function getGraphData(src: SimulateResult): ChartConfiguration {
const point = [];
for (let i = 0; i < src.log.length; i++) {
point.push(i);
}
const dataX = src.log.map((d: SimulateResultLog) => d.ball.position.x);
const dataY = src.log.map((d: SimulateResultLog) => d.ball.position.y);
const dataZ = src.log.map((d: SimulateResultLog) => d.ball.position.z);

return {
type: "line",
data: {
labels: point,
datasets: [{
label: "X axis",
data: dataX,
borderWidth: 1,
}, {
label: "Y axis",
data: dataY,
borderWidth: 1,
}, {
label: "Z axis",
data: dataZ,
borderWidth: 1,
}],
},
options: {
scales: {
y: {
beginAtZero: true,
},
},
},
};
}

function createChart(
parent: HTMLElement,
data: ChartConfiguration,
): HTMLCanvasElement {
const rowCanvas = document.createElement("canvas");
rowCanvas.style.width = "400px";
rowCanvas.style.height = "400px";
document.getElementById("chartMount")?.appendChild(rowCanvas);

new Chart(rowCanvas, data);
return rowCanvas;
}
function destroyChart(parent: HTMLElement, target: HTMLCanvasElement): void {
parent.removeChild(target);
}

export default function BabylonSimulationControl() {
const [valueX, setValueX] = useState(-0.5);
const [valueZ, setValueZ] = useState(-0.5);
const [canvas, setCanvas] = useState(null);
const [resource, setResource] = useState(null);

useEffect(() => {
const call = async () => {
const res = await fetch(`/api/sim?x=${valueX}&z=${valueZ}`, {
headers: {
"Content-Type": "application/json",
},
});
const data = await res.json() as SimulateResult;

setCanvas(
createChart(
document.getElementById("chartMount") as HTMLElement,
getGraphData(data),
),
);
setResource(window.startBabylon(data));
};
call();
}, []);

const onSubmit = async (e: Event) => {
e.preventDefault();

resource.deleteView();
destroyChart(document.getElementById("chartMount")!, canvas);

const res = await fetch(`/api/sim?x=${valueX}&z=${valueZ}`, {
headers: {
"Content-Type": "application/json",
},
});
const data = await res.json();

setCanvas(
createChart(
document.getElementById("chartMount") as HTMLElement,
getGraphData(data),
),
);
setResource(window.startBabylon(data));
};
return (
<div class="py-4">
<div>
<p class="font-medium text-gray-900 dark:text-white">
Ball drop coordinate
</p>
</div>
<form onSubmit={onSubmit}>
<div class="sm:flex">
<div class="mb-2 mr-3 flex">
<label
for="x-axis"
class="w-20 block mt-3 text-sm font-medium text-gray-900 dark:text-white"
>
X axis
</label>
<input
type="number"
name="x-axis"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
value={valueX}
onChange={(e) => setValueX(Number(e.target.value))}
min="-5"
max="5"
step="0.1"
/>
</div>
<div class="mb-2 mr-3 flex">
<label
for="z-axis"
class="w-20 block mt-3 text-sm font-medium text-gray-900 dark:text-white"
>
Z axis
</label>
<input
type="number"
name="z-axis"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
value={valueZ}
onChange={(e) => setValueZ(Number(e.target.value))}
min="-5"
max="5"
step="0.1"
/>
</div>
<div class="mb-2 mr-3">
<button
type="submit"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 w-max"
>
Update param!
</button>
</div>
</div>
</form>
</div>
);
}

ポイントになるのは、以下の部分。
APIのレスポンスから、グラフの作成とbabylon.jsのセットアップを行う。

1
2
3
4
5
6
7
8
9
10
11
12
13
const res = await fetch(`/api/sim?x=${valueX}&z=${valueZ}`, {
headers: {
"Content-Type": "application/json",
},
});
const data = await res.json() as SimulateResult;
setCanvas(
createChart(
document.getElementById("chartMount") as HTMLElement,
getGraphData(data),
),
);
setResource(window.startBabylon(data));

処理の中で、生DOMを触る処理をしているが、もう少しうまい(正しい)やり方もありそう。
今回はこの通りだが、正しい方法は見直したい。

API から帰ってきた物理シミュレーション内容に基づくBabylon.jsを含むプラグイン(js)

プラグインは以下のように実装します。

app\plugins\babylon_plugin.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Plugin } from "$fresh/server.ts";

export function BabylonPlugin(): Plugin {
return {
name: "babylon-plugin",
entrypoints: {
babylon_app: import.meta.resolve(`./babylon_app.ts`),
},
render(ctx) {
ctx.render();
return {
scripts: [
{
entrypoint: "babylon_app",
state: {},
},
],
};
},
};
}

babylon.jsを直接扱うコードは以下の通りです。

app\plugins\babylon_app.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import * as BABYLON from "npm:@babylonjs/core/index.js";
import type {
SimulateResult,
SimulateResultObjectBox,
SimulateResultObjectGround,
SimulateResultObjectsKeys,
SimulateResultObjectSphere,
} from "../routes/api/sim.ts";

function isBoxType(src: unknown): src is SimulateResultObjectBox {
if (!src) {
return false;
}
if (typeof src !== "object") {
return false;
}
if (!("type" in src)) {
return false;
}
return src.type === "box";
}
function isSphereType(src: unknown): src is SimulateResultObjectSphere {
if (!src) {
return false;
}
if (typeof src !== "object") {
return false;
}
if (!("type" in src)) {
return false;
}
return src.type === "sphere";
}
function isGroundType(src: unknown): src is SimulateResultObjectGround {
if (!src) {
return false;
}
if (typeof src !== "object") {
return false;
}
if (!("type" in src)) {
return false;
}
return src.type === "ground";
}

function startBabylon(data: SimulateResult) {
const canvas = document.createElement("canvas");
canvas.id = `renderCanvas${(new Date()).getTime()}`;
canvas.style.width = "400px";
canvas.style.height = "400px";
document.body.appendChild(canvas);

document.getElementById("babylonMount")!.appendChild(canvas);

const engine = new BABYLON.Engine(canvas, true, {
preserveDrawingBuffer: true,
stencil: true,
});

const scene = new BABYLON.Scene(engine);

const camera = new BABYLON.ArcRotateCamera(
"Camera",
(Math.PI * 60) / 180,
(Math.PI * 120) / 180,
-30,
BABYLON.Vector3.Zero(),
scene,
);
camera.setTarget(BABYLON.Vector3.Zero());
camera.attachControl(canvas, true);

new BABYLON.HemisphericLight(
"light",
new BABYLON.Vector3(0, 100, 0),
scene,
);

const objects: { [key in SimulateResultObjectsKeys]: BABYLON.Mesh | null } = {
ball: null,
box: null,
ground: null,
};
const objectKeys = Object.keys(data.objects) as Array<
SimulateResultObjectsKeys
>;

objectKeys.forEach((key) => {
const object = data.objects[key];

if (isBoxType(object)) {
objects[key] = BABYLON.MeshBuilder.CreateBox(
key,
object.params,
scene,
);
} else if (isSphereType(object)) {
objects[key] = BABYLON.MeshBuilder.CreateSphere(
key,
object.params,
scene,
);
} else if (isGroundType(object)) {
objects[key] = BABYLON.MeshBuilder.CreateGround(
key,
object.params,
scene,
);
}
});

let i = 0;
engine.runRenderLoop(function () {
if (i >= data.log.length) {
i = 0;
}
objectKeys.forEach((key) => {
if (!objects[key] || objects[key] === null) {
return;
}
objects[key]!.position.x = data.log[i][key].position.x;
objects[key]!.position.y = data.log[i][key].position.y;
objects[key]!.position.z = data.log[i][key].position.z;
objects[key]!.rotation.x = data.log[i][key].rotation.x;
objects[key]!.rotation.y = data.log[i][key].rotation.y;
objects[key]!.rotation.z = data.log[i][key].rotation.z;
});

i++;
scene.render();
});

const deleteView = () => {
engine.stopRenderLoop();
engine.dispose();
engine.views = [];
document.getElementById("babylonMount")!.removeChild(canvas);
};

return { deleteView };
}

declare global {
interface Window {
startBabylon: typeof startBabylon;
}
}

export default function babylonApp() {
window.startBabylon = startBabylon;
}

プラグインによって、ブラウザ読み込むjsで、windowにbabylon.jsの処理を呼び出す口を登録しておきます。

登録した処理は、islandsでAPIのレスポンスを引数に呼び出して表示開始します。

今回、サーバー側でボールと箱と地面が1フレーム目から存在している構造になっています。
拡張してよく見られる雨のように新しいオブジェクトが次々振ってくるようなシミュレーションも、原理的には可能です。


Fresh の上で Babylon.jsを動かし、物理シミュレーションをサーバーに任せる構造を作ってみました。
この構成でないとできないこととして、表示する時点で座標の遷移を表示できています。
これはAPIですでに計算済みだからです。

個人的に、物理演算をサーバーさせることを目的とした理由の1つは、多人数参加のゲームのためです。
どうしても、ブラウザでの物理演算は、各クライアントで同じ結果を生まないこともあるので、中央管理したかったのです。
近いうちにこれを踏まえて簡単なゲームを作りたいところです。

今回、作ったものはfresh-babylon-physics.deno.devで公開しています。
Deno Deployでホストしていますが、動くのはなかなか感動でした。
WASMを含むnpmパッケージもDenoで動くようになったと。
今回は300ステップの動作をAPIで返しています。
テストする中で、36000ステップまで返すことができました。
オブジェクト数が今回少なかったのもありますが、かなり長い時間のシミュレーションできることも見込めました。

では。