@jgengine/shell 0.34.0 → 0.35.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/GamePlayerShell.js +2 -0
  3. package/dist/Shell3dPresentation.js +1 -1
  4. package/dist/audio/AudioComponents.js +7 -0
  5. package/dist/audio/audioEngine.d.ts +59 -0
  6. package/dist/audio/audioEngine.js +334 -15
  7. package/dist/audio/audioProbe.d.ts +10 -5
  8. package/dist/audio/audioProbe.js +6 -3
  9. package/dist/audio/occlusion.d.ts +11 -0
  10. package/dist/audio/occlusion.js +20 -0
  11. package/dist/play/index.d.ts +3 -0
  12. package/dist/play/index.js +3 -0
  13. package/dist/play/run.d.ts +27 -0
  14. package/dist/play/run.js +266 -0
  15. package/dist/play/script.d.ts +103 -0
  16. package/dist/play/script.js +52 -0
  17. package/dist/play/timeline.d.ts +43 -0
  18. package/dist/play/timeline.js +27 -0
  19. package/dist/pointer/pointerService.d.ts +2 -0
  20. package/dist/pointer/pointerService.js +4 -1
  21. package/dist/postfx/PostProcessing.d.ts +15 -1
  22. package/dist/postfx/PostProcessing.js +16 -6
  23. package/dist/postfx/postfxTierPolicy.d.ts +25 -0
  24. package/dist/postfx/postfxTierPolicy.js +17 -0
  25. package/dist/render/CascadedShadows.d.ts +4 -1
  26. package/dist/render/CascadedShadows.js +15 -7
  27. package/dist/render/CharacterCrowd.d.ts +11 -7
  28. package/dist/render/CharacterCrowd.js +103 -90
  29. package/dist/render/SceneLighting.js +14 -6
  30. package/dist/render/SceneWarmupGate.d.ts +6 -1
  31. package/dist/render/SceneWarmupGate.js +33 -7
  32. package/dist/render/SkinnedInstances.d.ts +16 -8
  33. package/dist/render/SkinnedInstances.js +215 -65
  34. package/dist/render/characterLod.d.ts +7 -0
  35. package/dist/render/characterLod.js +11 -0
  36. package/dist/render/crowdBatch.d.ts +45 -0
  37. package/dist/render/crowdBatch.js +68 -0
  38. package/dist/render/crowdRegistry.d.ts +55 -5
  39. package/dist/render/crowdRegistry.js +92 -9
  40. package/dist/render/shaderWarmup.d.ts +75 -18
  41. package/dist/render/shaderWarmup.js +221 -64
  42. package/dist/render/shadowCasters.d.ts +1 -0
  43. package/dist/render/shadowCasters.js +1 -1
  44. package/dist/render/shadowTiers.d.ts +36 -0
  45. package/dist/render/shadowTiers.js +50 -0
  46. package/dist/world/WorldScene.js +28 -11
  47. package/package.json +8 -4
  48. package/skills/jgengine/api.md +3 -3
  49. package/skills/jgengine-ui/api.md +79 -9
  50. package/skills/jgengine-ui/capabilities.md +7 -2
  51. package/skills/jgengine-world/api.md +18 -2
  52. package/skills/jgengine-world/capabilities.md +8 -0
@@ -6,21 +6,86 @@ import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js
6
6
  import { clone as cloneSkinned } from "three/examples/jsm/utils/SkeletonUtils.js";
7
7
  import { planBoneTexture } from "@jgengine/core/anim/boneTexture";
8
8
  import { sharedGltfLoader } from "./modelLoad.js";
