@ludra/three 0.91.0 → 0.93.0
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/dist/index.d.ts +25 -1
- package/dist/index.js +228 -16
- package/package.json +2 -2
- package/src/index.ts +236 -7
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,18 @@ export declare class ThreeRenderer implements Renderer3D {
|
|
|
25
25
|
private readonly objects;
|
|
26
26
|
private readonly lights;
|
|
27
27
|
private readonly textures;
|
|
28
|
+
/** Loaded glTF files by path; null when one failed to load (its entities fall back to a shape). */
|
|
29
|
+
private readonly gltfs;
|
|
30
|
+
private readonly gltfLoads;
|
|
31
|
+
private gltfLoader;
|
|
32
|
+
private readonly models;
|
|
33
|
+
/** Entities drawn with a placeholder while their model loads. */
|
|
34
|
+
private readonly waiting;
|
|
35
|
+
/**
|
|
36
|
+
* The clips each modelled entity played, recorded once per simulation step (not per frame), so a
|
|
37
|
+
* pose depends only on the simulation: a screenshot shows what the live game shows at that moment.
|
|
38
|
+
*/
|
|
39
|
+
private readonly clipHistory;
|
|
28
40
|
private readonly debugGroup;
|
|
29
41
|
private readonly defaultLights;
|
|
30
42
|
private rig;
|
|
@@ -38,8 +50,10 @@ export declare class ThreeRenderer implements Renderer3D {
|
|
|
38
50
|
private readonly def;
|
|
39
51
|
private readonly assetBase;
|
|
40
52
|
constructor(def: SceneDef, width: number, height: number, assetBase: string);
|
|
41
|
-
/** Loads every texture the scene names, so the first frame (and screenshots) are complete. */
|
|
53
|
+
/** Loads every texture and model the scene (and its prefabs) names, so the first frame (and screenshots) are complete. */
|
|
42
54
|
load(): Promise<void>;
|
|
55
|
+
/** Loads a glTF file once. Draco and meshopt compressed files work too. */
|
|
56
|
+
private loadModel;
|
|
43
57
|
private applyEnvironment;
|
|
44
58
|
step(world: WorldView): void;
|
|
45
59
|
reset(world: WorldView): void;
|
|
@@ -50,10 +64,20 @@ export declare class ThreeRenderer implements Renderer3D {
|
|
|
50
64
|
/** Scripts' render hooks: the real three.js, this entity's group, the scene, camera and renderer. */
|
|
51
65
|
private runRenderHooks;
|
|
52
66
|
dispose(): void;
|
|
67
|
+
/** Notes which clip every modelled entity should play now, and when that clip started. */
|
|
68
|
+
private recordClips;
|
|
69
|
+
/** Poses every model for this moment: a pure function of simulation time and the clip history. */
|
|
70
|
+
private poseModels;
|
|
71
|
+
private modelHandle;
|
|
53
72
|
/** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
|
|
54
73
|
private sync;
|
|
55
74
|
private addDefaultLights;
|
|
56
75
|
private createMesh;
|
|
76
|
+
/**
|
|
77
|
+
* A copy of the model (bones included), turned, fitted to the entity and offset. The returned group
|
|
78
|
+
* is placed like any mesh; inside it a pivot holds the fit, so scripts can still move the root.
|
|
79
|
+
*/
|
|
80
|
+
private createModel;
|
|
57
81
|
private placeLight;
|
|
58
82
|
/** Where the camera wants to be this step, eased by its smoothing. Deterministic per step. */
|
|
59
83
|
private updateRig;
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
import * as THREE from "three";
|
|
10
10
|
import { ConvexGeometry } from "three/addons/geometries/ConvexGeometry.js";
|
|
11
|
+
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
|
|
12
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
13
|
+
import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
|
|
14
|
+
import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js";
|
|
11
15
|
import { FIXED_DT, } from "@ludra/core";
|
|
12
16
|
const DEG = Math.PI / 180;
|
|
13
17
|
const TONE_MAPPING = {
|
|
@@ -34,6 +38,18 @@ export class ThreeRenderer {
|
|
|
34
38
|
objects = new Map();
|
|
35
39
|
lights = new Map();
|
|
36
40
|
textures = new Map();
|
|
41
|
+
/** Loaded glTF files by path; null when one failed to load (its entities fall back to a shape). */
|
|
42
|
+
gltfs = new Map();
|
|
43
|
+
gltfLoads = new Map();
|
|
44
|
+
gltfLoader;
|
|
45
|
+
models = new Map();
|
|
46
|
+
/** Entities drawn with a placeholder while their model loads. */
|
|
47
|
+
waiting = new Set();
|
|
48
|
+
/**
|
|
49
|
+
* The clips each modelled entity played, recorded once per simulation step (not per frame), so a
|
|
50
|
+
* pose depends only on the simulation: a screenshot shows what the live game shows at that moment.
|
|
51
|
+
*/
|
|
52
|
+
clipHistory = new Map();
|
|
37
53
|
debugGroup = new THREE.Group();
|
|
38
54
|
defaultLights = new THREE.Group();
|
|
39
55
|
rig;
|
|
@@ -66,22 +82,48 @@ export class ThreeRenderer {
|
|
|
66
82
|
this.applyEnvironment(env);
|
|
67
83
|
this.scene.add(this.debugGroup, this.defaultLights);
|
|
68
84
|
}
|
|
69
|
-
/** Loads every texture the scene names, so the first frame (and screenshots) are complete. */
|
|
85
|
+
/** Loads every texture and model the scene (and its prefabs) names, so the first frame (and screenshots) are complete. */
|
|
70
86
|
async load() {
|
|
71
87
|
const loader = new THREE.TextureLoader();
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
const meshes = [...this.def.entities, ...Object.values(this.def.prefabs ?? {})].map((e) => e.components?.mesh);
|
|
89
|
+
const named = (field) => new Set(meshes.map((m) => m?.[field]).filter((p) => typeof p === "string" && p !== ""));
|
|
90
|
+
const paths = named("texture");
|
|
91
|
+
const models = [...named("model")].map((path) => this.loadModel(path));
|
|
92
|
+
await Promise.all([
|
|
93
|
+
...models,
|
|
94
|
+
...[...paths].map(async (path) => {
|
|
95
|
+
try {
|
|
96
|
+
const texture = await loader.loadAsync(this.assetBase + path);
|
|
97
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
98
|
+
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
|
99
|
+
texture.anisotropy = 4;
|
|
100
|
+
this.textures.set(path, texture);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
console.warn(`[ludra] could not load texture "${path}", using the mesh color instead.`);
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
/** Loads a glTF file once. Draco and meshopt compressed files work too. */
|
|
109
|
+
loadModel(path) {
|
|
110
|
+
let load = this.gltfLoads.get(path);
|
|
111
|
+
if (load)
|
|
112
|
+
return load;
|
|
113
|
+
if (!this.gltfLoader) {
|
|
114
|
+
this.gltfLoader = new GLTFLoader();
|
|
115
|
+
const draco = new DRACOLoader();
|
|
116
|
+
// The decoder sits in three's addons, which the import map serves.
|
|
117
|
+
draco.setDecoderPath(new URL("../libs/draco/gltf/", import.meta.resolve("three/addons/loaders/DRACOLoader.js")).href);
|
|
118
|
+
this.gltfLoader.setDRACOLoader(draco);
|
|
119
|
+
this.gltfLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
120
|
+
}
|
|
121
|
+
load = this.track(this.gltfLoader.loadAsync(this.assetBase + path).then((gltf) => void this.gltfs.set(path, gltf), (err) => {
|
|
122
|
+
console.warn(`[ludra] could not load model "${path}" (${err?.message ?? err}), drawing its shape instead.`);
|
|
123
|
+
this.gltfs.set(path, null);
|
|
84
124
|
}));
|
|
125
|
+
this.gltfLoads.set(path, load);
|
|
126
|
+
return load;
|
|
85
127
|
}
|
|
86
128
|
applyEnvironment(env) {
|
|
87
129
|
const sky = env.sky ?? ["#6fa8ff", "#dfeaff"];
|
|
@@ -109,9 +151,12 @@ export class ThreeRenderer {
|
|
|
109
151
|
}
|
|
110
152
|
step(world) {
|
|
111
153
|
this.updateRig(world, FIXED_DT);
|
|
154
|
+
this.recordClips(world);
|
|
112
155
|
}
|
|
113
156
|
reset(world) {
|
|
114
157
|
this.rig = undefined;
|
|
158
|
+
this.clipHistory.clear();
|
|
159
|
+
this.recordClips(world);
|
|
115
160
|
// Things render hooks built belong to the old run.
|
|
116
161
|
for (const group of this.hookGroups.values())
|
|
117
162
|
group.removeFromParent();
|
|
@@ -127,6 +172,7 @@ export class ThreeRenderer {
|
|
|
127
172
|
const rig = this.rig;
|
|
128
173
|
this.camera.position.copy(rig.position);
|
|
129
174
|
this.camera.lookAt(rig.look);
|
|
175
|
+
this.poseModels(world);
|
|
130
176
|
this.runRenderHooks(world);
|
|
131
177
|
if (this.customRender)
|
|
132
178
|
this.customRender();
|
|
@@ -176,6 +222,7 @@ export class ThreeRenderer {
|
|
|
176
222
|
camera: this.camera,
|
|
177
223
|
renderer: this.renderer,
|
|
178
224
|
object: group,
|
|
225
|
+
model: this.modelHandle(e),
|
|
179
226
|
time: world.time ?? 0,
|
|
180
227
|
local: this.hookLocals.get(e),
|
|
181
228
|
view: { width: this.canvas.width, height: this.canvas.height },
|
|
@@ -202,15 +249,76 @@ export class ThreeRenderer {
|
|
|
202
249
|
texture.dispose();
|
|
203
250
|
this.renderer.dispose();
|
|
204
251
|
}
|
|
252
|
+
// ---------------------------------------------------------------- animation
|
|
253
|
+
/** Notes which clip every modelled entity should play now, and when that clip started. */
|
|
254
|
+
recordClips(world) {
|
|
255
|
+
const time = world.time ?? 0;
|
|
256
|
+
const live = new Set(world.entities);
|
|
257
|
+
for (const entity of this.clipHistory.keys())
|
|
258
|
+
if (!live.has(entity))
|
|
259
|
+
this.clipHistory.delete(entity);
|
|
260
|
+
for (const e of world.entities) {
|
|
261
|
+
if (!e.mesh?.model)
|
|
262
|
+
continue;
|
|
263
|
+
const { clip, once } = chooseClip(e);
|
|
264
|
+
const history = this.clipHistory.get(e) ?? [];
|
|
265
|
+
const last = history[history.length - 1];
|
|
266
|
+
if (last && last.clip === clip)
|
|
267
|
+
continue;
|
|
268
|
+
history.push({ clip, since: time, once });
|
|
269
|
+
// Only the newest two matter: the clip playing and the one it blends out of.
|
|
270
|
+
if (history.length > 2)
|
|
271
|
+
history.shift();
|
|
272
|
+
this.clipHistory.set(e, history);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Poses every model for this moment: a pure function of simulation time and the clip history. */
|
|
276
|
+
poseModels(world) {
|
|
277
|
+
const time = world.time ?? 0;
|
|
278
|
+
for (const [e, model] of this.models) {
|
|
279
|
+
if (model.actions.size === 0)
|
|
280
|
+
continue;
|
|
281
|
+
const spec = e.mesh;
|
|
282
|
+
const history = this.clipHistory.get(e) ?? [];
|
|
283
|
+
const current = history[history.length - 1];
|
|
284
|
+
const previous = history[history.length - 2];
|
|
285
|
+
const blend = !current || !previous || spec.fade <= 0 ? 1 : Math.min(1, (time - current.since) / spec.fade);
|
|
286
|
+
for (const [name, action] of model.actions) {
|
|
287
|
+
const entry = name === current?.clip ? current : name === previous?.clip && blend < 1 ? previous : undefined;
|
|
288
|
+
if (!entry) {
|
|
289
|
+
action.setEffectiveWeight(0);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const duration = action.getClip().duration;
|
|
293
|
+
const elapsed = Math.max(0, time - entry.since) * spec.animationSpeed;
|
|
294
|
+
action.time = duration <= 0 ? 0 : entry.once ? Math.min(elapsed, duration - 1e-4) : elapsed % duration;
|
|
295
|
+
action.setEffectiveWeight(entry === current ? blend : 1 - blend);
|
|
296
|
+
}
|
|
297
|
+
if (current && current.clip !== "" && !model.actions.has(current.clip) && !warnedClips.has(`${e.id}:${current.clip}`)) {
|
|
298
|
+
warnedClips.add(`${e.id}:${current.clip}`);
|
|
299
|
+
console.warn(`[ludra] entity "${e.id}" asked for the clip "${current.clip}", which its model doesn't have. Clips: ${[...model.actions.keys()].join(", ") || "none"}.`);
|
|
300
|
+
}
|
|
301
|
+
model.mixer.update(0);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
modelHandle(e) {
|
|
305
|
+
const model = this.models.get(e);
|
|
306
|
+
return model && { root: model.root, clips: model.clips, mixer: model.mixer };
|
|
307
|
+
}
|
|
205
308
|
// ---------------------------------------------------------------- objects
|
|
206
309
|
/** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
|
|
207
310
|
sync(world) {
|
|
208
311
|
const live = new Set(world.entities);
|
|
209
312
|
for (const [entity, object] of this.objects) {
|
|
210
|
-
|
|
313
|
+
// A model that finished loading replaces its placeholder.
|
|
314
|
+
const ready = this.waiting.has(entity) && this.gltfs.has(entity.mesh.model);
|
|
315
|
+
if (live.has(entity) && !ready)
|
|
211
316
|
continue;
|
|
212
317
|
object.removeFromParent();
|
|
213
318
|
this.objects.delete(entity);
|
|
319
|
+
this.models.get(entity)?.mixer.stopAllAction();
|
|
320
|
+
this.models.delete(entity);
|
|
321
|
+
this.waiting.delete(entity);
|
|
214
322
|
}
|
|
215
323
|
for (const [entity, light] of this.lights) {
|
|
216
324
|
if (live.has(entity))
|
|
@@ -265,6 +373,19 @@ export class ThreeRenderer {
|
|
|
265
373
|
}
|
|
266
374
|
createMesh(e) {
|
|
267
375
|
const spec = e.mesh;
|
|
376
|
+
if (spec.model) {
|
|
377
|
+
const gltf = this.gltfs.get(spec.model);
|
|
378
|
+
if (gltf)
|
|
379
|
+
return this.createModel(e, gltf);
|
|
380
|
+
if (gltf === undefined) {
|
|
381
|
+
// Not loaded yet (a spawned entity whose model the scene never named): an empty stand-in until it is.
|
|
382
|
+
void this.loadModel(spec.model);
|
|
383
|
+
this.waiting.add(e);
|
|
384
|
+
const placeholder = new THREE.Group();
|
|
385
|
+
placeholder.name = e.id;
|
|
386
|
+
return placeholder;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
268
389
|
const geometry = meshGeometry(spec, e.body);
|
|
269
390
|
const texture = spec.texture ? this.textures.get(spec.texture)?.clone() : undefined;
|
|
270
391
|
if (texture && spec.textureScale > 0) {
|
|
@@ -293,6 +414,57 @@ export class ThreeRenderer {
|
|
|
293
414
|
mesh.name = e.id;
|
|
294
415
|
return mesh;
|
|
295
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* A copy of the model (bones included), turned, fitted to the entity and offset. The returned group
|
|
419
|
+
* is placed like any mesh; inside it a pivot holds the fit, so scripts can still move the root.
|
|
420
|
+
*/
|
|
421
|
+
createModel(e, gltf) {
|
|
422
|
+
const spec = e.mesh;
|
|
423
|
+
const root = cloneSkinned(gltf.scene);
|
|
424
|
+
root.traverse((o) => {
|
|
425
|
+
if (o.isMesh) {
|
|
426
|
+
o.castShadow = spec.castShadow;
|
|
427
|
+
o.receiveShadow = spec.receiveShadow;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
root.rotation.set((spec.turn.x ?? 0) * DEG, (spec.turn.y ?? 0) * DEG, (spec.turn.z ?? 0) * DEG, "YXZ");
|
|
431
|
+
root.updateMatrixWorld(true);
|
|
432
|
+
const box = new THREE.Box3().setFromObject(root, true);
|
|
433
|
+
const size = box.isEmpty() ? new THREE.Vector3(1, 1, 1) : box.getSize(new THREE.Vector3());
|
|
434
|
+
const center = box.isEmpty() ? new THREE.Vector3() : box.getCenter(new THREE.Vector3());
|
|
435
|
+
const w = spec.width || e.body?.width || 0;
|
|
436
|
+
const h = spec.height || e.body?.height || 0;
|
|
437
|
+
const d = spec.depth || e.body?.depth || e.body?.width || 0;
|
|
438
|
+
const fitted = (target, own) => (target > 0 && own > 0 ? target / own : 1);
|
|
439
|
+
const scale = new THREE.Vector3(1, 1, 1);
|
|
440
|
+
if (spec.fit === "height")
|
|
441
|
+
scale.setScalar(fitted(h, size.y));
|
|
442
|
+
else if (spec.fit === "box")
|
|
443
|
+
scale.set(fitted(w, size.x), fitted(h, size.y), fitted(d, size.z));
|
|
444
|
+
scale.multiplyScalar(spec.scale);
|
|
445
|
+
const pivot = new THREE.Group();
|
|
446
|
+
pivot.scale.copy(scale);
|
|
447
|
+
pivot.add(root);
|
|
448
|
+
if (spec.fit !== "none") {
|
|
449
|
+
// Centred on the entity, standing on the bottom of its body (or on its origin without a height).
|
|
450
|
+
root.position.sub(new THREE.Vector3(center.x, box.isEmpty() ? 0 : box.min.y, center.z));
|
|
451
|
+
pivot.position.y = h > 0 ? -h / 2 : 0;
|
|
452
|
+
}
|
|
453
|
+
pivot.position.add(new THREE.Vector3(spec.offset.x ?? 0, spec.offset.y ?? 0, spec.offset.z ?? 0));
|
|
454
|
+
const mixer = new THREE.AnimationMixer(root);
|
|
455
|
+
const actions = new Map();
|
|
456
|
+
for (const clip of gltf.animations) {
|
|
457
|
+
const action = mixer.clipAction(clip);
|
|
458
|
+
action.setEffectiveWeight(0);
|
|
459
|
+
action.play();
|
|
460
|
+
actions.set(clip.name, action);
|
|
461
|
+
}
|
|
462
|
+
this.models.set(e, { root, clips: gltf.animations, mixer, actions });
|
|
463
|
+
const group = new THREE.Group();
|
|
464
|
+
group.name = e.id;
|
|
465
|
+
group.add(pivot);
|
|
466
|
+
return group;
|
|
467
|
+
}
|
|
296
468
|
placeLight(light, e) {
|
|
297
469
|
const t = e.transform;
|
|
298
470
|
const spec = e.light;
|
|
@@ -408,6 +580,36 @@ function recentEvents(world, name, seconds) {
|
|
|
408
580
|
}
|
|
409
581
|
return result.reverse();
|
|
410
582
|
}
|
|
583
|
+
/** Clips already reported missing, so the console says it once. */
|
|
584
|
+
const warnedClips = new Set();
|
|
585
|
+
/**
|
|
586
|
+
* The clip an entity should play: a script's `state.animation`, then `mesh.animation`, then the
|
|
587
|
+
* clip its `animations` map gives the way it moves (in the air: jump while rising, fall after).
|
|
588
|
+
*/
|
|
589
|
+
function chooseClip(e) {
|
|
590
|
+
const spec = e.mesh;
|
|
591
|
+
const scripted = e.state.animation;
|
|
592
|
+
if (typeof scripted === "string" && scripted !== "")
|
|
593
|
+
return { clip: scripted, once: false };
|
|
594
|
+
if (spec.animation)
|
|
595
|
+
return { clip: spec.animation, once: false };
|
|
596
|
+
const map = spec.animations;
|
|
597
|
+
const pick = (...states) => states.map((s) => map[s]).find((c) => c) ?? "";
|
|
598
|
+
const b = e.body;
|
|
599
|
+
if (!b)
|
|
600
|
+
return { clip: pick("idle"), once: false };
|
|
601
|
+
if (!b.grounded && b.type === "dynamic") {
|
|
602
|
+
const clip = b.vy > 0 ? pick("jump", "fall", "idle") : pick("fall", "jump", "idle");
|
|
603
|
+
// A jump plays once and holds its last pose; a fall loops.
|
|
604
|
+
return { clip, once: clip === map.jump && clip !== map.fall };
|
|
605
|
+
}
|
|
606
|
+
const speed = Math.hypot(b.vx, b.vz);
|
|
607
|
+
if (speed > 0.3) {
|
|
608
|
+
const runFrom = (e.character?.speed ?? 5) * 0.6;
|
|
609
|
+
return { clip: speed < runFrom ? pick("walk", "run", "idle") : pick("run", "walk", "idle"), once: false };
|
|
610
|
+
}
|
|
611
|
+
return { clip: pick("idle"), once: false };
|
|
612
|
+
}
|
|
411
613
|
const DEFAULT_CAMERA = { mode: "follow", target: "", fov: 55, distance: 9, height: 4, yaw: 0, orbitSpeed: 20, lookAt: {}, smoothing: 0.12 };
|
|
412
614
|
function placeObject(object, e) {
|
|
413
615
|
const t = e.transform;
|
|
@@ -456,7 +658,7 @@ function createLight(spec) {
|
|
|
456
658
|
/** The mesh's geometry: its own shape and size, or its body's. */
|
|
457
659
|
function meshGeometry(spec, body) {
|
|
458
660
|
const shape = spec.shape === "auto" ? (body ? bodyShape(body) : "box") : spec.shape;
|
|
459
|
-
if (shape === "convex" && body && spec.shape === "auto")
|
|
661
|
+
if ((shape === "convex" || shape === "mesh") && body && spec.shape === "auto")
|
|
460
662
|
return colliderGeometry(body);
|
|
461
663
|
const w = spec.width || body?.width || 1;
|
|
462
664
|
const h = spec.height || body?.height || 1;
|
|
@@ -479,7 +681,7 @@ function meshGeometry(spec, body) {
|
|
|
479
681
|
}
|
|
480
682
|
}
|
|
481
683
|
function bodyShape(body) {
|
|
482
|
-
return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" ? body.shape : "box";
|
|
684
|
+
return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" || body.shape === "mesh" ? body.shape : "box";
|
|
483
685
|
}
|
|
484
686
|
/** The exact collider shape, for the debug view and convex meshes. */
|
|
485
687
|
function colliderGeometry(b) {
|
|
@@ -495,6 +697,16 @@ function colliderGeometry(b) {
|
|
|
495
697
|
if (b.points.length >= 4)
|
|
496
698
|
return new ConvexGeometry(b.points.map(([x, y, z]) => new THREE.Vector3(x, y, z ?? 0)));
|
|
497
699
|
return new THREE.BoxGeometry(b.width, b.height, d);
|
|
700
|
+
case "mesh": {
|
|
701
|
+
if (b.points.length < 3 || b.triangles.length === 0)
|
|
702
|
+
return new THREE.BoxGeometry(b.width, b.height, d);
|
|
703
|
+
// Exactly the collider's triangles, so procedural terrain is drawn as it collides.
|
|
704
|
+
const geometry = new THREE.BufferGeometry();
|
|
705
|
+
geometry.setAttribute("position", new THREE.Float32BufferAttribute(b.points.flatMap(([x, y, z]) => [x, y, z ?? 0]), 3));
|
|
706
|
+
geometry.setIndex(b.triangles.flat());
|
|
707
|
+
geometry.computeVertexNormals();
|
|
708
|
+
return geometry;
|
|
709
|
+
}
|
|
498
710
|
default:
|
|
499
711
|
return new THREE.BoxGeometry(b.width, b.height, d);
|
|
500
712
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ludra/three",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"description": "The 3D renderer for Ludra: Three.js meshes, PBR materials, lights, shadows, cameras and environments for 3D scenes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"src"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ludra/core": "0.
|
|
34
|
+
"@ludra/core": "0.93.0",
|
|
35
35
|
"three": "0.186.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { ConvexGeometry } from "three/addons/geometries/ConvexGeometry.js";
|
|
3
|
+
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
|
|
4
|
+
import { GLTFLoader, type GLTF } from "three/addons/loaders/GLTFLoader.js";
|
|
5
|
+
import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
|
|
6
|
+
import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js";
|
|
3
7
|
import {
|
|
4
8
|
FIXED_DT,
|
|
9
|
+
type AnimationState,
|
|
5
10
|
type Body,
|
|
6
11
|
type Camera3D,
|
|
7
12
|
type DrawEvent,
|
|
@@ -46,6 +51,21 @@ export const createRenderer3D: Renderer3DFactory = async (scene, options) => {
|
|
|
46
51
|
return renderer;
|
|
47
52
|
};
|
|
48
53
|
|
|
54
|
+
/** A drawn glTF model and what poses it. */
|
|
55
|
+
interface Model {
|
|
56
|
+
root: THREE.Object3D;
|
|
57
|
+
clips: THREE.AnimationClip[];
|
|
58
|
+
mixer: THREE.AnimationMixer;
|
|
59
|
+
actions: Map<string, THREE.AnimationAction>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A clip an entity started playing, at simulation time `since`. `once` holds the last pose (a jump). */
|
|
63
|
+
interface ClipStart {
|
|
64
|
+
clip: string;
|
|
65
|
+
since: number;
|
|
66
|
+
once: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
interface Rig {
|
|
50
70
|
position: THREE.Vector3;
|
|
51
71
|
look: THREE.Vector3;
|
|
@@ -65,6 +85,18 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
65
85
|
private readonly objects = new Map<Entity, THREE.Object3D>();
|
|
66
86
|
private readonly lights = new Map<Entity, THREE.Light>();
|
|
67
87
|
private readonly textures = new Map<string, THREE.Texture>();
|
|
88
|
+
/** Loaded glTF files by path; null when one failed to load (its entities fall back to a shape). */
|
|
89
|
+
private readonly gltfs = new Map<string, GLTF | null>();
|
|
90
|
+
private readonly gltfLoads = new Map<string, Promise<void>>();
|
|
91
|
+
private gltfLoader: GLTFLoader | undefined;
|
|
92
|
+
private readonly models = new Map<Entity, Model>();
|
|
93
|
+
/** Entities drawn with a placeholder while their model loads. */
|
|
94
|
+
private readonly waiting = new Set<Entity>();
|
|
95
|
+
/**
|
|
96
|
+
* The clips each modelled entity played, recorded once per simulation step (not per frame), so a
|
|
97
|
+
* pose depends only on the simulation: a screenshot shows what the live game shows at that moment.
|
|
98
|
+
*/
|
|
99
|
+
private readonly clipHistory = new Map<Entity, ClipStart[]>();
|
|
68
100
|
private readonly debugGroup = new THREE.Group();
|
|
69
101
|
private readonly defaultLights = new THREE.Group();
|
|
70
102
|
private rig: Rig | undefined;
|
|
@@ -100,12 +132,16 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
100
132
|
this.scene.add(this.debugGroup, this.defaultLights);
|
|
101
133
|
}
|
|
102
134
|
|
|
103
|
-
/** Loads every texture the scene names, so the first frame (and screenshots) are complete. */
|
|
135
|
+
/** Loads every texture and model the scene (and its prefabs) names, so the first frame (and screenshots) are complete. */
|
|
104
136
|
async load(): Promise<void> {
|
|
105
137
|
const loader = new THREE.TextureLoader();
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
138
|
+
const meshes = [...this.def.entities, ...Object.values(this.def.prefabs ?? {})].map((e) => e.components?.mesh as Record<string, unknown> | undefined);
|
|
139
|
+
const named = (field: string) => new Set(meshes.map((m) => m?.[field]).filter((p): p is string => typeof p === "string" && p !== ""));
|
|
140
|
+
const paths = named("texture");
|
|
141
|
+
const models = [...named("model")].map((path) => this.loadModel(path));
|
|
142
|
+
await Promise.all([
|
|
143
|
+
...models,
|
|
144
|
+
...[...paths].map(async (path) => {
|
|
109
145
|
try {
|
|
110
146
|
const texture = await loader.loadAsync(this.assetBase + path);
|
|
111
147
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
@@ -116,7 +152,32 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
116
152
|
console.warn(`[ludra] could not load texture "${path}", using the mesh color instead.`);
|
|
117
153
|
}
|
|
118
154
|
}),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Loads a glTF file once. Draco and meshopt compressed files work too. */
|
|
159
|
+
private loadModel(path: string): Promise<void> {
|
|
160
|
+
let load = this.gltfLoads.get(path);
|
|
161
|
+
if (load) return load;
|
|
162
|
+
if (!this.gltfLoader) {
|
|
163
|
+
this.gltfLoader = new GLTFLoader();
|
|
164
|
+
const draco = new DRACOLoader();
|
|
165
|
+
// The decoder sits in three's addons, which the import map serves.
|
|
166
|
+
draco.setDecoderPath(new URL("../libs/draco/gltf/", import.meta.resolve("three/addons/loaders/DRACOLoader.js")).href);
|
|
167
|
+
this.gltfLoader.setDRACOLoader(draco);
|
|
168
|
+
this.gltfLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
169
|
+
}
|
|
170
|
+
load = this.track(
|
|
171
|
+
this.gltfLoader.loadAsync(this.assetBase + path).then(
|
|
172
|
+
(gltf) => void this.gltfs.set(path, gltf),
|
|
173
|
+
(err: unknown) => {
|
|
174
|
+
console.warn(`[ludra] could not load model "${path}" (${(err as Error)?.message ?? err}), drawing its shape instead.`);
|
|
175
|
+
this.gltfs.set(path, null);
|
|
176
|
+
},
|
|
177
|
+
),
|
|
119
178
|
);
|
|
179
|
+
this.gltfLoads.set(path, load);
|
|
180
|
+
return load;
|
|
120
181
|
}
|
|
121
182
|
|
|
122
183
|
private applyEnvironment(env: EnvironmentDef): void {
|
|
@@ -145,10 +206,13 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
145
206
|
|
|
146
207
|
step(world: WorldView): void {
|
|
147
208
|
this.updateRig(world, FIXED_DT);
|
|
209
|
+
this.recordClips(world);
|
|
148
210
|
}
|
|
149
211
|
|
|
150
212
|
reset(world: WorldView): void {
|
|
151
213
|
this.rig = undefined;
|
|
214
|
+
this.clipHistory.clear();
|
|
215
|
+
this.recordClips(world);
|
|
152
216
|
// Things render hooks built belong to the old run.
|
|
153
217
|
for (const group of this.hookGroups.values()) group.removeFromParent();
|
|
154
218
|
this.hookGroups.clear();
|
|
@@ -163,6 +227,7 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
163
227
|
const rig = this.rig!;
|
|
164
228
|
this.camera.position.copy(rig.position);
|
|
165
229
|
this.camera.lookAt(rig.look);
|
|
230
|
+
this.poseModels(world);
|
|
166
231
|
this.runRenderHooks(world);
|
|
167
232
|
if (this.customRender) this.customRender();
|
|
168
233
|
else this.renderer.render(this.scene, this.camera);
|
|
@@ -210,6 +275,7 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
210
275
|
camera: this.camera,
|
|
211
276
|
renderer: this.renderer,
|
|
212
277
|
object: group,
|
|
278
|
+
model: this.modelHandle(e),
|
|
213
279
|
time: world.time ?? 0,
|
|
214
280
|
local: this.hookLocals.get(e)!,
|
|
215
281
|
view: { width: this.canvas.width, height: this.canvas.height },
|
|
@@ -235,15 +301,74 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
235
301
|
this.renderer.dispose();
|
|
236
302
|
}
|
|
237
303
|
|
|
304
|
+
// ---------------------------------------------------------------- animation
|
|
305
|
+
|
|
306
|
+
/** Notes which clip every modelled entity should play now, and when that clip started. */
|
|
307
|
+
private recordClips(world: WorldView): void {
|
|
308
|
+
const time = world.time ?? 0;
|
|
309
|
+
const live = new Set(world.entities);
|
|
310
|
+
for (const entity of this.clipHistory.keys()) if (!live.has(entity)) this.clipHistory.delete(entity);
|
|
311
|
+
for (const e of world.entities) {
|
|
312
|
+
if (!e.mesh?.model) continue;
|
|
313
|
+
const { clip, once } = chooseClip(e);
|
|
314
|
+
const history = this.clipHistory.get(e) ?? [];
|
|
315
|
+
const last = history[history.length - 1];
|
|
316
|
+
if (last && last.clip === clip) continue;
|
|
317
|
+
history.push({ clip, since: time, once });
|
|
318
|
+
// Only the newest two matter: the clip playing and the one it blends out of.
|
|
319
|
+
if (history.length > 2) history.shift();
|
|
320
|
+
this.clipHistory.set(e, history);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Poses every model for this moment: a pure function of simulation time and the clip history. */
|
|
325
|
+
private poseModels(world: WorldView): void {
|
|
326
|
+
const time = world.time ?? 0;
|
|
327
|
+
for (const [e, model] of this.models) {
|
|
328
|
+
if (model.actions.size === 0) continue;
|
|
329
|
+
const spec = e.mesh!;
|
|
330
|
+
const history = this.clipHistory.get(e) ?? [];
|
|
331
|
+
const current = history[history.length - 1];
|
|
332
|
+
const previous = history[history.length - 2];
|
|
333
|
+
const blend = !current || !previous || spec.fade <= 0 ? 1 : Math.min(1, (time - current.since) / spec.fade);
|
|
334
|
+
for (const [name, action] of model.actions) {
|
|
335
|
+
const entry = name === current?.clip ? current : name === previous?.clip && blend < 1 ? previous : undefined;
|
|
336
|
+
if (!entry) {
|
|
337
|
+
action.setEffectiveWeight(0);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const duration = action.getClip().duration;
|
|
341
|
+
const elapsed = Math.max(0, time - entry.since) * spec.animationSpeed;
|
|
342
|
+
action.time = duration <= 0 ? 0 : entry.once ? Math.min(elapsed, duration - 1e-4) : elapsed % duration;
|
|
343
|
+
action.setEffectiveWeight(entry === current ? blend : 1 - blend);
|
|
344
|
+
}
|
|
345
|
+
if (current && current.clip !== "" && !model.actions.has(current.clip) && !warnedClips.has(`${e.id}:${current.clip}`)) {
|
|
346
|
+
warnedClips.add(`${e.id}:${current.clip}`);
|
|
347
|
+
console.warn(`[ludra] entity "${e.id}" asked for the clip "${current.clip}", which its model doesn't have. Clips: ${[...model.actions.keys()].join(", ") || "none"}.`);
|
|
348
|
+
}
|
|
349
|
+
model.mixer.update(0);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private modelHandle(e: Entity): RenderContext["model"] {
|
|
354
|
+
const model = this.models.get(e);
|
|
355
|
+
return model && { root: model.root, clips: model.clips, mixer: model.mixer };
|
|
356
|
+
}
|
|
357
|
+
|
|
238
358
|
// ---------------------------------------------------------------- objects
|
|
239
359
|
|
|
240
360
|
/** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
|
|
241
361
|
private sync(world: WorldView): void {
|
|
242
362
|
const live = new Set(world.entities);
|
|
243
363
|
for (const [entity, object] of this.objects) {
|
|
244
|
-
|
|
364
|
+
// A model that finished loading replaces its placeholder.
|
|
365
|
+
const ready = this.waiting.has(entity) && this.gltfs.has(entity.mesh!.model);
|
|
366
|
+
if (live.has(entity) && !ready) continue;
|
|
245
367
|
object.removeFromParent();
|
|
246
368
|
this.objects.delete(entity);
|
|
369
|
+
this.models.get(entity)?.mixer.stopAllAction();
|
|
370
|
+
this.models.delete(entity);
|
|
371
|
+
this.waiting.delete(entity);
|
|
247
372
|
}
|
|
248
373
|
for (const [entity, light] of this.lights) {
|
|
249
374
|
if (live.has(entity)) continue;
|
|
@@ -297,6 +422,18 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
297
422
|
|
|
298
423
|
private createMesh(e: Entity): THREE.Object3D {
|
|
299
424
|
const spec = e.mesh!;
|
|
425
|
+
if (spec.model) {
|
|
426
|
+
const gltf = this.gltfs.get(spec.model);
|
|
427
|
+
if (gltf) return this.createModel(e, gltf);
|
|
428
|
+
if (gltf === undefined) {
|
|
429
|
+
// Not loaded yet (a spawned entity whose model the scene never named): an empty stand-in until it is.
|
|
430
|
+
void this.loadModel(spec.model);
|
|
431
|
+
this.waiting.add(e);
|
|
432
|
+
const placeholder = new THREE.Group();
|
|
433
|
+
placeholder.name = e.id;
|
|
434
|
+
return placeholder;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
300
437
|
const geometry = meshGeometry(spec, e.body);
|
|
301
438
|
const texture = spec.texture ? this.textures.get(spec.texture)?.clone() : undefined;
|
|
302
439
|
if (texture && spec.textureScale > 0) {
|
|
@@ -326,6 +463,60 @@ export class ThreeRenderer implements Renderer3D {
|
|
|
326
463
|
return mesh;
|
|
327
464
|
}
|
|
328
465
|
|
|
466
|
+
/**
|
|
467
|
+
* A copy of the model (bones included), turned, fitted to the entity and offset. The returned group
|
|
468
|
+
* is placed like any mesh; inside it a pivot holds the fit, so scripts can still move the root.
|
|
469
|
+
*/
|
|
470
|
+
private createModel(e: Entity, gltf: GLTF): THREE.Object3D {
|
|
471
|
+
const spec = e.mesh!;
|
|
472
|
+
const root = cloneSkinned(gltf.scene);
|
|
473
|
+
root.traverse((o) => {
|
|
474
|
+
if ((o as THREE.Mesh).isMesh) {
|
|
475
|
+
o.castShadow = spec.castShadow;
|
|
476
|
+
o.receiveShadow = spec.receiveShadow;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
root.rotation.set((spec.turn.x ?? 0) * DEG, (spec.turn.y ?? 0) * DEG, (spec.turn.z ?? 0) * DEG, "YXZ");
|
|
480
|
+
root.updateMatrixWorld(true);
|
|
481
|
+
const box = new THREE.Box3().setFromObject(root, true);
|
|
482
|
+
const size = box.isEmpty() ? new THREE.Vector3(1, 1, 1) : box.getSize(new THREE.Vector3());
|
|
483
|
+
const center = box.isEmpty() ? new THREE.Vector3() : box.getCenter(new THREE.Vector3());
|
|
484
|
+
|
|
485
|
+
const w = spec.width || e.body?.width || 0;
|
|
486
|
+
const h = spec.height || e.body?.height || 0;
|
|
487
|
+
const d = spec.depth || e.body?.depth || e.body?.width || 0;
|
|
488
|
+
const fitted = (target: number, own: number) => (target > 0 && own > 0 ? target / own : 1);
|
|
489
|
+
const scale = new THREE.Vector3(1, 1, 1);
|
|
490
|
+
if (spec.fit === "height") scale.setScalar(fitted(h, size.y));
|
|
491
|
+
else if (spec.fit === "box") scale.set(fitted(w, size.x), fitted(h, size.y), fitted(d, size.z));
|
|
492
|
+
scale.multiplyScalar(spec.scale);
|
|
493
|
+
|
|
494
|
+
const pivot = new THREE.Group();
|
|
495
|
+
pivot.scale.copy(scale);
|
|
496
|
+
pivot.add(root);
|
|
497
|
+
if (spec.fit !== "none") {
|
|
498
|
+
// Centred on the entity, standing on the bottom of its body (or on its origin without a height).
|
|
499
|
+
root.position.sub(new THREE.Vector3(center.x, box.isEmpty() ? 0 : box.min.y, center.z));
|
|
500
|
+
pivot.position.y = h > 0 ? -h / 2 : 0;
|
|
501
|
+
}
|
|
502
|
+
pivot.position.add(new THREE.Vector3(spec.offset.x ?? 0, spec.offset.y ?? 0, spec.offset.z ?? 0));
|
|
503
|
+
|
|
504
|
+
const mixer = new THREE.AnimationMixer(root);
|
|
505
|
+
const actions = new Map<string, THREE.AnimationAction>();
|
|
506
|
+
for (const clip of gltf.animations) {
|
|
507
|
+
const action = mixer.clipAction(clip);
|
|
508
|
+
action.setEffectiveWeight(0);
|
|
509
|
+
action.play();
|
|
510
|
+
actions.set(clip.name, action);
|
|
511
|
+
}
|
|
512
|
+
this.models.set(e, { root, clips: gltf.animations, mixer, actions });
|
|
513
|
+
|
|
514
|
+
const group = new THREE.Group();
|
|
515
|
+
group.name = e.id;
|
|
516
|
+
group.add(pivot);
|
|
517
|
+
return group;
|
|
518
|
+
}
|
|
519
|
+
|
|
329
520
|
private placeLight(light: THREE.Light, e: Entity): void {
|
|
330
521
|
const t = e.transform;
|
|
331
522
|
const spec = e.light!;
|
|
@@ -442,6 +633,35 @@ function recentEvents(world: WorldView, name: string | undefined, seconds: numbe
|
|
|
442
633
|
return result.reverse();
|
|
443
634
|
}
|
|
444
635
|
|
|
636
|
+
/** Clips already reported missing, so the console says it once. */
|
|
637
|
+
const warnedClips = new Set<string>();
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The clip an entity should play: a script's `state.animation`, then `mesh.animation`, then the
|
|
641
|
+
* clip its `animations` map gives the way it moves (in the air: jump while rising, fall after).
|
|
642
|
+
*/
|
|
643
|
+
function chooseClip(e: Entity): { clip: string; once: boolean } {
|
|
644
|
+
const spec = e.mesh!;
|
|
645
|
+
const scripted = e.state.animation;
|
|
646
|
+
if (typeof scripted === "string" && scripted !== "") return { clip: scripted, once: false };
|
|
647
|
+
if (spec.animation) return { clip: spec.animation, once: false };
|
|
648
|
+
const map = spec.animations;
|
|
649
|
+
const pick = (...states: AnimationState[]) => states.map((s) => map[s]).find((c) => c) ?? "";
|
|
650
|
+
const b = e.body;
|
|
651
|
+
if (!b) return { clip: pick("idle"), once: false };
|
|
652
|
+
if (!b.grounded && b.type === "dynamic") {
|
|
653
|
+
const clip = b.vy > 0 ? pick("jump", "fall", "idle") : pick("fall", "jump", "idle");
|
|
654
|
+
// A jump plays once and holds its last pose; a fall loops.
|
|
655
|
+
return { clip, once: clip === map.jump && clip !== map.fall };
|
|
656
|
+
}
|
|
657
|
+
const speed = Math.hypot(b.vx, b.vz);
|
|
658
|
+
if (speed > 0.3) {
|
|
659
|
+
const runFrom = (e.character?.speed ?? 5) * 0.6;
|
|
660
|
+
return { clip: speed < runFrom ? pick("walk", "run", "idle") : pick("run", "walk", "idle"), once: false };
|
|
661
|
+
}
|
|
662
|
+
return { clip: pick("idle"), once: false };
|
|
663
|
+
}
|
|
664
|
+
|
|
445
665
|
const DEFAULT_CAMERA: Camera3D = { mode: "follow", target: "", fov: 55, distance: 9, height: 4, yaw: 0, orbitSpeed: 20, lookAt: {}, smoothing: 0.12 };
|
|
446
666
|
|
|
447
667
|
function placeObject(object: THREE.Object3D, e: Entity): void {
|
|
@@ -493,7 +713,7 @@ function createLight(spec: LightSpec): THREE.Light {
|
|
|
493
713
|
/** The mesh's geometry: its own shape and size, or its body's. */
|
|
494
714
|
function meshGeometry(spec: MeshSpec, body: Body | undefined): THREE.BufferGeometry {
|
|
495
715
|
const shape = spec.shape === "auto" ? (body ? bodyShape(body) : "box") : spec.shape;
|
|
496
|
-
if (shape === "convex" && body && spec.shape === "auto") return colliderGeometry(body);
|
|
716
|
+
if ((shape === "convex" || shape === "mesh") && body && spec.shape === "auto") return colliderGeometry(body);
|
|
497
717
|
const w = spec.width || body?.width || 1;
|
|
498
718
|
const h = spec.height || body?.height || 1;
|
|
499
719
|
const d = spec.depth || (body ? body.depth || body.width : w);
|
|
@@ -516,7 +736,7 @@ function meshGeometry(spec: MeshSpec, body: Body | undefined): THREE.BufferGeome
|
|
|
516
736
|
}
|
|
517
737
|
|
|
518
738
|
function bodyShape(body: Body): string {
|
|
519
|
-
return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" ? body.shape : "box";
|
|
739
|
+
return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" || body.shape === "mesh" ? body.shape : "box";
|
|
520
740
|
}
|
|
521
741
|
|
|
522
742
|
/** The exact collider shape, for the debug view and convex meshes. */
|
|
@@ -532,6 +752,15 @@ function colliderGeometry(b: Body): THREE.BufferGeometry {
|
|
|
532
752
|
case "convex":
|
|
533
753
|
if (b.points.length >= 4) return new ConvexGeometry(b.points.map(([x, y, z]) => new THREE.Vector3(x, y, z ?? 0)));
|
|
534
754
|
return new THREE.BoxGeometry(b.width, b.height, d);
|
|
755
|
+
case "mesh": {
|
|
756
|
+
if (b.points.length < 3 || b.triangles.length === 0) return new THREE.BoxGeometry(b.width, b.height, d);
|
|
757
|
+
// Exactly the collider's triangles, so procedural terrain is drawn as it collides.
|
|
758
|
+
const geometry = new THREE.BufferGeometry();
|
|
759
|
+
geometry.setAttribute("position", new THREE.Float32BufferAttribute(b.points.flatMap(([x, y, z]) => [x!, y!, z ?? 0]), 3));
|
|
760
|
+
geometry.setIndex(b.triangles.flat());
|
|
761
|
+
geometry.computeVertexNormals();
|
|
762
|
+
return geometry;
|
|
763
|
+
}
|
|
535
764
|
default:
|
|
536
765
|
return new THREE.BoxGeometry(b.width, b.height, d);
|
|
537
766
|
}
|