@ludra/three 0.0.0-stage → 0.92.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/LICENSE +21 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +713 -0
- package/package.json +49 -4
- package/src/index.ts +767 -0
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import * as THREE from "three";
|
|
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";
|
|
15
|
+
import { FIXED_DT, } from "@ludra/core";
|
|
16
|
+
const DEG = Math.PI / 180;
|
|
17
|
+
const TONE_MAPPING = {
|
|
18
|
+
aces: THREE.ACESFilmicToneMapping,
|
|
19
|
+
neutral: THREE.NeutralToneMapping,
|
|
20
|
+
agx: THREE.AgXToneMapping,
|
|
21
|
+
none: THREE.NoToneMapping,
|
|
22
|
+
};
|
|
23
|
+
/** Collider colors in the debug view, as in 2D: static, dynamic, kinematic, sensor, player. */
|
|
24
|
+
const DEBUG_COLORS = { static: 0x7fdbff, dynamic: 0xffdc00, kinematic: 0xff851b, sensor: 0x2ecc40, player: 0xff4136 };
|
|
25
|
+
/** Creates the 3D renderer for a scene. Pass it to startGame as `loadRenderer3D`. */
|
|
26
|
+
export const createRenderer3D = async (scene, options) => {
|
|
27
|
+
const renderer = new ThreeRenderer(scene, options.width, options.height, options.assetBase);
|
|
28
|
+
await renderer.load();
|
|
29
|
+
return renderer;
|
|
30
|
+
};
|
|
31
|
+
export class ThreeRenderer {
|
|
32
|
+
canvas;
|
|
33
|
+
debug = false;
|
|
34
|
+
/** Three.js itself, for render hooks and anything else (step 4 hands these to scripts). */
|
|
35
|
+
renderer;
|
|
36
|
+
scene = new THREE.Scene();
|
|
37
|
+
camera;
|
|
38
|
+
objects = new Map();
|
|
39
|
+
lights = new Map();
|
|
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();
|
|
53
|
+
debugGroup = new THREE.Group();
|
|
54
|
+
defaultLights = new THREE.Group();
|
|
55
|
+
rig;
|
|
56
|
+
/** Each render-hook entity's own group, and its ctx.local. */
|
|
57
|
+
hookGroups = new Map();
|
|
58
|
+
hookLocals = new Map();
|
|
59
|
+
/** A render hook's own way of drawing the frame (an EffectComposer), if one set it. */
|
|
60
|
+
customRender;
|
|
61
|
+
/** Addons render hooks are still loading. */
|
|
62
|
+
pending = new Set();
|
|
63
|
+
def;
|
|
64
|
+
assetBase;
|
|
65
|
+
constructor(def, width, height, assetBase) {
|
|
66
|
+
this.def = def;
|
|
67
|
+
this.assetBase = assetBase;
|
|
68
|
+
this.canvas = document.createElement("canvas");
|
|
69
|
+
this.canvas.width = width;
|
|
70
|
+
this.canvas.height = height;
|
|
71
|
+
// preserveDrawingBuffer: the frame is copied into the game's 2D canvas after drawing.
|
|
72
|
+
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, preserveDrawingBuffer: true });
|
|
73
|
+
this.renderer.setPixelRatio(1);
|
|
74
|
+
this.renderer.setSize(width, height, false);
|
|
75
|
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
76
|
+
const env = def.environment ?? {};
|
|
77
|
+
this.renderer.toneMapping = TONE_MAPPING[env.toneMapping ?? "aces"];
|
|
78
|
+
this.renderer.toneMappingExposure = env.exposure ?? 1;
|
|
79
|
+
this.renderer.shadowMap.enabled = env.shadows ?? true;
|
|
80
|
+
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
81
|
+
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.05, 2000);
|
|
82
|
+
this.applyEnvironment(env);
|
|
83
|
+
this.scene.add(this.debugGroup, this.defaultLights);
|
|
84
|
+
}
|
|
85
|
+
/** Loads every texture and model the scene (and its prefabs) names, so the first frame (and screenshots) are complete. */
|
|
86
|
+
async load() {
|
|
87
|
+
const loader = new THREE.TextureLoader();
|
|
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);
|
|
124
|
+
}));
|
|
125
|
+
this.gltfLoads.set(path, load);
|
|
126
|
+
return load;
|
|
127
|
+
}
|
|
128
|
+
applyEnvironment(env) {
|
|
129
|
+
const sky = env.sky ?? ["#6fa8ff", "#dfeaff"];
|
|
130
|
+
if (typeof sky === "string")
|
|
131
|
+
this.scene.background = new THREE.Color(sky);
|
|
132
|
+
else {
|
|
133
|
+
// A vertical gradient: top of the sky to the horizon.
|
|
134
|
+
const c = document.createElement("canvas");
|
|
135
|
+
c.width = 2;
|
|
136
|
+
c.height = 256;
|
|
137
|
+
const g = c.getContext("2d");
|
|
138
|
+
const gradient = g.createLinearGradient(0, 0, 0, 256);
|
|
139
|
+
gradient.addColorStop(0, sky[0]);
|
|
140
|
+
gradient.addColorStop(1, sky[1]);
|
|
141
|
+
g.fillStyle = gradient;
|
|
142
|
+
g.fillRect(0, 0, 2, 256);
|
|
143
|
+
const texture = new THREE.CanvasTexture(c);
|
|
144
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
145
|
+
this.scene.background = texture;
|
|
146
|
+
}
|
|
147
|
+
if (env.fog) {
|
|
148
|
+
const color = env.fog.color ?? (typeof sky === "string" ? sky : sky[1]);
|
|
149
|
+
this.scene.fog = new THREE.Fog(color, env.fog.near ?? 30, env.fog.far ?? 150);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
step(world) {
|
|
153
|
+
this.updateRig(world, FIXED_DT);
|
|
154
|
+
this.recordClips(world);
|
|
155
|
+
}
|
|
156
|
+
reset(world) {
|
|
157
|
+
this.rig = undefined;
|
|
158
|
+
this.clipHistory.clear();
|
|
159
|
+
this.recordClips(world);
|
|
160
|
+
// Things render hooks built belong to the old run.
|
|
161
|
+
for (const group of this.hookGroups.values())
|
|
162
|
+
group.removeFromParent();
|
|
163
|
+
this.hookGroups.clear();
|
|
164
|
+
this.hookLocals.clear();
|
|
165
|
+
this.customRender = undefined;
|
|
166
|
+
this.updateRig(world, 0);
|
|
167
|
+
}
|
|
168
|
+
render(world) {
|
|
169
|
+
if (!this.rig)
|
|
170
|
+
this.updateRig(world, 0);
|
|
171
|
+
this.sync(world);
|
|
172
|
+
const rig = this.rig;
|
|
173
|
+
this.camera.position.copy(rig.position);
|
|
174
|
+
this.camera.lookAt(rig.look);
|
|
175
|
+
this.poseModels(world);
|
|
176
|
+
this.runRenderHooks(world);
|
|
177
|
+
if (this.customRender)
|
|
178
|
+
this.customRender();
|
|
179
|
+
else
|
|
180
|
+
this.renderer.render(this.scene, this.camera);
|
|
181
|
+
}
|
|
182
|
+
/** Waits for every addon render hooks asked for, and for what their callbacks start in turn. */
|
|
183
|
+
async settle() {
|
|
184
|
+
for (let round = 0; round < 5 && this.pending.size > 0; round++) {
|
|
185
|
+
await Promise.allSettled([...this.pending]);
|
|
186
|
+
// Let .then callbacks run (they often load the next thing or set a custom render).
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
track(promise) {
|
|
191
|
+
this.pending.add(promise);
|
|
192
|
+
void promise.finally(() => this.pending.delete(promise)).catch(() => { });
|
|
193
|
+
return promise;
|
|
194
|
+
}
|
|
195
|
+
/** Scripts' render hooks: the real three.js, this entity's group, the scene, camera and renderer. */
|
|
196
|
+
runRenderHooks(world) {
|
|
197
|
+
const live = new Set(world.entities);
|
|
198
|
+
for (const [entity, group] of this.hookGroups) {
|
|
199
|
+
if (live.has(entity))
|
|
200
|
+
continue;
|
|
201
|
+
group.removeFromParent();
|
|
202
|
+
this.hookGroups.delete(entity);
|
|
203
|
+
this.hookLocals.delete(entity);
|
|
204
|
+
}
|
|
205
|
+
if (!world.callRenderHook)
|
|
206
|
+
return;
|
|
207
|
+
for (const e of world.entities) {
|
|
208
|
+
if (!e.script?.module.render)
|
|
209
|
+
continue;
|
|
210
|
+
let group = this.hookGroups.get(e);
|
|
211
|
+
if (!group) {
|
|
212
|
+
group = new THREE.Group();
|
|
213
|
+
group.name = `${e.id}:render`;
|
|
214
|
+
this.hookGroups.set(e, group);
|
|
215
|
+
this.hookLocals.set(e, {});
|
|
216
|
+
this.scene.add(group);
|
|
217
|
+
}
|
|
218
|
+
placeObject(group, e);
|
|
219
|
+
world.callRenderHook(e, {
|
|
220
|
+
THREE,
|
|
221
|
+
scene: this.scene,
|
|
222
|
+
camera: this.camera,
|
|
223
|
+
renderer: this.renderer,
|
|
224
|
+
object: group,
|
|
225
|
+
model: this.modelHandle(e),
|
|
226
|
+
time: world.time ?? 0,
|
|
227
|
+
local: this.hookLocals.get(e),
|
|
228
|
+
view: { width: this.canvas.width, height: this.canvas.height },
|
|
229
|
+
recent: (name, seconds = 0.5) => recentEvents(world, name, seconds),
|
|
230
|
+
addon: (path) => this.track(import(__rewriteRelativeImportExtension(`three/addons/${path}`))),
|
|
231
|
+
setRender: (fn) => {
|
|
232
|
+
this.customRender = fn;
|
|
233
|
+
},
|
|
234
|
+
random: Math.random,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
dispose() {
|
|
239
|
+
this.scene.traverse((o) => {
|
|
240
|
+
const mesh = o;
|
|
241
|
+
mesh.geometry?.dispose();
|
|
242
|
+
const material = mesh.material;
|
|
243
|
+
if (Array.isArray(material))
|
|
244
|
+
material.forEach((m) => m.dispose());
|
|
245
|
+
else
|
|
246
|
+
material?.dispose();
|
|
247
|
+
});
|
|
248
|
+
for (const texture of this.textures.values())
|
|
249
|
+
texture.dispose();
|
|
250
|
+
this.renderer.dispose();
|
|
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
|
+
}
|
|
308
|
+
// ---------------------------------------------------------------- objects
|
|
309
|
+
/** Brings Three's scene in line with the world: new entities, removed ones, every transform. */
|
|
310
|
+
sync(world) {
|
|
311
|
+
const live = new Set(world.entities);
|
|
312
|
+
for (const [entity, object] of this.objects) {
|
|
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)
|
|
316
|
+
continue;
|
|
317
|
+
object.removeFromParent();
|
|
318
|
+
this.objects.delete(entity);
|
|
319
|
+
this.models.get(entity)?.mixer.stopAllAction();
|
|
320
|
+
this.models.delete(entity);
|
|
321
|
+
this.waiting.delete(entity);
|
|
322
|
+
}
|
|
323
|
+
for (const [entity, light] of this.lights) {
|
|
324
|
+
if (live.has(entity))
|
|
325
|
+
continue;
|
|
326
|
+
light.removeFromParent();
|
|
327
|
+
this.lights.delete(entity);
|
|
328
|
+
}
|
|
329
|
+
let lit = false;
|
|
330
|
+
for (const e of world.entities) {
|
|
331
|
+
if (e.mesh) {
|
|
332
|
+
let object = this.objects.get(e);
|
|
333
|
+
if (!object) {
|
|
334
|
+
object = this.createMesh(e);
|
|
335
|
+
this.objects.set(e, object);
|
|
336
|
+
this.scene.add(object);
|
|
337
|
+
}
|
|
338
|
+
object.visible = e.mesh.visible;
|
|
339
|
+
placeObject(object, e);
|
|
340
|
+
}
|
|
341
|
+
if (e.light) {
|
|
342
|
+
lit = true;
|
|
343
|
+
let light = this.lights.get(e);
|
|
344
|
+
if (!light) {
|
|
345
|
+
light = createLight(e.light);
|
|
346
|
+
this.lights.set(e, light);
|
|
347
|
+
this.scene.add(light);
|
|
348
|
+
if (light instanceof THREE.DirectionalLight || light instanceof THREE.SpotLight)
|
|
349
|
+
this.scene.add(light.target);
|
|
350
|
+
}
|
|
351
|
+
this.placeLight(light, e);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// A scene without lights would be black: a soft sky and a sun until it has its own.
|
|
355
|
+
if (!lit && this.defaultLights.children.length === 0)
|
|
356
|
+
this.addDefaultLights();
|
|
357
|
+
this.defaultLights.visible = !lit;
|
|
358
|
+
if (!lit) {
|
|
359
|
+
const sun = this.defaultLights.children.find((c) => c instanceof THREE.DirectionalLight);
|
|
360
|
+
if (sun) {
|
|
361
|
+
sun.position.copy(this.rig.focus).add(new THREE.Vector3(8, 16, 6));
|
|
362
|
+
sun.target.position.copy(this.rig.focus);
|
|
363
|
+
sun.target.updateMatrixWorld();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
this.syncDebug(world);
|
|
367
|
+
}
|
|
368
|
+
addDefaultLights() {
|
|
369
|
+
const sky = new THREE.HemisphereLight("#dfe9ff", "#4a4036", 1.1);
|
|
370
|
+
const sun = new THREE.DirectionalLight("#fff4e0", 2.4);
|
|
371
|
+
configureSun(sun, 40);
|
|
372
|
+
this.defaultLights.add(sky, sun, sun.target);
|
|
373
|
+
}
|
|
374
|
+
createMesh(e) {
|
|
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
|
+
}
|
|
389
|
+
const geometry = meshGeometry(spec, e.body);
|
|
390
|
+
const texture = spec.texture ? this.textures.get(spec.texture)?.clone() : undefined;
|
|
391
|
+
if (texture && spec.textureScale > 0) {
|
|
392
|
+
// Tile the image every textureScale metres over the mesh's two largest sides.
|
|
393
|
+
geometry.computeBoundingBox();
|
|
394
|
+
const size = geometry.boundingBox.getSize(new THREE.Vector3());
|
|
395
|
+
const [a, b] = [size.x, size.y, size.z].sort((p, q) => q - p);
|
|
396
|
+
texture.repeat.set(a / spec.textureScale, b / spec.textureScale);
|
|
397
|
+
texture.needsUpdate = true;
|
|
398
|
+
}
|
|
399
|
+
const material = new THREE.MeshStandardMaterial({
|
|
400
|
+
color: new THREE.Color(spec.color),
|
|
401
|
+
metalness: spec.metalness,
|
|
402
|
+
roughness: spec.roughness,
|
|
403
|
+
emissive: new THREE.Color(spec.emissive),
|
|
404
|
+
emissiveIntensity: spec.emissiveIntensity,
|
|
405
|
+
transparent: spec.opacity < 1,
|
|
406
|
+
opacity: spec.opacity,
|
|
407
|
+
map: texture ?? null,
|
|
408
|
+
flatShading: spec.flatShading,
|
|
409
|
+
wireframe: spec.wireframe,
|
|
410
|
+
});
|
|
411
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
412
|
+
mesh.castShadow = spec.castShadow;
|
|
413
|
+
mesh.receiveShadow = spec.receiveShadow;
|
|
414
|
+
mesh.name = e.id;
|
|
415
|
+
return mesh;
|
|
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
|
+
}
|
|
468
|
+
placeLight(light, e) {
|
|
469
|
+
const t = e.transform;
|
|
470
|
+
const spec = e.light;
|
|
471
|
+
const target = new THREE.Vector3(spec.target.x ?? 0, spec.target.y ?? 0, spec.target.z ?? 0);
|
|
472
|
+
if (light instanceof THREE.DirectionalLight) {
|
|
473
|
+
// Sunlight: keep its direction, but centre the shadow area on what the camera watches.
|
|
474
|
+
const direction = new THREE.Vector3(t.x, t.y, t.z).sub(target);
|
|
475
|
+
const focus = this.rig?.focus ?? target;
|
|
476
|
+
light.position.copy(focus).add(direction);
|
|
477
|
+
light.target.position.copy(focus);
|
|
478
|
+
light.target.updateMatrixWorld();
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
light.position.set(t.x, t.y, t.z);
|
|
482
|
+
if (light instanceof THREE.SpotLight) {
|
|
483
|
+
light.target.position.copy(target);
|
|
484
|
+
light.target.updateMatrixWorld();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// ---------------------------------------------------------------- camera
|
|
489
|
+
/** Where the camera wants to be this step, eased by its smoothing. Deterministic per step. */
|
|
490
|
+
updateRig(world, dt) {
|
|
491
|
+
const cameraEntity = world.entities.find((e) => e.camera3d);
|
|
492
|
+
const spec = cameraEntity?.camera3d ?? DEFAULT_CAMERA;
|
|
493
|
+
const target = (spec.target ? world.entities.find((e) => e.id === spec.target) : undefined) ?? world.entities.find((e) => e.character) ?? undefined;
|
|
494
|
+
const yaw = (this.rig?.yaw ?? spec.yaw) + (spec.mode === "orbit" ? spec.orbitSpeed * dt : 0);
|
|
495
|
+
this.camera.fov = spec.fov;
|
|
496
|
+
this.camera.updateProjectionMatrix();
|
|
497
|
+
let position;
|
|
498
|
+
let look;
|
|
499
|
+
const center = target ? new THREE.Vector3(target.transform.x, target.transform.y, target.transform.z) : new THREE.Vector3();
|
|
500
|
+
if (spec.mode === "fixed") {
|
|
501
|
+
const t = cameraEntity?.transform ?? { x: 0, y: 10, z: 18 };
|
|
502
|
+
position = new THREE.Vector3(t.x, t.y, t.z);
|
|
503
|
+
look = new THREE.Vector3(spec.lookAt.x ?? 0, spec.lookAt.y ?? 0, spec.lookAt.z ?? 0);
|
|
504
|
+
}
|
|
505
|
+
else if (spec.mode === "firstPerson" && target) {
|
|
506
|
+
const eye = (target.body?.height ?? 1.8) * 0.4;
|
|
507
|
+
position = center.clone().add(new THREE.Vector3(0, eye, 0));
|
|
508
|
+
// Facing -z at a turn of 0; a turn of -90 faces +x.
|
|
509
|
+
const turn = target.transform.rotationY * DEG;
|
|
510
|
+
look = position.clone().add(new THREE.Vector3(-Math.sin(turn), 0, -Math.cos(turn)));
|
|
511
|
+
}
|
|
512
|
+
else if (target || spec.mode === "orbit") {
|
|
513
|
+
position = center.clone().add(new THREE.Vector3(Math.sin(yaw * DEG) * spec.distance, spec.height, Math.cos(yaw * DEG) * spec.distance));
|
|
514
|
+
look = center.clone().add(new THREE.Vector3(0, (target?.body?.height ?? 0) * 0.25, 0));
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// Nothing to follow: an overview of the origin.
|
|
518
|
+
position = new THREE.Vector3(0, 10, 18);
|
|
519
|
+
look = new THREE.Vector3();
|
|
520
|
+
}
|
|
521
|
+
if (!this.rig || dt === 0 || spec.smoothing === 0) {
|
|
522
|
+
this.rig = { position, look, focus: look.clone(), yaw };
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// Exponential easing toward the goal: frame-rate independent, the same every run.
|
|
526
|
+
const k = 1 - Math.exp(-dt / spec.smoothing);
|
|
527
|
+
this.rig.position.lerp(position, k);
|
|
528
|
+
this.rig.look.lerp(look, k);
|
|
529
|
+
this.rig.focus.copy(this.rig.look);
|
|
530
|
+
this.rig.yaw = yaw;
|
|
531
|
+
}
|
|
532
|
+
// ---------------------------------------------------------------- debug
|
|
533
|
+
/** Colliders as wireframes in their body's color, drawn over everything, and light positions. */
|
|
534
|
+
syncDebug(world) {
|
|
535
|
+
this.debugGroup.visible = this.debug;
|
|
536
|
+
if (!this.debug)
|
|
537
|
+
return;
|
|
538
|
+
for (const child of [...this.debugGroup.children]) {
|
|
539
|
+
child.removeFromParent();
|
|
540
|
+
child.geometry?.dispose();
|
|
541
|
+
}
|
|
542
|
+
for (const e of world.entities) {
|
|
543
|
+
const b = e.body;
|
|
544
|
+
if (b) {
|
|
545
|
+
const color = b.sensor ? DEBUG_COLORS.sensor : e.character ? DEBUG_COLORS.player : DEBUG_COLORS[b.type];
|
|
546
|
+
const shape = colliderGeometry(b);
|
|
547
|
+
const lines = new THREE.LineSegments(new THREE.EdgesGeometry(shape, 1), new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.9 }));
|
|
548
|
+
shape.dispose();
|
|
549
|
+
placeObject(lines, e);
|
|
550
|
+
lines.renderOrder = 999;
|
|
551
|
+
this.debugGroup.add(lines);
|
|
552
|
+
}
|
|
553
|
+
if (e.light) {
|
|
554
|
+
const marker = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 6), new THREE.MeshBasicMaterial({ color: 0xffee88, depthTest: false }));
|
|
555
|
+
marker.position.set(e.transform.x, e.transform.y, e.transform.z);
|
|
556
|
+
marker.renderOrder = 999;
|
|
557
|
+
this.debugGroup.add(marker);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const axes = new THREE.AxesHelper(1.5);
|
|
561
|
+
axes.renderOrder = 999;
|
|
562
|
+
axes.material.depthTest = false;
|
|
563
|
+
this.debugGroup.add(axes);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** Events in the last `seconds`, as hooks see them. */
|
|
567
|
+
function recentEvents(world, name, seconds) {
|
|
568
|
+
const events = world.events ?? [];
|
|
569
|
+
const since = (world.time ?? 0) - seconds;
|
|
570
|
+
const result = [];
|
|
571
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
572
|
+
const event = events[i];
|
|
573
|
+
if (event.time < since)
|
|
574
|
+
break;
|
|
575
|
+
const eventName = event.type === "custom" ? event.name : event.type;
|
|
576
|
+
if (name !== undefined && eventName !== name)
|
|
577
|
+
continue;
|
|
578
|
+
const { type: _type, frame: _frame, time, x, y, entity, ...details } = event;
|
|
579
|
+
result.push({ name: eventName, time, x, y, entity, data: event.type === "custom" ? event.data : details });
|
|
580
|
+
}
|
|
581
|
+
return result.reverse();
|
|
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
|
+
}
|
|
613
|
+
const DEFAULT_CAMERA = { mode: "follow", target: "", fov: 55, distance: 9, height: 4, yaw: 0, orbitSpeed: 20, lookAt: {}, smoothing: 0.12 };
|
|
614
|
+
function placeObject(object, e) {
|
|
615
|
+
const t = e.transform;
|
|
616
|
+
object.position.set(t.x, t.y, t.z);
|
|
617
|
+
// Ludra's 3D rotations are applied y, then x, then z: Three's "YXZ" order.
|
|
618
|
+
object.rotation.set(t.rotationX * DEG, t.rotationY * DEG, t.rotation * DEG, "YXZ");
|
|
619
|
+
}
|
|
620
|
+
function configureSun(light, size) {
|
|
621
|
+
light.castShadow = true;
|
|
622
|
+
light.shadow.mapSize.set(2048, 2048);
|
|
623
|
+
const half = size / 2;
|
|
624
|
+
Object.assign(light.shadow.camera, { left: -half, right: half, top: half, bottom: -half, near: 0.5, far: size * 4 });
|
|
625
|
+
light.shadow.camera.updateProjectionMatrix();
|
|
626
|
+
light.shadow.bias = -0.0005;
|
|
627
|
+
light.shadow.normalBias = 0.02;
|
|
628
|
+
}
|
|
629
|
+
function createLight(spec) {
|
|
630
|
+
const color = new THREE.Color(spec.color);
|
|
631
|
+
switch (spec.type) {
|
|
632
|
+
case "ambient":
|
|
633
|
+
return new THREE.AmbientLight(color, spec.intensity);
|
|
634
|
+
case "hemisphere":
|
|
635
|
+
return new THREE.HemisphereLight(color, new THREE.Color(spec.groundColor), spec.intensity);
|
|
636
|
+
case "point": {
|
|
637
|
+
const light = new THREE.PointLight(color, spec.intensity, spec.distance, 2);
|
|
638
|
+
light.castShadow = spec.castShadow;
|
|
639
|
+
light.shadow.mapSize.set(512, 512);
|
|
640
|
+
light.shadow.bias = -0.002;
|
|
641
|
+
return light;
|
|
642
|
+
}
|
|
643
|
+
case "spot": {
|
|
644
|
+
const light = new THREE.SpotLight(color, spec.intensity, spec.distance, spec.angle * DEG, spec.penumbra, 2);
|
|
645
|
+
light.castShadow = spec.castShadow;
|
|
646
|
+
light.shadow.mapSize.set(1024, 1024);
|
|
647
|
+
light.shadow.bias = -0.001;
|
|
648
|
+
return light;
|
|
649
|
+
}
|
|
650
|
+
default: {
|
|
651
|
+
const light = new THREE.DirectionalLight(color, spec.intensity);
|
|
652
|
+
if (spec.castShadow)
|
|
653
|
+
configureSun(light, spec.shadowSize);
|
|
654
|
+
return light;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** The mesh's geometry: its own shape and size, or its body's. */
|
|
659
|
+
function meshGeometry(spec, body) {
|
|
660
|
+
const shape = spec.shape === "auto" ? (body ? bodyShape(body) : "box") : spec.shape;
|
|
661
|
+
if ((shape === "convex" || shape === "mesh") && body && spec.shape === "auto")
|
|
662
|
+
return colliderGeometry(body);
|
|
663
|
+
const w = spec.width || body?.width || 1;
|
|
664
|
+
const h = spec.height || body?.height || 1;
|
|
665
|
+
const d = spec.depth || (body ? body.depth || body.width : w);
|
|
666
|
+
switch (shape) {
|
|
667
|
+
case "sphere":
|
|
668
|
+
return new THREE.SphereGeometry(w / 2, 40, 24);
|
|
669
|
+
case "capsule":
|
|
670
|
+
return new THREE.CapsuleGeometry(w / 2, Math.max(0, h - w), 10, 24);
|
|
671
|
+
case "cylinder":
|
|
672
|
+
return new THREE.CylinderGeometry(w / 2, w / 2, h, 40);
|
|
673
|
+
case "cone":
|
|
674
|
+
return new THREE.ConeGeometry(w / 2, h, 40);
|
|
675
|
+
case "plane":
|
|
676
|
+
return new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2);
|
|
677
|
+
case "torus":
|
|
678
|
+
return new THREE.TorusGeometry((w - h) / 2, h / 2, 20, 56);
|
|
679
|
+
default:
|
|
680
|
+
return new THREE.BoxGeometry(w, h, d);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function bodyShape(body) {
|
|
684
|
+
return body.shape === "box" || body.shape === "sphere" || body.shape === "capsule" || body.shape === "cylinder" || body.shape === "convex" || body.shape === "mesh" ? body.shape : "box";
|
|
685
|
+
}
|
|
686
|
+
/** The exact collider shape, for the debug view and convex meshes. */
|
|
687
|
+
function colliderGeometry(b) {
|
|
688
|
+
const d = b.depth || b.width;
|
|
689
|
+
switch (b.shape) {
|
|
690
|
+
case "sphere":
|
|
691
|
+
return new THREE.SphereGeometry(b.width / 2, 16, 10);
|
|
692
|
+
case "capsule":
|
|
693
|
+
return new THREE.CapsuleGeometry(b.width / 2, Math.max(0, b.height - b.width), 4, 12);
|
|
694
|
+
case "cylinder":
|
|
695
|
+
return new THREE.CylinderGeometry(b.width / 2, b.width / 2, b.height, 16);
|
|
696
|
+
case "convex":
|
|
697
|
+
if (b.points.length >= 4)
|
|
698
|
+
return new ConvexGeometry(b.points.map(([x, y, z]) => new THREE.Vector3(x, y, z ?? 0)));
|
|
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
|
+
}
|
|
710
|
+
default:
|
|
711
|
+
return new THREE.BoxGeometry(b.width, b.height, d);
|
|
712
|
+
}
|
|
713
|
+
}
|