9
- function slotFor(slots, bone, inverse) {
10
- const found = slots.findIndex((slot) => slot.bone === bone && slot.inverse.equals(inverse));
9
+ const IDENTITY = new THREE.Matrix4();
10
+ function slotFor(slots, node, inverse) {
11
+ const found = slots.findIndex((slot) => slot.node === node && slot.inverse.equals(inverse));
11
12
  if (found >= 0)
12
13
  return found;
13
- slots.push({ bone, inverse: inverse.clone() });
14
+ slots.push({ node, inverse: inverse.clone() });
14
15
  return slots.length - 1;
15
16
  }
16
17
  function firstMaterial(material) {
17
18
  return Array.isArray(material) ? material[0] : material;
18
19
  }
20
+ /** Node names any of the clips animate, so a rigid part rides the nearest one that carries it. */
21
+ function animatedNodeNames(clips) {
22
+ const names = new Set();
23
+ for (const clip of clips) {
24
+ for (const track of clip.tracks) {
25
+ const parsed = THREE.PropertyBinding.parseTrackName(track.name);
26
+ if (parsed.nodeName !== undefined && parsed.nodeName.length > 0)
27
+ names.add(parsed.nodeName);
28
+ }
29
+ }
30
+ return names;
31
+ }
32
+ /** The nearest node (self included) a clip moves, or the root when nothing above this mesh is animated. */
33
+ function rigidSlotNode(mesh, root, animated) {
34
+ let node = mesh;
35
+ while (node !== null) {
36
+ if (animated.has(node.name))
37
+ return node;
38
+ if (node === root)
39
+ break;
40
+ node = node.parent;
41
+ }
42
+ return root;
43
+ }
44
+ /** Attribute layout two geometries must agree on before their vertices can share a draw. */
45
+ function layoutKey(geometry) {
46
+ const parts = [geometry.index === null ? "plain" : "indexed"];
47
+ for (const name of Object.keys(geometry.attributes).sort()) {
48
+ const attribute = geometry.attributes[name];
49
+ parts.push(`${name}:${attribute.itemSize}:${attribute.normalized === true ? 1 : 0}`);
50
+ }
51
+ return parts.join(",");
52
+ }
19
53
  /**
20
- * Bakes a rig for {@link SkinnedInstances}: merges its skinned meshes into one geometry in bind
21
- * space, samples every requested clip at `fps`, and writes each bone's model-space skinning matrix
22
- * into a float texture laid out by `planBoneTexture`. Meshes with several materials keep only the
23
- * first. Exported for tests and custom crowd renderers.
54
+ * Flattens an indexed part so every baked geometry shares one attribute set. Bakes happen once per
55
+ * character kind, and mixed indexed/non-indexed sources (a box plus an icosahedron) would otherwise
56
+ * refuse to merge into one draw.
57
+ */
58
+ function flatten(geometry) {
59
+ if (geometry.index === null)
60
+ return geometry;
61
+ const expanded = geometry.toNonIndexed();
62
+ geometry.dispose();
63
+ return expanded;
64
+ }
65
+ /** Copies the attributes a batch can draw: the vertex, its normal and its texture coordinate. */
66
+ function bakePart(mesh, relative) {
67
+ const source = mesh.geometry;
68
+ const geometry = new THREE.BufferGeometry();
69
+ geometry.setAttribute("position", source.getAttribute("position").clone());
70
+ if (source.getAttribute("normal") !== undefined)
71
+ geometry.setAttribute("normal", source.getAttribute("normal").clone());
72
+ else
73
+ geometry.setAttribute("normal", new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 3), 3));
74
+ const uv = source.getAttribute("uv");
75
+ geometry.setAttribute("uv", uv !== undefined ? uv.clone() : new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 2), 2));
76
+ if (source.index !== null)
77
+ geometry.setIndex(source.index.clone());
78
+ geometry.applyMatrix4(relative);
79
+ return { geometry: flatten(geometry), layout: "", material: firstMaterial(mesh.material) };
80
+ }
81
+ /**
82
+ * Bakes a rig for {@link SkinnedInstances}: merges its meshes into one geometry per material, samples
83
+ * every requested clip at `fps`, and writes each slot's model-space matrix into a float texture laid
84
+ * out by `planBoneTexture`. A skinned mesh contributes its bones as slots; a rig-less part that a
85
+ * clip moves contributes that node, with its vertices weighted fully to it, so a node-transform
86
+ * character (a walking miniature with rotating limbs) batches through the same shader. A model with
87
+ * no clips at all bakes to `animated: false`: one merged geometry per material and no texture.
88
+ * Meshes with several materials keep only the first. Exported for tests and custom crowd renderers.
24
89
  */
