Babylon.js ナビゲーションメッシュと物理エンジンで遊ぶ

Babylon.js がナビゲーションメッシュの機能を持っていることに最近気が付いた。
公式が用意するものでは、エージェントを使って群衆コントロールをするものがあるものの、よりアクティブなものを試したいと思い、物理エンジンと組み合わせてみる。

最終的にできたのはこちら。

参考

環境構築

実行環境は、Deno でViteを使う。

1
2
$ deno run -A npm:vite
$ deno install npm:babylonjs npm:@babylonjs/havok npm:recast-detour npm:recast

以下で実行する。

1
$ deno run dev

実装

以下のように実装する。

main.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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
import * as BABYLON from "babylonjs";
import HavokPhysics from "@babylonjs/havok";
import havokWasmUrl from "../assets/HavokPhysics.wasm?url";
import Recast from "recast-detour/recast.es6.js";
const canvas = document.getElementById("renderCanvas")! as HTMLCanvasElement;

const havok = await HavokPhysics({
locateFile: () => havokWasmUrl,
});

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

// シーンの作成
const scene = new BABYLON.Scene(engine);

// 物理エンジンの有効化
scene.enablePhysics(
new BABYLON.Vector3(0, -9.8, 0),
new BABYLON.HavokPlugin(true, havok),
);

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

// 地面の作成 面は10分割する
const ground = BABYLON.MeshBuilder.CreateGround(
"Ground",
{ width: 30, height: 30, subdivisionsX: 10, subdivisionsY: 10 },
scene,
);
const groundAggregate = new BABYLON.PhysicsAggregate(
ground,
BABYLON.PhysicsShapeType.BOX,
{ mass: 0, friction: 10 },
scene,
);

// 照明の作成
const light = new BABYLON.HemisphericLight(
"light",
new BABYLON.Vector3(0, 100, 0),
scene,
);

// 箱の作成
const boxMaterial = new BABYLON.StandardMaterial("BoxMaterial");
boxMaterial.diffuseColor = new BABYLON.Color3(1.0, 0, 0);
const boxMesh = BABYLON.MeshBuilder.CreateBox(
"box",
{ height: 1, width: 1, depth: 1 },
scene,
);
boxMesh.position = new BABYLON.Vector3(-5, 3, -5);
boxMesh.material = boxMaterial;
const boxPhysics = new BABYLON.PhysicsAggregate(
boxMesh,
BABYLON.PhysicsShapeType.BOX,
{ mass: 1, friction: 10 },
scene,
);

// ゴールポイント作成
const goalBoxMaterial = new BABYLON.StandardMaterial("BoxMaterial2");
goalBoxMaterial.diffuseColor = new BABYLON.Color3(0, 1, 0);
const goalBoxMesh = BABYLON.MeshBuilder.CreateBox(
"box",
{ height: 1, width: 1, depth: 1 },
scene,
);
goalBoxMesh.position = new BABYLON.Vector3(9, 0.5, 8);
goalBoxMesh.material = goalBoxMaterial;

// 障害物の作成
function createObstacles(positions: BABYLON.Vector3[]) {
const meshs = [];

const boxMaterial3 = new BABYLON.StandardMaterial("ObstaclesMaterial");
boxMaterial3.diffuseColor = new BABYLON.Color3(0.5, 0.5, 0.5);
for (let i = 0; i < positions.length; i++) {
const mesh = BABYLON.MeshBuilder.CreateBox(
"box",
{ height: 3, width: 3, depth: 3 },
scene,
);
mesh.position = positions[i];
mesh.material = boxMaterial3;
mesh.material.alpha = 0.7;
meshs.push(mesh);

new BABYLON.PhysicsAggregate(
mesh,
BABYLON.PhysicsShapeType.BOX,
{ mass: 0.05, friction: 10 },
scene,
);
}
return meshs;
}

const ObstaclePositions = [
new BABYLON.Vector3(-2, 1.5, -2),
new BABYLON.Vector3(6, 1.5, 6),
];

const obstaclesMesh = createObstacles(ObstaclePositions);

