@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 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 paths = new Set(this.def.entities.map((e) => e.components.mesh?.texture).filter((p) => typeof p === "string" && p !== ""));
73
- await Promise.all([...paths].map(async (path) => {
74
- try {
75
- const texture = await loader.loadAsync(this.assetBase + path);
76
- texture.colorSpace = THREE.SRGBColorSpace;
77
- texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
78
- texture.anisotropy = 4;
79
- this.textures.set(path, texture);
80
- }
81
- catch {
82
- console.warn(`[ludra] could not load texture "${path}", using the mesh color instead.`);
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
- if (live.has(entity))
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.91.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.91.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 paths = new Set(this.def.entities.map((e) => e.components.mesh?.texture).filter((p): p is string => typeof p === "string" && p !== ""));
107
- await Promise.all(
108
- [...paths].map(async (path) => {
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
- if (live.has(entity)) continue;
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
  }