25
90
  export function bakeSkinnedCrowd(source, options = {}) {
26
91
  const root = cloneSkinned(source.scene);
@@ -28,59 +93,136 @@ export function bakeSkinnedCrowd(source, options = {}) {
28
93
  root.quaternion.identity();
29
94
  root.scale.set(1, 1, 1);
30
95
  root.updateMatrixWorld(true);
31
- const meshes = [];
32
- root.traverse((object) => {
33
- if (object.isSkinnedMesh === true)
34
- meshes.push(object);
96
+ const names = options.clips ?? source.animations.map((clip) => clip.name);
97
+ const clips = names.map((name) => {
98
+ const clip = THREE.AnimationClip.findByName(source.animations, name);
99
+ if (clip === null)
100
+ throw new Error(`bakeSkinnedCrowd: no clip named "${name}"`);
101
+ return clip;
35
102
  });
36
- if (meshes.length === 0)
37
- throw new Error("bakeSkinnedCrowd: the source has no skinned mesh");
103
+ const animatedClips = clips.length > 0 ? clips : source.animations;
104
+ const animatedNames = animatedNodeNames(animatedClips);
105
+ const rootInverse = root.matrixWorld.clone().invert();
38
106
  const slots = [];
39
107
  const geometries = [];
40
108
  const materials = [];
41
- for (const mesh of meshes) {
42
- const source = mesh.geometry;
43
- const geometry = new THREE.BufferGeometry();
44
- geometry.setAttribute("position", source.getAttribute("position").clone());
45
- if (source.getAttribute("normal") !== undefined)
46
- geometry.setAttribute("normal", source.getAttribute("normal").clone());
47
- else
48
- geometry.setAttribute("normal", new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 3), 3));
49
- const uv = source.getAttribute("uv");
50
- geometry.setAttribute("uv", uv !== undefined ? uv.clone() : new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 2), 2));
51
- if (source.index !== null)
52
- geometry.setIndex(source.index.clone());
53
- geometry.applyMatrix4(mesh.bindMatrix);
54
- const skinIndex = source.getAttribute("skinIndex");
55
- const skinWeight = source.getAttribute("skinWeight");
56
- const remapped = new Float32Array(skinIndex.count * 4);
57
- const weights = new Float32Array(skinWeight.count * 4);
58
- const bySkeletonIndex = mesh.skeleton.bones.map((bone, i) => slotFor(slots, bone, mesh.skeleton.boneInverses[i]));
59
- for (let v = 0; v < skinIndex.count; v += 1) {
60
- for (let c = 0; c < 4; c += 1) {
61
- remapped[v * 4 + c] = bySkeletonIndex[skinIndex.getComponent(v, c)] ?? 0;
62
- weights[v * 4 + c] = skinWeight.getComponent(v, c);
109
+ const bounds = new THREE.Box3();
110
+ const groups = new Map();
111
+ root.updateMatrixWorld(true);
112
+ root.traverse((object) => {
113
+ const mesh = object;
114
+ if (mesh.isMesh !== true || Array.isArray(mesh.material) || mesh.material === undefined)
115
+ return;
116
+ if (Object.keys(mesh.geometry.morphAttributes).length > 0)
117
+ throw new Error("bakeSkinnedCrowd: the rig has morph targets");
118
+ if (mesh.geometry.getAttribute("position") === undefined)
119
+ return;
120
+ mesh.geometry.computeBoundingBox();
121
+ if (mesh.geometry.boundingBox !== null)
122
+ bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld));
123
+ const skinned = mesh;
124
+ let part;
125
+ if (skinned.isSkinnedMesh === true) {
126
+ const source = mesh.geometry;
127
+ const geometry = new THREE.BufferGeometry();
128
+ geometry.setAttribute("position", source.getAttribute("position").clone());
129
+ if (source.getAttribute("normal") !== undefined)
130
+ geometry.setAttribute("normal", source.getAttribute("normal").clone());
131
+ else
132
+ geometry.setAttribute("normal", new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 3), 3));
133
+ const uv = source.getAttribute("uv");
134
+ geometry.setAttribute("uv", uv !== undefined ? uv.clone() : new THREE.Float32BufferAttribute(new Float32Array(source.getAttribute("position").count * 2), 2));
135
+ if (source.index !== null)
136
+ geometry.setIndex(source.index.clone());
137
+ geometry.applyMatrix4(skinned.bindMatrix);
138
+ const skinIndex = source.getAttribute("skinIndex");
139
+ const skinWeight = source.getAttribute("skinWeight");
140
+ const remapped = new Float32Array(skinIndex.count * 4);
141
+ const weights = new Float32Array(skinWeight.count * 4);
142
+ const bySkeletonIndex = skinned.skeleton.bones.map((bone, index) => slotFor(slots, bone, skinned.skeleton.boneInverses[index]));
143
+ for (let v = 0; v < skinIndex.count; v += 1) {
144
+ for (let c = 0; c < 4; c += 1) {
145
+ remapped[v * 4 + c] = bySkeletonIndex[skinIndex.getComponent(v, c)] ?? 0;
146
+ weights[v * 4 + c] = skinWeight.getComponent(v, c);
147
+ }
148
+ }
149
+ geometry.setAttribute("skinIndex", new THREE.Float32BufferAttribute(remapped, 4));
150
+ geometry.setAttribute("skinWeight", new THREE.Float32BufferAttribute(weights, 4));
151
+ const flat = flatten(geometry);
152
+ part = { geometry: flat, layout: layoutKey(flat), material: firstMaterial(mesh.material) };
153
+ }
154
+ else {
155
+ const slotNode = rigidSlotNode(mesh, root, animatedNames);
156
+ const nodeInverse = slotNode.matrixWorld.clone().invert();
157
+ const slot = slotFor(slots, slotNode, IDENTITY);
158
+ const relative = new THREE.Matrix4().multiplyMatrices(nodeInverse, mesh.matrixWorld);
159
+ part = bakePart(mesh, relative);
160
+ const count = part.geometry.getAttribute("position").count;
161
+ const skinIndex = new Float32Array(count * 4);
162
+ const skinWeight = new Float32Array(count * 4);
163
+ for (let v = 0; v < count; v += 1) {
164
+ skinIndex[v * 4] = slot;
165
+ skinWeight[v * 4] = 1;
166
+ }
167
+ part.geometry.setAttribute("skinIndex", new THREE.Float32BufferAttribute(skinIndex, 4));
168
+ part.geometry.setAttribute("skinWeight", new THREE.Float32BufferAttribute(skinWeight, 4));
169
+ part.layout = layoutKey(part.geometry);
170
+ }
171
+ // Vertices are already in slot space, chosen per vertex by skinIndex, so parts that share a
172
+ // material and a vertex layout merge into one draw whatever node moves them.
173
+ const key = `${part.material.uuid}|${part.layout}`;
174
+ const existing = groups.get(key);
175
+ if (existing === undefined)
176
+ groups.set(key, { geometry: part.geometry, material: part.material });
177
+ else {
178
+ const merged = mergeGeometries([existing.geometry, part.geometry], false);
179
+ if (merged === null) {
180
+ groups.set(`${key}|${groups.size}`, { geometry: part.geometry, material: part.material });
181
+ }
182
+ else {
183
+ existing.geometry.dispose();
184
+ part.geometry.dispose();
185
+ existing.geometry = merged;
63
186
  }
64
187
  }
65
- geometry.setAttribute("skinIndex", new THREE.Float32BufferAttribute(remapped, 4));
66
- geometry.setAttribute("skinWeight", new THREE.Float32BufferAttribute(weights, 4));
67
- geometries.push(geometry);
68
- materials.push(firstMaterial(mesh.material));
188
+ });
189
+ for (const group of groups.values()) {
190
+ geometries.push(group.geometry);
191
+ materials.push(group.material);
192
+ }
193
+ if (geometries.length === 0)
194
+ throw new Error("bakeSkinnedCrowd: the source has no drawable mesh");
195
+ // The shader transforms a normal by the slot matrix, which is only exact for uniform scale.
196
+ const scale = new THREE.Vector3();
197
+ for (const slot of slots) {
198
+ slot.node.matrixWorld.decompose(new THREE.Vector3(), new THREE.Quaternion(), scale);
199
+ if (Math.abs(scale.x - scale.y) > 1e-4 || Math.abs(scale.y - scale.z) > 1e-4) {
200
+ throw new Error("bakeSkinnedCrowd: the rig animates a node with non-uniform scale");
201
+ }
69
202
  }
