@jgengine/shell 0.33.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.
- package/CHANGELOG.md +38 -0
- package/dist/GamePlayerShell.js +2 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/audio/AudioComponents.js +7 -0
- package/dist/audio/audioEngine.d.ts +59 -0
- package/dist/audio/audioEngine.js +334 -15
- package/dist/audio/audioProbe.d.ts +10 -5
- package/dist/audio/audioProbe.js +6 -3
- package/dist/audio/occlusion.d.ts +11 -0
- package/dist/audio/occlusion.js +20 -0
- package/dist/play/index.d.ts +3 -0
- package/dist/play/index.js +3 -0
- package/dist/play/run.d.ts +27 -0
- package/dist/play/run.js +266 -0
- package/dist/play/script.d.ts +103 -0
- package/dist/play/script.js +52 -0
- package/dist/play/timeline.d.ts +43 -0
- package/dist/play/timeline.js +27 -0
- package/dist/pointer/pointerService.d.ts +2 -0
- package/dist/pointer/pointerService.js +4 -1
- package/dist/postfx/PostProcessing.d.ts +15 -1
- package/dist/postfx/PostProcessing.js +16 -6
- package/dist/postfx/postfxTierPolicy.d.ts +25 -0
- package/dist/postfx/postfxTierPolicy.js +17 -0
- package/dist/render/CascadedShadows.d.ts +4 -1
- package/dist/render/CascadedShadows.js +15 -7
- package/dist/render/CharacterCrowd.d.ts +11 -7
- package/dist/render/CharacterCrowd.js +103 -90
- package/dist/render/SceneLighting.js +14 -6
- package/dist/render/SceneWarmupGate.d.ts +6 -1
- package/dist/render/SceneWarmupGate.js +33 -7
- package/dist/render/SkinnedInstances.d.ts +16 -8
- package/dist/render/SkinnedInstances.js +215 -65
- package/dist/render/characterLod.d.ts +7 -0
- package/dist/render/characterLod.js +11 -0
- package/dist/render/crowdBatch.d.ts +45 -0
- package/dist/render/crowdBatch.js +68 -0
- package/dist/render/crowdRegistry.d.ts +55 -5
- package/dist/render/crowdRegistry.js +92 -9
- package/dist/render/shaderWarmup.d.ts +75 -18
- package/dist/render/shaderWarmup.js +221 -64
- package/dist/render/shadowCasters.d.ts +1 -0
- package/dist/render/shadowCasters.js +1 -1
- package/dist/render/shadowTiers.d.ts +36 -0
- package/dist/render/shadowTiers.js +50 -0
- package/dist/world/WorldScene.js +28 -11
- package/package.json +8 -4
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-ui/api.md +79 -9
- package/skills/jgengine-ui/capabilities.md +7 -2
- package/skills/jgengine-world/api.md +21 -3
- package/skills/jgengine-world/capabilities.md +8 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -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
|
-
|
|
10
|
-
|
|
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({
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
geometries.push(geometry);
|
|
68
|
-
materials.push(
|
|
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
|
-
|
|
72
|
-
part
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
22
|
-
* rig instead of drawing a frozen
|
|
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
|
-
/**
|
|
26
|
-
|
|
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. */
|