const recast = await new Recast();
const navigationPlugin = new BABYLON.RecastJSPlugin(recast);

// ナビメッシュの作成 公式の例を参照しながら調整
const parameters = {
cs: 0.2,
ch: 0.2,
walkableSlopeAngle: 45,
walkableHeight: 2,
walkableClimb: 0.4,
walkableRadius: 5,
maxEdgeLen: 20,
maxSimplificationError: 1.3,
minRegionArea: 18,
mergeRegionArea: 8,
maxVertsPerPoly: 6,
detailSampleDist: 8,
detailSampleMaxError: 8,
};

navigationPlugin.createNavMesh([ground, ...obstaclesMesh], parameters);
let navMeshDebug = navigationPlugin.createDebugNavMesh(scene);
const matDebug = new BABYLON.StandardMaterial("matDebug", scene);
matDebug.diffuseColor = new BABYLON.Color3(0.2, 0.4, 1);
matDebug.alpha = 0.4;
navMeshDebug.material = matDebug;

function updateNavMesh() {
navigationPlugin.createNavMesh([ground, ...obstaclesMesh], parameters);
navMeshDebug.dispose();
navMeshDebug = navigationPlugin.createDebugNavMesh(scene);
navMeshDebug.material = matDebug;
}

function getGroundPosition() {
const pickinfo = scene.pick(scene.pointerX, scene.pointerY);
if (pickinfo.hit) {
return pickinfo.pickedPoint;
}
return null;
}

scene.onPointerObservable.add((pointerInfo) => {
switch (pointerInfo.type) {
case BABYLON.PointerEventTypes.POINTERDOWN:
if (pointerInfo.pickInfo?.hit) {
const startingPoint = getGroundPosition();
if (startingPoint) {
goalBoxMesh.position = startingPoint;
}
}
break;
}
});

let pathLine = undefined;

let startCount = 0;
let count = 0;

engine.runRenderLoop(function () {
if(startCount++ < 300){
scene.render();
return;
}

if(++count > 30){
updateNavMesh()
count = 0;
}

const pathPoints = navigationPlugin.computePathSmooth(
navigationPlugin.getClosestPoint(boxMesh.position),
navigationPlugin.getClosestPoint(goalBoxMesh.position),
);
if (pathPoints && pathPoints.length > 0) {
pathLine = BABYLON.MeshBuilder.CreateDashedLines("ribbon", {
points: pathPoints,
updatable: true,
instance: pathLine!,
dashSize: 10,
}, scene);
if (pathLine) {
pathLine.enableEdgesRendering();
pathLine.edgesWidth = 20;
}
}

if (pathPoints && pathPoints.length > 1) {
const nextPoint = pathPoints[1];
const dir = nextPoint.subtract(boxMesh.position);
dir.y = 0;
dir.normalize();
dir.scaleInPlace(0.25);

//障害物との接触により身動きできなくなることを回避するためランダムに揺らす
const rand = (Math.random() - 0.5) * 0.8;
const cos = Math.cos(rand);
const sin = Math.sin(rand);
const newX = dir.x * cos - dir.z * sin;
const newZ = dir.x * sin + dir.z * cos;
dir.x = newX;
dir.z = newZ;

boxPhysics.body.applyImpulse(
dir,
boxMesh.position,
);
}
scene.render();
});
index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en" style="background-color: black">
<head>
<meta charset="UTF-8" />
<link type="style" href="/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS</title>
</head>
<body>
<canvas id="renderCanvas" width="800px" height="800px"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

サンプルのところかなり参考にした部分もありますが、
ダイナミックなナビゲーションメッシュの更新を導入する形にしています。

こちらを動かすと以下のようになります。


以上、Babylon.js ナビゲーションメッシュと物理エンジンを組み合わせてみました。

ナビゲーションメッシュの設定はかなりパラメータが多く、専門知識が必要な点を感じます。

複雑なことをするなら、自動計算するナビゲーションメッシュではなく、別途作成しての読み込みをするべきでありそうです。

では。