70
- const geometry = mergeGeometries(geometries, true);
71
- for (const part of geometries)
72
- part.dispose();
203
+ const geometry = geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, true);
204
+ if (geometries.length > 1)
205
+ for (const part of geometries)
206
+ part.dispose();
73
207
  if (geometry === null)
74
208
  throw new Error("bakeSkinnedCrowd: could not merge the rig's meshes");
75
209
  geometry.computeBoundingBox();
76
210
  geometry.computeBoundingSphere();
77
- const names = options.clips ?? source.animations.map((clip) => clip.name);
78
- const clips = names.map((name) => {
79
- const clip = THREE.AnimationClip.findByName(source.animations, name);
80
- if (clip === null)
81
- throw new Error(`bakeSkinnedCrowd: no clip named "${name}"`);
82
- return clip;
83
- });
211
+ if (clips.length === 0) {
212
+ return {
213
+ geometry,
214
+ materials,
215
+ texture: null,
216
+ layout: null,
217
+ loops: [],
218
+ clipIndex: new Map(),
219
+ animated: false,
220
+ bounds,
221
+ dispose() {
222
+ geometry.dispose();
223
+ },
224
+ };
225
+ }
84
226
  const layout = planBoneTexture({
85
227
  boneCount: slots.length,
86
228
  clips: clips.map((clip) => ({ name: clip.name, duration: clip.duration })),
@@ -89,7 +231,6 @@ export function bakeSkinnedCrowd(source, options = {}) {
89
231
  });
90
232
  const data = new Float32Array(layout.width * layout.height * 4);
91
233
  const mixer = new THREE.AnimationMixer(root);
92
- const rootInverse = root.matrixWorld.clone().invert();
93
234
  const matrix = new THREE.Matrix4();
94
235
  clips.forEach((clip, clipIndex) => {
95
236
  const baked = layout.clips[clipIndex];
@@ -101,7 +242,7 @@ export function bakeSkinnedCrowd(source, options = {}) {
101
242
  root.updateMatrixWorld(true);
102
243
  const rowOffset = (baked.startFrame + frame) * layout.width * 4;
103
244
  slots.forEach((slot, s) => {
104
- matrix.multiplyMatrices(rootInverse, slot.bone.matrixWorld).multiply(slot.inverse);
245
+ matrix.multiplyMatrices(rootInverse, slot.node.matrixWorld).multiply(slot.inverse);
105
246
  data.set(matrix.elements, rowOffset + s * 16);
106
247
  });
107
248
  }
@@ -121,6 +262,8 @@ export function bakeSkinnedCrowd(source, options = {}) {
121
262
  layout,
122
263
  loops: layout.clips.map((clip) => !once.has(clip.name)),
123
264
  clipIndex: new Map(layout.clips.map((clip, index) => [clip.name, index])),
265
+ animated: true,
266
+ bounds,
124
267
  dispose() {
125
268
  geometry.dispose();
126
269
  texture.dispose();
@@ -129,11 +272,14 @@ export function bakeSkinnedCrowd(source, options = {}) {
129
272
  }
130
273
  /** Builds the uniforms a crowd material reads: the texture and per-clip start, frames, fps and loop. */
131
274
  export function createCrowdUniforms(baked) {
275
+ const layout = baked.layout;
276
+ if (baked.texture === null || layout === null)
277
+ throw new Error("createCrowdUniforms: the rig is static and needs no crowd uniforms");
132
278
  return {
133
279
  crowdBones: { value: baked.texture },
134
280
  crowdTime: { value: 0 },
135
281
  crowdClips: {
136
- value: baked.layout.clips.map((clip, index) => new THREE.Vector4(clip.startFrame, clip.frameCount, baked.layout.fps, baked.loops[index] ? 1 : 0)),
282
+ value: layout.clips.map((clip, index) => new THREE.Vector4(clip.startFrame, clip.frameCount, layout.fps, baked.loops[index] ? 1 : 0)),
137
283
  },
138
284
  };
139
285
  }
@@ -207,43 +353,47 @@ export function SkinnedInstances({ url, instances, clips, once, fps, modelScale
207
353
  const onceKey = once?.join("\u0000");
208
354
  const baked = useMemo(() => bakeSkinnedCrowd({ scene: gltf.scene, animations: gltf.animations }, { clips, once, fps }), [gltf, clipKey, onceKey, fps]);
209
355
  useEffect(() => () => baked.dispose(), [baked]);
210
- const uniforms = useMemo(() => createCrowdUniforms(baked), [baked]);
211
- const materials = useMemo(() => baked.materials.map((material) => patchCrowdMaterial(material.clone(), uniforms)), [baked, uniforms]);
212
- const depthMaterial = useMemo(() => patchCrowdMaterial(new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), uniforms), [uniforms]);
356
+ const uniforms = useMemo(() => (baked.animated ? createCrowdUniforms(baked) : null), [baked]);
357
+ const materials = useMemo(() => baked.materials.map((material) => (uniforms === null ? material.clone() : patchCrowdMaterial(material.clone(), uniforms))), [baked, uniforms]);
358
+ const depthMaterial = useMemo(() => (uniforms === null ? null : patchCrowdMaterial(new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), uniforms)), [uniforms]);
213
359
  useEffect(() => () => {
214
360
  for (const material of materials)
215
361
  material.dispose();
216
- depthMaterial.dispose();
362
+ depthMaterial?.dispose();
217
363
  }, [materials, depthMaterial]);
218
364
  const capacity = Math.max(1, instances.length);
219
365
  const mesh = useMemo(() => {
220
- const instanced = new THREE.InstancedMesh(baked.geometry, materials, capacity);
221
- instanced.customDepthMaterial = depthMaterial;
222
- instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
366
+ const instanced = new THREE.InstancedMesh(baked.geometry, materials.length === 1 ? materials[0] : materials, capacity);
367
+ if (depthMaterial !== null)
368
+ instanced.customDepthMaterial = depthMaterial;
369
+ if (uniforms !== null)
370
+ instanced.geometry.setAttribute("crowdPlay", new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3));
223
371
  return instanced;
224
- }, [baked, materials, depthMaterial, capacity]);
372
+ }, [baked, materials, depthMaterial, uniforms, capacity]);
225
373
  useEffect(() => () => {
226
374
  mesh.dispose();
227
375
  }, [mesh]);
228
376
  useLayoutEffect(() => {
229
- const play = mesh.geometry.getAttribute("crowdPlay");
377
+ const play = uniforms === null ? null : mesh.geometry.getAttribute("crowdPlay");
230
378
  instances.forEach((instance, i) => {
231
379
  const scale = (instance.scale ?? 1) * modelScale;
232
380
  scratchQuaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, instance.rotationY ?? 0);
233
381
  scratchMatrix.compose(scratchPosition.set(instance.position[0], instance.position[1], instance.position[2]), scratchQuaternion, scratchScale.set(scale, scale, scale));
234
382
  mesh.setMatrixAt(i, scratchMatrix);
235
- play.setXYZ(i, baked.clipIndex.get(instance.clip) ?? 0, instance.timeOffset ?? 0, instance.speed ?? 1);
383
+ play?.setXYZ(i, instance.clip === undefined ? 0 : baked.clipIndex.get(instance.clip) ?? 0, instance.timeOffset ?? 0, instance.speed ?? 1);
236
384
  });
237
385
  mesh.count = instances.length;
238
386
  mesh.instanceMatrix.needsUpdate = true;
239
- play.needsUpdate = true;
387
+ if (play !== null)
388
+ play.needsUpdate = true;
240
389
  mesh.computeBoundingSphere();
241
390
  // Poses reach past the bind-pose bounds (arms out, a lunge), so pad the cull sphere.
242
391
  if (mesh.boundingSphere !== null)
243
392
  mesh.boundingSphere.radius += (baked.geometry.boundingSphere?.radius ?? 0) * modelScale;
244
- }, [mesh, instances, baked, modelScale]);
393
+ }, [mesh, instances, baked, modelScale, uniforms]);
245
394
  useFrame((_three, delta) => {
246
- uniforms.crowdTime.value += delta * timeScale;
395
+ if (uniforms !== null)
396
+ uniforms.crowdTime.value += delta * timeScale;
247
397
  });
