@forgeax/engine-runtime 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -26,37 +26,39 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@forgeax/engine-animation": "0.1.
|
|
30
|
-
"@forgeax/engine-assets-runtime": "0.1.
|
|
31
|
-
"@forgeax/engine-audio-webaudio": "0.1.
|
|
32
|
-
"@forgeax/engine-codec": "0.1.
|
|
33
|
-
"@forgeax/engine-debug-draw": "0.1.
|
|
34
|
-
"@forgeax/engine-ecs": "0.1.
|
|
35
|
-
"@forgeax/engine-font": "0.1.
|
|
36
|
-
"@forgeax/engine-geometry": "0.1.
|
|
37
|
-
"@forgeax/engine-graphics-extras": "0.1.
|
|
38
|
-
"@forgeax/engine-image": "0.1.
|
|
39
|
-
"@forgeax/engine-input": "0.1.
|
|
40
|
-
"@forgeax/engine-math": "0.1.
|
|
41
|
-
"@forgeax/engine-pack": "0.1.
|
|
42
|
-
"@forgeax/engine-profiler": "0.1.
|
|
43
|
-
"@forgeax/engine-render": "0.1.
|
|
44
|
-
"@forgeax/engine-render-graph": "0.1.
|
|
45
|
-
"@forgeax/engine-rhi": "0.1.
|
|
46
|
-
"@forgeax/engine-rhi-null": "0.1.
|
|
47
|
-
"@forgeax/engine-rhi-webgpu": "0.1.
|
|
48
|
-
"@forgeax/engine-rhi-wgpu": "0.1.
|
|
49
|
-
"@forgeax/engine-scene": "0.1.
|
|
50
|
-
"@forgeax/engine-shader": "0.1.
|
|
51
|
-
"@forgeax/engine-skinning": "0.1.
|
|
52
|
-
"@forgeax/engine-types": "0.1.
|
|
53
|
-
"@forgeax/engine-vfx": "0.1.
|
|
29
|
+
"@forgeax/engine-animation": "0.1.28",
|
|
30
|
+
"@forgeax/engine-assets-runtime": "0.1.28",
|
|
31
|
+
"@forgeax/engine-audio-webaudio": "0.1.28",
|
|
32
|
+
"@forgeax/engine-codec": "0.1.28",
|
|
33
|
+
"@forgeax/engine-debug-draw": "0.1.28",
|
|
34
|
+
"@forgeax/engine-ecs": "0.1.28",
|
|
35
|
+
"@forgeax/engine-font": "0.1.28",
|
|
36
|
+
"@forgeax/engine-geometry": "0.1.28",
|
|
37
|
+
"@forgeax/engine-graphics-extras": "0.1.28",
|
|
38
|
+
"@forgeax/engine-image": "0.1.28",
|
|
39
|
+
"@forgeax/engine-input": "0.1.28",
|
|
40
|
+
"@forgeax/engine-math": "0.1.28",
|
|
41
|
+
"@forgeax/engine-pack": "0.1.28",
|
|
42
|
+
"@forgeax/engine-profiler": "0.1.28",
|
|
43
|
+
"@forgeax/engine-render": "0.1.28",
|
|
44
|
+
"@forgeax/engine-render-graph": "0.1.28",
|
|
45
|
+
"@forgeax/engine-rhi": "0.1.28",
|
|
46
|
+
"@forgeax/engine-rhi-null": "0.1.28",
|
|
47
|
+
"@forgeax/engine-rhi-webgpu": "0.1.28",
|
|
48
|
+
"@forgeax/engine-rhi-wgpu": "0.1.28",
|
|
49
|
+
"@forgeax/engine-scene": "0.1.28",
|
|
50
|
+
"@forgeax/engine-shader": "0.1.28",
|
|
51
|
+
"@forgeax/engine-skinning": "0.1.28",
|
|
52
|
+
"@forgeax/engine-types": "0.1.28",
|
|
53
|
+
"@forgeax/engine-vfx": "0.1.28"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@forgeax/engine-import": "0.1.
|
|
57
|
-
"@forgeax/engine-naga": "0.1.
|
|
58
|
-
"@forgeax/engine-
|
|
59
|
-
"@forgeax/engine-
|
|
56
|
+
"@forgeax/engine-import": "0.1.28",
|
|
57
|
+
"@forgeax/engine-naga": "0.1.28",
|
|
58
|
+
"@forgeax/engine-physics": "0.1.28",
|
|
59
|
+
"@forgeax/engine-physics-rapier3d": "0.1.28",
|
|
60
|
+
"@forgeax/engine-state": "0.1.28",
|
|
61
|
+
"@forgeax/engine-vite-plugin-shader": "0.1.28",
|
|
60
62
|
"@types/node": "^20.14.0"
|
|
61
63
|
},
|
|
62
64
|
"forgeax": {
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { createWorldContext, FixedTime, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createBoxGeometry, meshFromInterleaved } from '@forgeax/engine-geometry';
|
|
3
|
+
import { vec3 } from '@forgeax/engine-math';
|
|
4
|
+
import { RigidBody, RigidBodyTypeValue } from '@forgeax/engine-physics';
|
|
5
|
+
import {
|
|
6
|
+
createRapier3DPhysicsWorld,
|
|
7
|
+
loadRapier3D,
|
|
8
|
+
registerPhysicsSystems,
|
|
9
|
+
} from '@forgeax/engine-physics-rapier3d';
|
|
10
|
+
import {
|
|
11
|
+
ANTIALIAS_TAA,
|
|
12
|
+
Camera,
|
|
13
|
+
DirectionalLight,
|
|
14
|
+
type DynamicGeometryCandidate,
|
|
15
|
+
Materials,
|
|
16
|
+
MeshFilter,
|
|
17
|
+
MeshRenderer,
|
|
18
|
+
} from '@forgeax/engine-render';
|
|
19
|
+
import { ChildOf, propagateTransforms, scenePlugin, Transform } from '@forgeax/engine-scene';
|
|
20
|
+
import { err, type MeshAsset, ok } from '@forgeax/engine-types';
|
|
21
|
+
import { expect, it } from 'vitest';
|
|
22
|
+
import { page } from 'vitest/browser';
|
|
23
|
+
import { createRenderer } from '../createRenderer';
|
|
24
|
+
|
|
25
|
+
// Deterministic external geometry output: four boxes form an aperture.
|
|
26
|
+
// The Engine consumes an ordinary MeshAsset, without a voxel renderer.
|
|
27
|
+
function aperture(): MeshAsset {
|
|
28
|
+
const vertices: number[] = [];
|
|
29
|
+
const indices: number[] = [];
|
|
30
|
+
for (const [x, y, w, h] of [
|
|
31
|
+
[-0.8, 0, 0.4, 2],
|
|
32
|
+
[0.8, 0, 0.4, 2],
|
|
33
|
+
[0, -0.8, 1.2, 0.4],
|
|
34
|
+
[0, 0.8, 1.2, 0.4],
|
|
35
|
+
] as const) {
|
|
36
|
+
const box = createBoxGeometry(w, h, 0.5).unwrap();
|
|
37
|
+
const offset = vertices.length / 8;
|
|
38
|
+
const { position, normal, uv } = box.attributes;
|
|
39
|
+
if (position === undefined || normal === undefined || uv === undefined)
|
|
40
|
+
throw new Error('box attribute closure');
|
|
41
|
+
for (let i = 0; i < position.length / 3; i++) {
|
|
42
|
+
vertices.push(
|
|
43
|
+
(position[i * 3] ?? 0) + x,
|
|
44
|
+
(position[i * 3 + 1] ?? 0) + y,
|
|
45
|
+
position[i * 3 + 2] ?? 0,
|
|
46
|
+
normal[i * 3] ?? 0,
|
|
47
|
+
normal[i * 3 + 1] ?? 0,
|
|
48
|
+
normal[i * 3 + 2] ?? 0,
|
|
49
|
+
uv[i * 2] ?? 0,
|
|
50
|
+
uv[i * 2 + 1] ?? 0,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
for (const index of box.indices) indices.push(index + offset);
|
|
54
|
+
}
|
|
55
|
+
return meshFromInterleaved(new Float32Array(vertices), new Uint16Array(indices)).unwrap();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('atomically publishes physics and PBR apertures with shadow, parent motion and fresh TAA history', async () => {
|
|
59
|
+
const canvas = document.createElement('canvas');
|
|
60
|
+
canvas.width = 384;
|
|
61
|
+
canvas.height = 384;
|
|
62
|
+
canvas.style.width = '384px';
|
|
63
|
+
canvas.style.height = '384px';
|
|
64
|
+
document.body.appendChild(canvas);
|
|
65
|
+
const renderer = (
|
|
66
|
+
await createRenderer(canvas, {}, { shaderManifestUrl: '/shaders/manifest.json' })
|
|
67
|
+
).unwrap();
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
const unsubscribe = renderer.subscribe((event) => {
|
|
70
|
+
if (event.kind === 'error') errors.push(event.error.code);
|
|
71
|
+
});
|
|
72
|
+
const world = new World();
|
|
73
|
+
const scene = await createWorldContext(world, [scenePlugin()]);
|
|
74
|
+
const rapier = await loadRapier3D();
|
|
75
|
+
if ('code' in rapier) throw rapier;
|
|
76
|
+
const physics = createRapier3DPhysicsWorld(rapier);
|
|
77
|
+
world.insertResource('PhysicsWorld', physics);
|
|
78
|
+
const releasePhysics = registerPhysicsSystems(world);
|
|
79
|
+
try {
|
|
80
|
+
const material = world.allocSharedRef(
|
|
81
|
+
'MaterialAsset',
|
|
82
|
+
Materials.standard({ baseColor: [0.7, 0.24, 0.06, 1], metallic: 0.15, roughness: 0.45 }),
|
|
83
|
+
);
|
|
84
|
+
const gray = world.allocSharedRef(
|
|
85
|
+
'MaterialAsset',
|
|
86
|
+
Materials.standard({ baseColor: [0.55, 0.6, 0.65, 1], metallic: 0, roughness: 0.8 }),
|
|
87
|
+
);
|
|
88
|
+
const closedMesh = createBoxGeometry(2, 2, 0.5).unwrap();
|
|
89
|
+
const openMesh = aperture();
|
|
90
|
+
const closedHandle = world.allocSharedRef('MeshAsset', closedMesh);
|
|
91
|
+
const openHandle = world.allocSharedRef('MeshAsset', openMesh);
|
|
92
|
+
const parent = world
|
|
93
|
+
.spawn(
|
|
94
|
+
{ component: Transform, data: {} },
|
|
95
|
+
{ component: RigidBody, data: { type: RigidBodyTypeValue.static } },
|
|
96
|
+
)
|
|
97
|
+
.unwrap();
|
|
98
|
+
const entity = world
|
|
99
|
+
.spawn(
|
|
100
|
+
{ component: Transform, data: {} },
|
|
101
|
+
{ component: ChildOf, data: { parent } },
|
|
102
|
+
{ component: MeshFilter, data: { assetHandle: closedHandle } },
|
|
103
|
+
{ component: MeshRenderer, data: { materials: [material] } },
|
|
104
|
+
)
|
|
105
|
+
.unwrap();
|
|
106
|
+
const floor = world.allocSharedRef('MeshAsset', createBoxGeometry(7, 0.2, 7).unwrap());
|
|
107
|
+
world
|
|
108
|
+
.spawn(
|
|
109
|
+
{ component: Transform, data: { pos: [0, -1.25, 0] } },
|
|
110
|
+
{ component: MeshFilter, data: { assetHandle: floor } },
|
|
111
|
+
{ component: MeshRenderer, data: { materials: [gray] } },
|
|
112
|
+
)
|
|
113
|
+
.unwrap();
|
|
114
|
+
const light = world
|
|
115
|
+
.spawn({
|
|
116
|
+
component: DirectionalLight,
|
|
117
|
+
data: { direction: [-0.5, -0.9, -0.7], intensity: 3, castShadow: true },
|
|
118
|
+
})
|
|
119
|
+
.unwrap();
|
|
120
|
+
const camera = world
|
|
121
|
+
.spawn(
|
|
122
|
+
{ component: Transform, data: { pos: [0, 1, 7] } },
|
|
123
|
+
{ component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 40 } },
|
|
124
|
+
)
|
|
125
|
+
.unwrap();
|
|
126
|
+
const lease = renderer.attach(world).unwrap();
|
|
127
|
+
const physicsInput = (revision: number, opened: boolean) => {
|
|
128
|
+
const cells: [number, number, number][] = [];
|
|
129
|
+
for (let x = -2; x <= 2; x++)
|
|
130
|
+
for (let y = -2; y <= 2; y++) {
|
|
131
|
+
if (!opened || Math.abs(x) === 2 || Math.abs(y) === 2) cells.push([x, y, 0]);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
entity: parent,
|
|
135
|
+
revision,
|
|
136
|
+
sourceKey: 'wave1:slab',
|
|
137
|
+
worldIdentity: world,
|
|
138
|
+
bodyType: 'static' as const,
|
|
139
|
+
shapes: [
|
|
140
|
+
{
|
|
141
|
+
id: 'slab',
|
|
142
|
+
revision,
|
|
143
|
+
cells,
|
|
144
|
+
voxelSize: [0.4, 0.4, 0.5] as const,
|
|
145
|
+
origin: [-0.2, -0.2, -0.25] as const,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
const centerHit = () => physics.raycast(vec3.create(0, 0, 4), vec3.create(0, 0, -1), 8);
|
|
151
|
+
world.update(1 / 60).unwrap();
|
|
152
|
+
physics
|
|
153
|
+
.admitDerivedShapeCandidate(
|
|
154
|
+
physics.prepareDerivedShapeCandidate(physicsInput(1, false)).unwrap(),
|
|
155
|
+
)
|
|
156
|
+
.unwrap();
|
|
157
|
+
world.update(1 / 60).unwrap();
|
|
158
|
+
expect(centerHit()?.entity).toBe(parent);
|
|
159
|
+
const draw = async () => {
|
|
160
|
+
propagateTransforms(world).unwrap();
|
|
161
|
+
const receipt = renderer
|
|
162
|
+
.draw({ leases: [lease], camera: { lease }, environment: { lease } })
|
|
163
|
+
.unwrap();
|
|
164
|
+
(await receipt.completed).unwrap();
|
|
165
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
166
|
+
return receipt;
|
|
167
|
+
};
|
|
168
|
+
const shot = async (name: string) => {
|
|
169
|
+
const value = await page
|
|
170
|
+
.elementLocator(canvas)
|
|
171
|
+
.screenshot({ path: `wave1-${name}.png`, base64: true });
|
|
172
|
+
return typeof value === 'string' ? value : value.base64;
|
|
173
|
+
};
|
|
174
|
+
await draw();
|
|
175
|
+
const closed = await shot('closed');
|
|
176
|
+
const candidate = renderer
|
|
177
|
+
.prepareDynamicGeometry({
|
|
178
|
+
world,
|
|
179
|
+
entity,
|
|
180
|
+
physicsEntity: parent,
|
|
181
|
+
mesh: openMesh,
|
|
182
|
+
meshHandle: openHandle,
|
|
183
|
+
revision: 2,
|
|
184
|
+
})
|
|
185
|
+
.unwrap();
|
|
186
|
+
expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(closedHandle);
|
|
187
|
+
await draw();
|
|
188
|
+
expect(await shot('prepared')).toBe(closed);
|
|
189
|
+
let accepted: DynamicGeometryCandidate | undefined;
|
|
190
|
+
physics
|
|
191
|
+
.admitDerivedShapeCandidate(
|
|
192
|
+
physics.prepareDerivedShapeCandidate(physicsInput(2, true)).unwrap(),
|
|
193
|
+
() => {
|
|
194
|
+
const result = renderer.acceptDynamicGeometry(candidate, {
|
|
195
|
+
world,
|
|
196
|
+
fixedStep: world.getResource(FixedTime).tick,
|
|
197
|
+
});
|
|
198
|
+
if (!result.ok) return err(result.error);
|
|
199
|
+
accepted = result.value;
|
|
200
|
+
expect(
|
|
201
|
+
renderer.draw({ leases: [lease], camera: { lease }, environment: { lease } }),
|
|
202
|
+
).toMatchObject({ ok: false, error: { code: 'frame-input-invalid' } });
|
|
203
|
+
return ok(undefined);
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
.unwrap();
|
|
207
|
+
world.update(0).unwrap();
|
|
208
|
+
expect(accepted).toBeUndefined();
|
|
209
|
+
expect(centerHit()?.entity).toBe(parent);
|
|
210
|
+
world.update(2 / 60).unwrap();
|
|
211
|
+
expect(accepted).toBeDefined();
|
|
212
|
+
if (accepted === undefined) throw new Error('paired admission did not run');
|
|
213
|
+
expect(centerHit()).toBeUndefined();
|
|
214
|
+
const frame = await draw();
|
|
215
|
+
expect(renderer.dynamicGeometryReceipt(accepted)?.frameId).toBe(frame.frameId);
|
|
216
|
+
const opened = await shot('open');
|
|
217
|
+
expect(opened).not.toBe(closed);
|
|
218
|
+
world.set(light, DirectionalLight, { castShadow: false }).unwrap();
|
|
219
|
+
await draw();
|
|
220
|
+
expect(await shot('no-shadow')).not.toBe(opened);
|
|
221
|
+
world.set(light, DirectionalLight, { castShadow: true }).unwrap();
|
|
222
|
+
world.set(parent, Transform, { pos: [0.75, 0, 0] }).unwrap();
|
|
223
|
+
world.update(1 / 60).unwrap();
|
|
224
|
+
await draw();
|
|
225
|
+
expect(await shot('moved')).not.toBe(opened);
|
|
226
|
+
const replacement = renderer
|
|
227
|
+
.prepareDynamicGeometry({
|
|
228
|
+
world,
|
|
229
|
+
entity,
|
|
230
|
+
physicsEntity: parent,
|
|
231
|
+
mesh: closedMesh,
|
|
232
|
+
meshHandle: closedHandle,
|
|
233
|
+
materialIdentity: String(material),
|
|
234
|
+
revision: 3,
|
|
235
|
+
})
|
|
236
|
+
.unwrap();
|
|
237
|
+
world.set(entity, MeshRenderer, { materials: [gray] }).unwrap();
|
|
238
|
+
physics
|
|
239
|
+
.admitDerivedShapeCandidate(
|
|
240
|
+
physics.prepareDerivedShapeCandidate(physicsInput(3, false)).unwrap(),
|
|
241
|
+
() => {
|
|
242
|
+
const result = renderer.acceptDynamicGeometry(replacement, {
|
|
243
|
+
world,
|
|
244
|
+
fixedStep: world.getResource(FixedTime).tick,
|
|
245
|
+
});
|
|
246
|
+
return result.ok ? ok(undefined) : err(result.error);
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
.unwrap();
|
|
250
|
+
world.update(1 / 60).unwrap();
|
|
251
|
+
expect(physics.getDerivedPublication(parent)?.revision).toBe(2);
|
|
252
|
+
expect(physics.getDerivedFailure(parent)?.recovery).toBe('old-state-retained');
|
|
253
|
+
expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(openHandle);
|
|
254
|
+
renderer.cancelDynamicGeometry(replacement).unwrap();
|
|
255
|
+
world.set(entity, MeshRenderer, { materials: [material] }).unwrap();
|
|
256
|
+
const sealedCandidate = renderer
|
|
257
|
+
.prepareDynamicGeometry({
|
|
258
|
+
world,
|
|
259
|
+
entity,
|
|
260
|
+
physicsEntity: parent,
|
|
261
|
+
mesh: closedMesh,
|
|
262
|
+
meshHandle: closedHandle,
|
|
263
|
+
revision: 4,
|
|
264
|
+
})
|
|
265
|
+
.unwrap();
|
|
266
|
+
physics
|
|
267
|
+
.admitDerivedShapeCandidate(
|
|
268
|
+
physics.prepareDerivedShapeCandidate(physicsInput(4, false)).unwrap(),
|
|
269
|
+
() => {
|
|
270
|
+
const result = renderer.acceptDynamicGeometry(sealedCandidate, {
|
|
271
|
+
world,
|
|
272
|
+
fixedStep: world.getResource(FixedTime).tick,
|
|
273
|
+
});
|
|
274
|
+
return result.ok ? ok(undefined) : err(result.error);
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
.unwrap();
|
|
278
|
+
world.update(1 / 60).unwrap();
|
|
279
|
+
expect(physics.getDerivedPublication(parent)?.revision).toBe(4);
|
|
280
|
+
await draw();
|
|
281
|
+
renderer.retireDynamicGeometry(accepted as DynamicGeometryCandidate).unwrap();
|
|
282
|
+
await shot('sealed');
|
|
283
|
+
world.set(camera, Camera, { antialias: ANTIALIAS_TAA }).unwrap();
|
|
284
|
+
for (let i = 0; i < 8; i++) await draw();
|
|
285
|
+
await shot('taa-sealed');
|
|
286
|
+
expect(renderer.inspect().temporal.mode).toBe('taa');
|
|
287
|
+
expect(renderer.inspect().temporal.historyValid).toBe(true);
|
|
288
|
+
expect(renderer.inspect().temporal.frameIndex).toBeGreaterThanOrEqual(7);
|
|
289
|
+
const taaCandidate = renderer
|
|
290
|
+
.prepareDynamicGeometry({
|
|
291
|
+
world,
|
|
292
|
+
entity,
|
|
293
|
+
physicsEntity: parent,
|
|
294
|
+
mesh: openMesh,
|
|
295
|
+
meshHandle: openHandle,
|
|
296
|
+
revision: 5,
|
|
297
|
+
})
|
|
298
|
+
.unwrap();
|
|
299
|
+
physics
|
|
300
|
+
.admitDerivedShapeCandidate(
|
|
301
|
+
physics.prepareDerivedShapeCandidate(physicsInput(5, true)).unwrap(),
|
|
302
|
+
() => {
|
|
303
|
+
const result = renderer.acceptDynamicGeometry(taaCandidate, {
|
|
304
|
+
world,
|
|
305
|
+
fixedStep: world.getResource(FixedTime).tick,
|
|
306
|
+
});
|
|
307
|
+
return result.ok ? ok(undefined) : err(result.error);
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
.unwrap();
|
|
311
|
+
world.update(1 / 60).unwrap();
|
|
312
|
+
await draw();
|
|
313
|
+
expect(renderer.inspect().temporal.frameIndex).toBe(0);
|
|
314
|
+
await shot('taa-open-first');
|
|
315
|
+
expect(errors).toEqual([]);
|
|
316
|
+
} finally {
|
|
317
|
+
unsubscribe();
|
|
318
|
+
await renderer.dispose();
|
|
319
|
+
releasePhysics();
|
|
320
|
+
const entities = [...world.query({ read: [Transform] }).unwrap()].map((row) => row.entity);
|
|
321
|
+
for (const entity of entities.reverse()) world.despawn(entity).unwrap();
|
|
322
|
+
await scene.fiber.dispose();
|
|
323
|
+
physics.dispose();
|
|
324
|
+
canvas.remove();
|
|
325
|
+
}
|
|
326
|
+
}, 60000);
|