248
398
  return _jsx("primitive", { object: mesh, castShadow: castShadow, receiveShadow: receiveShadow });
249
399
  }
@@ -6,6 +6,7 @@ export interface CharacterLodPolicy {
6
6
  halfRateDistance: number;
7
7
  crowdDistance: number;
8
8
  crowdHysteresis: number;
9
+ crowdCapacity: number;
9
10
  }
10
11
  /** The engine's default character tiers: full under 15 m, half under 40 m, GPU crowd beyond 25 m. */
11
12
  export declare const DEFAULT_CHARACTER_LOD: CharacterLodPolicy;
@@ -22,6 +23,12 @@ export declare function characterLodPolicy(config: ModelLodConfig | undefined):
22
23
  * hysteresis, so a character walking along the crowd boundary does not swap representation every frame.
23
24
  */
24
25
  export declare function characterLodTier(distance: number, config: ModelLodConfig | undefined, previous?: CharacterLodTier): CharacterLodTier;
26
+ /**
27
+ * The tier a character poses its own rig on, ignoring the crowd path. A crowd character whose batch
28
+ * is full falls back to this, so an over-cap crowd draws every member on its own rig instead of
29
+ * dropping the ones past the cap.
30
+ */
31
+ export declare function characterRigTier(distance: number, config: ModelLodConfig | undefined): CharacterLodTier;
25
32
  /** Share of frames a tier poses the rig: 1, 0.5, 0.25, and 0 once the GPU crowd draws it. */
26
33
  export declare function characterLodRate(tier: CharacterLodTier): number;
27
34
  /** Frames between pose updates for a tier: 1, 2, 4, and `0` meaning "never" for the crowd tier. */
@@ -4,6 +4,7 @@ export const DEFAULT_CHARACTER_LOD = {
4
4
  halfRateDistance: 40,
5
5
  crowdDistance: 25,
6
6
  crowdHysteresis: 2,
7
+ crowdCapacity: 128,
7
8
  };
8
9
  /**
9
10
  * Fills a model's authoring gaps with {@link DEFAULT_CHARACTER_LOD}. A missing, negative or
@@ -17,6 +18,7 @@ export function characterLodPolicy(config) {
17
18
  halfRateDistance: finite(config?.halfRateDistance, DEFAULT_CHARACTER_LOD.halfRateDistance),
18
19
  crowdDistance: finite(config?.crowdDistance, DEFAULT_CHARACTER_LOD.crowdDistance),
19
20
  crowdHysteresis: finite(config?.crowdHysteresis, DEFAULT_CHARACTER_LOD.crowdHysteresis),
21
+ crowdCapacity: Math.floor(finite(config?.crowdCapacity, DEFAULT_CHARACTER_LOD.crowdCapacity)),
20
22
  };
21
23
  // Tiers read in order, so a game that sets half below full would otherwise make "full" unreachable.
22
24
  policy.halfRateDistance = Math.max(policy.halfRateDistance, policy.fullRateDistance);
@@ -31,6 +33,15 @@ export function characterLodTier(distance, config, previous) {
31
33
  const crowdBoundary = previous === "crowd" ? policy.crowdDistance - policy.crowdHysteresis : policy.crowdDistance;
32
34
  if (distance >= crowdBoundary)
33
35
  return "crowd";
36
+ return characterRigTier(distance, config);
37
+ }
38
+ /**
39
+ * The tier a character poses its own rig on, ignoring the crowd path. A crowd character whose batch
40
+ * is full falls back to this, so an over-cap crowd draws every member on its own rig instead of
41
+ * dropping the ones past the cap.
42
+ */
43
+ export function characterRigTier(distance, config) {
44
+ const policy = characterLodPolicy(config);
34
45
  if (distance < policy.fullRateDistance)
35
46
  return "full";
36
47
  if (distance < policy.halfRateDistance)
@@ -0,0 +1,45 @@
1
+ import * as THREE from "three";
2
+ import { type CrowdMember, type CrowdSpeedBands } from "./crowdRegistry.js";
3
+ /** How a batch places every member of one character kind. @internal */
4
+ export interface CrowdBatchPlacement {
5
+ /** Uniform scale applied to the baked geometry. */
6
+ scale: number;
7
+ /** Translation applied after the member's own pose, so the baked rig stands where its own rig would. */
8
+ offset: THREE.Vector3;
9
+ }
10
+ /** The clip and time offset a batch is playing for one member. @internal */
11
+ export interface CrowdPlayback {
12
+ clip: number;
13
+ offset: number;
14
+ }
15
+ /** Everything one frame of a character batch needs. @internal */
16
+ export interface CrowdBatchFrame {
17
+ mesh: THREE.InstancedMesh;
18
+ /** Per-instance clip, time offset and speed; `null` for a static character kind. */
19
+ play: THREE.InstancedBufferAttribute | null;
20
+ /** Drawn members in batch order, already filtered by visibility. */
21
+ members: readonly {
22
+ id: string;
23
+ member: CrowdMember;
24
+ }[];
25
+ placement: CrowdBatchPlacement;
26
+ /** Baked clip names in `[idle, walk, run]` order. */
27
+ clips: readonly string[];
28
+ bands: CrowdSpeedBands;
29
+ clipIndex: ReadonlyMap<string, number>;
30
+ /** Seconds of batch time, so a member entering mid-clip continues the pose it was holding. */
31
+ time: number;
32
+ /** Per-member playback state, kept across frames; cleared by the caller when the batch unmounts. */
33
+ playback: Map<string, CrowdPlayback>;
34
+ /** Ids written into the compacted instance slots, in draw order. */
35
+ ids: string[];
36
+ }
37
+ /** A stable non-zero time offset for a member with no recorded pose, so a crowd is not in lockstep. */
38
+ export declare function poseTimeOffset(id: string): number;
39
+ /**
40
+ * Writes one frame of instances for a batch and returns how many it drew. Slots are compacted from
41
+ * zero every frame and `ids` is truncated to the drawn range, so a character that left the batch or
42
+ * went out of view never leaves its last matrix or id behind for the next member to inherit.
43
+ * @internal
44
+ */
45
+ export declare function writeCrowdBatch(frame: CrowdBatchFrame): number;
@@ -0,0 +1,68 @@
1
+ import * as THREE from "three";
2
+ import { animationPhaseOf } from "./characterLod.js";
3
+ import { crowdClipForSpeed, } from "./crowdRegistry.js";
4
+ const scratchMatrix = new THREE.Matrix4();
5
+ const scratchOffset = new THREE.Matrix4();
6
+ const scratchQuaternion = new THREE.Quaternion();
7
+ const scratchPosition = new THREE.Vector3();
8
+ const scratchScale = new THREE.Vector3();
9
+ /** A stable non-zero time offset for a member with no recorded pose, so a crowd is not in lockstep. */
10
+ export function poseTimeOffset(id) {
11
+ let hash = 0;
12
+ for (let index = 0; index < id.length; index += 1)
13
+ hash = (hash * 31 + id.charCodeAt(index)) | 0;
14
+ return (Math.abs(hash) % 1000) / 1000 + 0.05;
15
+ }
16
+ /**
17
+ * Writes one frame of instances for a batch and returns how many it drew. Slots are compacted from
18
+ * zero every frame and `ids` is truncated to the drawn range, so a character that left the batch or
19
+ * went out of view never leaves its last matrix or id behind for the next member to inherit.
20
+ * @internal
21
+ */
22
+ export function writeCrowdBatch(frame) {
23
+ const { mesh, play, members, placement, clips, bands, clipIndex, time, playback, ids } = frame;
24
+ let count = 0;
25
+ let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
26
+ for (const { id, member } of members) {
27
+ if (count >= mesh.instanceMatrix.count)
28
+ break;
29
+ if (play !== null) {
30
+ const clipName = crowdClipForSpeed(clips, member.speed, bands);
31
+ const clip = clipName === undefined ? 0 : clipIndex.get(clipName) ?? 0;
32
+ let entry = playback.get(id);
33
+ if (entry === undefined || entry.clip !== clip) {
34
+ // Hand the batch the pose the rig was holding, so the swap continues the same gait instead
35
+ // of restarting the clip at a different phase.
36
+ const phase = animationPhaseOf(id);
37
+ entry = { clip, offset: phase !== undefined && phase.clip === clipName ? phase.time - time : poseTimeOffset(id) };
38
+ playback.set(id, entry);
39
+ }
40
+ play.setXYZ(count, entry.clip, entry.offset, 1);
41
+ }
42
+ scratchQuaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, member.rotationY);
43
+ scratchMatrix.compose(scratchPosition.set(member.x, member.y, member.z), scratchQuaternion, scratchScale.setScalar(placement.scale));
44
+ scratchMatrix.multiply(scratchOffset.makeTranslation(placement.offset.x, placement.offset.y, placement.offset.z));
45
+ mesh.setMatrixAt(count, scratchMatrix);
46
+ ids[count] = id;
47
+ minX = Math.min(minX, member.x);
48
+ maxX = Math.max(maxX, member.x);
49
+ minY = Math.min(minY, member.y);
50
+ maxY = Math.max(maxY, member.y);
51
+ minZ = Math.min(minZ, member.z);
52
+ maxZ = Math.max(maxZ, member.z);
53
+ count += 1;
54
+ }
55
+ ids.length = count;
56
+ mesh.count = count;
57
+ mesh.instanceMatrix.needsUpdate = true;
58
+ if (play !== null)
59
+ play.needsUpdate = true;
60
+ if (count > 0) {
61
+ // One batch spans every drawn member, so its cull sphere has to follow them; three cannot cull
62
+ // an InstancedMesh per instance.
63
+ const sphere = mesh.boundingSphere ?? (mesh.boundingSphere = new THREE.Sphere());
64
+ sphere.center.set((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
65
+ sphere.radius = Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2 + placement.scale * 1.2;
66
+ }
67
+ return count;
68
+ }
@@ -1,3 +1,4 @@
1
+ import type * as THREE from "three";
1
2
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
2
3
  /** One character the GPU crowd path is drawing instead of its own rig. @internal */
3
4
  export interface CrowdMember {
@@ -8,22 +9,49 @@ export interface CrowdMember {
8
9
  /** Ground speed in metres per second, which picks the crowd's idle/walk/run clip. */
9
10
  speed: number;
10
11
  }
12
+ /** Where a batch's merged geometry sits, so picking hits what the batch draws. @internal */
13
+ export interface CrowdBatchGeometry {
14
+ geometry: THREE.BufferGeometry;
15
+ /** Placement the batch draws its members with, relative to each member's world pose. */
16
+ position: readonly [number, number, number];
17
+ scale: number;
18
+ }
19
+ /** Speed at or above which a crowd member walks, and one at which it runs. @internal */
20
+ export interface CrowdSpeedBands {
21
+ walk: number;
22
+ run: number;
23
+ }
11
24
  /**
12
25
  * The identity a character model shares with every other model the GPU crowd can draw: rig url,
13
26
  * height normalization and material tint. Two entity kinds with the same key can share one instanced
14
27
  * draw; a different tint or height needs its own.
15
28
  */
16
29
  export declare function crowdKindOf(model: ModelConfig): string;
17
- /** Whether a model can leave its own rig for the GPU crowd path: a rigged, clip-played, plain model. */
30
+ /**
31
+ * Whether a model can leave its own rig for the GPU crowd path: one whose drawn result a batch can
32
+ * reproduce. A rigged, clip-played, plain model qualifies, and so does a model with no animation at
33
+ * all (a static crowd character) — it has no playback to lose. A model that needs per-entity
34
+ * materials, wind, hidden nodes, IK, attachments or a part composition keeps its own rig.
35
+ */
18
36
  export declare function crowdEligible(model: ModelConfig | undefined): model is ModelConfig;
19
37
  /**
20
38
  * The clips a crowd batch bakes for a model: its resolved idle, walk and run, in that order. Empty
21
- * when the rig's clip names do not resolve to locomotion roles, which leaves the character on its own
22
- * rig instead of drawing a frozen crowd copy.
39
+ * when the model plays no clips at all (a static character) or when the rig's clip names do not
40
+ * resolve to locomotion roles, which leaves the character on its own rig instead of drawing a frozen
41
+ * crowd copy.
23
42
  */
24
43
  export declare function crowdClipsOf(model: ModelConfig, clipNames: readonly string[]): string[];
25
- /** The baked clip a crowd member at `speed` plays; clips are `[idle, walk, run]` as {@link crowdClipsOf} orders them. */
26
- export declare function crowdClipForSpeed(clips: readonly string[], speed: number): string | undefined;
44
+ /**
45
+ * The ground speeds the model's own locomotion graph switches clips at, so a batched character walks
46
+ * and idles at the same speed its own rig would. A model that declares none keeps the engine's
47
+ * defaults.
48
+ */
49
+ export declare function crowdSpeedBandsOf(model: ModelConfig, clipNames: readonly string[]): CrowdSpeedBands;
50
+ /**
51
+ * The baked clip a crowd member at `speed` plays; clips are `[idle, walk, run]` as {@link crowdClipsOf}
52
+ * orders them. `bands` is the model's own walk/run speed.
53
+ */
54
+ export declare function crowdClipForSpeed(clips: readonly string[], speed: number, bands?: CrowdSpeedBands): string | undefined;
27
55
  /** Mark a crowd batch drawable, so markers may hand it characters. @internal */
28
56
  export declare function setCrowdKindReady(kind: string, ready: boolean): void;
29
57
  /** Whether a character kind has a crowd batch that can draw it. @internal */
@@ -32,13 +60,35 @@ export declare function crowdKindReady(kind: string): boolean;
32
60
  * The characters the crowd layer is currently drawing, written by their entity markers each frame.
33
61
  * It holds poses rather than React state: 71 pedestrians writing matrices must not re-render the
34
62
  * world, and the crowd batch reads the same entries back to fill its instance buffers.
63
+ *
64
+ * A marker asks {@link claim} for a slot; the batch declares how many it can draw, so a full batch
65
+ * leaves the rest of its kind on their own rigs. The registry keeps drawn ids in claim order, which
66
+ * is the instance order the batch writes, so a character leaving compacts the rest instead of
67
+ * leaving a stale matrix behind.
35
68
  */
36
69
  export declare class CharacterCrowdRegistry {
37
70
  private readonly members;
71
+ private readonly capacities;
72
+ private readonly geometries;
73
+ /** Declare how many characters one kind's batch can draw; `undefined` clears the batch. */
74
+ setCapacity(kind: string, capacity: number | undefined): void;
75
+ /** How many characters one kind's batch can draw; zero until its batch mounts. */
76
+ capacity(kind: string): number;
77
+ /** Record the geometry and placement a batch draws its kind with, for picking. */
78
+ setGeometry(kind: string, geometry: CrowdBatchGeometry | undefined): void;
79
+ /** The drawn geometry of one kind, once its batch is ready. */
80
+ geometry(kind: string): CrowdBatchGeometry | undefined;
81
+ /**
82
+ * Hand a character to the batch. Returns false when the batch is full, and the caller then keeps
83
+ * that character on its own rig. An existing member always updates.
84
+ */
85
+ claim(id: string, kind: string, member: CrowdMember): boolean;
38
86
  /** Record the pose an entity is showing as a crowd instance this frame. */
39
87
  set(id: string, kind: string, member: CrowdMember): void;
40
88
  /** The entity went back to its own rig, or unmounted. */
41
89
  delete(id: string): void;
90
+ /** The kind this character is batched as, or `undefined` when it draws its own rig. */
91
+ kindOf(id: string): string | undefined;
42
92
  /** How many characters this kind is drawing; zero means the batch can skip its work. */
43
93
  count(kind: string): number;
44
94
  /** Visit every character of one kind, in insertion order. */