@jgengine/shell 0.27.0 → 0.29.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 +140 -0
- package/dist/GameUiPreview.js +3 -1
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.js +3 -1
- package/dist/camera/GameCameraRig.js +2 -2
- package/dist/camera/GameFirstPersonCamera.js +39 -2
- package/dist/camera/cameraCollision.d.ts +66 -1
- package/dist/camera/cameraCollision.js +72 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/camera/rigResolve.d.ts +13 -0
- package/dist/camera/rigResolve.js +16 -0
- package/dist/environment/EnvironmentScene.js +69 -4
- package/dist/postfx/shadowPrepass.d.ts +8 -1
- package/dist/postfx/shadowPrepass.js +18 -2
- package/dist/postfx/stylizePass.js +2 -1
- package/dist/render/CascadedShadows.d.ts +31 -1
- package/dist/render/CascadedShadows.js +71 -5
- package/dist/render/CharacterCrowd.d.ts +20 -0
- package/dist/render/CharacterCrowd.js +176 -0
- package/dist/render/SceneLighting.js +27 -3
- package/dist/render/SceneWarmupGate.d.ts +20 -0
- package/dist/render/SceneWarmupGate.js +48 -0
- package/dist/render/characterLod.d.ts +62 -0
- package/dist/render/characterLod.js +165 -0
- package/dist/render/crowdRegistry.d.ts +48 -0
- package/dist/render/crowdRegistry.js +97 -0
- package/dist/render/materialAsset.js +2 -1
- package/dist/render/modelLoad.js +3 -0
- package/dist/render/modelMeshMerge.d.ts +49 -0
- package/dist/render/modelMeshMerge.js +269 -0
- package/dist/render/shaderWarmup.d.ts +69 -0
- package/dist/render/shaderWarmup.js +120 -0
- package/dist/render/shadowCasters.d.ts +28 -0
- package/dist/render/shadowCasters.js +95 -0
- package/dist/render/staticTransforms.d.ts +20 -0
- package/dist/render/staticTransforms.js +32 -0
- package/dist/render/useFootIk.d.ts +11 -3
- package/dist/render/useFootIk.js +35 -7
- package/dist/render/useModelAnimation.d.ts +16 -2
- package/dist/render/useModelAnimation.js +73 -4
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +23 -0
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -0
- package/dist/settings/SettingsMenu.d.ts +1 -1
- package/dist/settings/SettingsMenu.js +10 -79
- package/dist/settings/settingsController.js +3 -0
- package/dist/world/StaticObjectModels.d.ts +13 -0
- package/dist/world/StaticObjectModels.js +28 -3
- package/dist/world/WorldParticles.d.ts +2 -1
- package/dist/world/WorldParticles.js +29 -10
- package/dist/world/WorldScene.js +50 -13
- package/dist/world/entityPose.d.ts +17 -0
- package/dist/world/entityPose.js +42 -0
- package/dist/world/staticObjectBatches.d.ts +33 -5
- package/dist/world/staticObjectBatches.js +40 -6
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +200 -9
- package/skills/jgengine-ui/capabilities.md +103 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +128 -6
- package/skills/jgengine-world/capabilities.md +41 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
|
3
|
+
const MERGED_NAMES_KEY = "jgMergedMeshNames";
|
|
4
|
+
let mergeEnabled = true;
|
|
5
|
+
/**
|
|
6
|
+
* Turn load-time sub-mesh merging on or off. On (the default) every loaded model whose meshes share a
|
|
7
|
+
* material collapses to one mesh per material, which is what a game with many instances of a
|
|
8
|
+
* multi-mesh prop needs; a game that must keep per-mesh nodes (named material slots, node visibility)
|
|
9
|
+
* turns it off.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export function setModelSubMeshMerge(enabled) {
|
|
13
|
+
mergeEnabled = enabled;
|
|
14
|
+
}
|
|
15
|
+
/** The node names a merged mesh absorbed, in the order they were merged. Empty for an unmerged mesh. @internal */
|
|
16
|
+
export function mergedMeshNames(mesh) {
|
|
17
|
+
const names = mesh.userData[MERGED_NAMES_KEY];
|
|
18
|
+
return Array.isArray(names) ? names : [];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether a mesh answers to `name`: its own name, or one of the sub-meshes a load-time merge folded
|
|
22
|
+
* into it. Name-based lookups (material assignment selectors) go through this so a merged model keeps
|
|
23
|
+
* resolving the names its source meshes had.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export function meshMatchesName(mesh, name) {
|
|
27
|
+
return mesh.name === name || mergedMeshNames(mesh).includes(name);
|
|
28
|
+
}
|
|
29
|
+
function countMeshes(root) {
|
|
30
|
+
let count = 0;
|
|
31
|
+
root.traverse((node) => {
|
|
32
|
+
if (node.isMesh === true)
|
|
33
|
+
count += 1;
|
|
34
|
+
});
|
|
35
|
+
return count;
|
|
36
|
+
}
|
|
37
|
+
/** Skinning, instancing and bone parents are all driven at runtime, so their transforms cannot be baked. */
|
|
38
|
+
function hasRiggedAncestor(mesh, root) {
|
|
39
|
+
let node = mesh;
|
|
40
|
+
while (node !== null && node !== root) {
|
|
41
|
+
if (node.isBone === true)
|
|
42
|
+
return true;
|
|
43
|
+
if (node.isSkinnedMesh === true)
|
|
44
|
+
return true;
|
|
45
|
+
node = node.parent;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/** Attribute layout two geometries must agree on before their vertices can share a draw. */
|
|
50
|
+
function attributeKey(geometry) {
|
|
51
|
+
const parts = [geometry.index === null ? "plain" : "indexed"];
|
|
52
|
+
for (const name of Object.keys(geometry.attributes).sort()) {
|
|
53
|
+
const attribute = geometry.attributes[name];
|
|
54
|
+
parts.push(`${name}:${attribute.itemSize}:${attribute.normalized === true ? 1 : 0}`);
|
|
55
|
+
}
|
|
56
|
+
return parts.join(",");
|
|
57
|
+
}
|
|
58
|
+
function isMergeable(mesh, root) {
|
|
59
|
+
if (mesh.isSkinnedMesh === true || mesh.isInstancedMesh === true)
|
|
60
|
+
return false;
|
|
61
|
+
// A node with children carries them: removing it would orphan them, and merging the children instead
|
|
62
|
+
// is handled by their own groups.
|
|
63
|
+
if (mesh.children.length > 0)
|
|
64
|
+
return false;
|
|
65
|
+
if (mesh.visible !== true)
|
|
66
|
+
return false;
|
|
67
|
+
if (Array.isArray(mesh.material) || mesh.material === undefined || mesh.material === null)
|
|
68
|
+
return false;
|
|
69
|
+
const geometry = mesh.geometry;
|
|
70
|
+
if (geometry === undefined || geometry.attributes.position === undefined)
|
|
71
|
+
return false;
|
|
72
|
+
if (Object.keys(geometry.morphAttributes).length > 0)
|
|
73
|
+
return false;
|
|
74
|
+
return !hasRiggedAncestor(mesh, root);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Collapse a freshly loaded model's meshes that share a material into one mesh per material, baking
|
|
78
|
+
* each mesh's transform relative to `root` into the merged vertices. Runs once per load, so every
|
|
79
|
+
* instance cloned from the model inherits the lower draw count.
|
|
80
|
+
*
|
|
81
|
+
* Skinned meshes, instanced meshes, meshes with morph targets or rigged ancestors, meshes with
|
|
82
|
+
* children, hidden meshes, mirrored meshes, and every model carrying an animation clip are left
|
|
83
|
+
* untouched: a clip can move any node in the file, so baking an animated model's transforms would
|
|
84
|
+
* freeze it.
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
87
|
+
export function mergeModelSubMeshes(root, animations = []) {
|
|
88
|
+
if (!mergeEnabled || animations.length > 0)
|
|
89
|
+
return { merged: 0, kept: countMeshes(root) };
|
|
90
|
+
root.updateMatrixWorld(true);
|
|
91
|
+
const rootInverse = root.matrixWorld.clone().invert();
|
|
92
|
+
const groups = new Map();
|
|
93
|
+
let kept = 0;
|
|
94
|
+
root.traverse((node) => {
|
|
95
|
+
const mesh = node;
|
|
96
|
+
if (mesh.isMesh !== true)
|
|
97
|
+
return;
|
|
98
|
+
if (!isMergeable(mesh, root)) {
|
|
99
|
+
kept += 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// A mirrored node renders with flipped winding, which a baked vertex has no way to express.
|
|
103
|
+
const relative = new THREE.Matrix4().multiplyMatrices(rootInverse, mesh.matrixWorld);
|
|
104
|
+
if (relative.determinant() < 0) {
|
|
105
|
+
kept += 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const material = mesh.material;
|
|
109
|
+
const key = `${material.uuid}|${attributeKey(mesh.geometry)}`;
|
|
110
|
+
const group = groups.get(key);
|
|
111
|
+
if (group === undefined)
|
|
112
|
+
groups.set(key, { material, meshes: [{ mesh, relative }] });
|
|
113
|
+
else
|
|
114
|
+
group.meshes.push({ mesh, relative });
|
|
115
|
+
});
|
|
116
|
+
let merged = 0;
|
|
117
|
+
for (const group of groups.values()) {
|
|
118
|
+
if (group.meshes.length < 2) {
|
|
119
|
+
kept += group.meshes.length;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const parts = group.meshes.map(({ mesh, relative }) => mesh.geometry.clone().applyMatrix4(relative));
|
|
123
|
+
const geometry = mergeGeometries(parts, false);
|
|
124
|
+
for (const part of parts)
|
|
125
|
+
part.dispose();
|
|
126
|
+
if (geometry === null) {
|
|
127
|
+
kept += group.meshes.length;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
geometry.computeBoundingBox();
|
|
131
|
+
geometry.computeBoundingSphere();
|
|
132
|
+
const first = group.meshes[0].mesh;
|
|
133
|
+
const mesh = new THREE.Mesh(geometry, group.material);
|
|
134
|
+
mesh.name = first.name;
|
|
135
|
+
mesh.renderOrder = first.renderOrder;
|
|
136
|
+
mesh.frustumCulled = first.frustumCulled;
|
|
137
|
+
mesh.castShadow = first.castShadow;
|
|
138
|
+
mesh.receiveShadow = first.receiveShadow;
|
|
139
|
+
const names = [...new Set(group.meshes.map(({ mesh: source }) => source.name).filter((name) => name.length > 0))];
|
|
140
|
+
if (names.length > 0)
|
|
141
|
+
mesh.userData[MERGED_NAMES_KEY] = names;
|
|
142
|
+
for (const { mesh: source } of group.meshes)
|
|
143
|
+
source.removeFromParent();
|
|
144
|
+
root.add(mesh);
|
|
145
|
+
merged += group.meshes.length - 1;
|
|
146
|
+
kept += 1;
|
|
147
|
+
}
|
|
148
|
+
return { merged, kept };
|
|
149
|
+
}
|
|
150
|
+
function boneSlot(slots, bone, inverse) {
|
|
151
|
+
const found = slots.findIndex((slot) => slot.bone === bone && slot.inverse.equals(inverse));
|
|
152
|
+
if (found >= 0)
|
|
153
|
+
return found;
|
|
154
|
+
slots.push({ bone, inverse: inverse.clone() });
|
|
155
|
+
return slots.length - 1;
|
|
156
|
+
}
|
|
157
|
+
function isMergeableSkinnedPart(mesh) {
|
|
158
|
+
if (mesh.isSkinnedMesh !== true)
|
|
159
|
+
return false;
|
|
160
|
+
if (mesh.children.length > 0 || mesh.visible !== true)
|
|
161
|
+
return false;
|
|
162
|
+
if (Array.isArray(mesh.material) || mesh.material === undefined || mesh.material === null)
|
|
163
|
+
return false;
|
|
164
|
+
const geometry = mesh.geometry;
|
|
165
|
+
if (geometry.attributes.position === undefined)
|
|
166
|
+
return false;
|
|
167
|
+
if (Object.keys(geometry.morphAttributes).length > 0)
|
|
168
|
+
return false;
|
|
169
|
+
return geometry.getAttribute("skinIndex") !== undefined && geometry.getAttribute("skinWeight") !== undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Collapse a rigged model's skinned parts that share a skeleton and a material into one skinned mesh,
|
|
173
|
+
* so one character is one draw and one bone matrix upload instead of one per body part. Vertices move
|
|
174
|
+
* into bind space (each part's own `bindMatrix` baked in) and skin indices are remapped onto the
|
|
175
|
+
* union of the parts' bones, which leaves the posed result unchanged. Runs once per load, before any
|
|
176
|
+
* clone, so every instance inherits the lower part count.
|
|
177
|
+
*
|
|
178
|
+
* Parts with a different material, a different attribute layout, morph targets, several materials or
|
|
179
|
+
* children stay as they are. A model that only has one part is untouched.
|
|
180
|
+
* @internal
|
|
181
|
+
*/
|
|
182
|
+
export function mergeSkinnedModelParts(root) {
|
|
183
|
+
if (!mergeEnabled)
|
|
184
|
+
return { merged: 0, kept: countMeshes(root) };
|
|
185
|
+
root.updateMatrixWorld(true);
|
|
186
|
+
const groups = new Map();
|
|
187
|
+
let kept = 0;
|
|
188
|
+
root.traverse((node) => {
|
|
189
|
+
const mesh = node;
|
|
190
|
+
if (mesh.isMesh !== true)
|
|
191
|
+
return;
|
|
192
|
+
if (!isMergeableSkinnedPart(mesh)) {
|
|
193
|
+
if (mesh.isSkinnedMesh === true)
|
|
194
|
+
kept += 1;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const skinned = mesh;
|
|
198
|
+
const bones = skinned.skeleton.bones.map((bone) => bone.uuid).join(",");
|
|
199
|
+
const material = skinned.material;
|
|
200
|
+
const key = `${material.uuid}|${attributeKey(skinned.geometry)}|${bones}`;
|
|
201
|
+
const group = groups.get(key);
|
|
202
|
+
if (group === undefined)
|
|
203
|
+
groups.set(key, { material, meshes: [skinned] });
|
|
204
|
+
else
|
|
205
|
+
group.meshes.push(skinned);
|
|
206
|
+
});
|
|
207
|
+
let merged = 0;
|
|
208
|
+
for (const group of groups.values()) {
|
|
209
|
+
if (group.meshes.length < 2) {
|
|
210
|
+
kept += group.meshes.length;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const slots = [];
|
|
214
|
+
const parts = [];
|
|
215
|
+
for (const mesh of group.meshes) {
|
|
216
|
+
const source = mesh.geometry;
|
|
217
|
+
const part = new THREE.BufferGeometry();
|
|
218
|
+
for (const [name, attribute] of Object.entries(source.attributes))
|
|
219
|
+
part.setAttribute(name, attribute.clone());
|
|
220
|
+
if (source.index !== null)
|
|
221
|
+
part.setIndex(source.index.clone());
|
|
222
|
+
part.applyMatrix4(mesh.bindMatrix);
|
|
223
|
+
const skinIndex = source.getAttribute("skinIndex");
|
|
224
|
+
const skinWeight = source.getAttribute("skinWeight");
|
|
225
|
+
const bySkeletonIndex = mesh.skeleton.bones.map((bone, index) => boneSlot(slots, bone, mesh.skeleton.boneInverses[index]));
|
|
226
|
+
const indices = new Float32Array(skinIndex.count * 4);
|
|
227
|
+
const weights = new Float32Array(skinWeight.count * 4);
|
|
228
|
+
for (let vertex = 0; vertex < skinIndex.count; vertex += 1) {
|
|
229
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
230
|
+
indices[vertex * 4 + channel] = bySkeletonIndex[skinIndex.getComponent(vertex, channel)] ?? 0;
|
|
231
|
+
weights[vertex * 4 + channel] = skinWeight.getComponent(vertex, channel);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
part.setAttribute("skinIndex", new THREE.Float32BufferAttribute(indices, 4));
|
|
235
|
+
part.setAttribute("skinWeight", new THREE.Float32BufferAttribute(weights, 4));
|
|
236
|
+
parts.push(part);
|
|
237
|
+
}
|
|
238
|
+
const geometry = mergeGeometries(parts, false);
|
|
239
|
+
for (const part of parts)
|
|
240
|
+
part.dispose();
|
|
241
|
+
if (geometry === null) {
|
|
242
|
+
kept += group.meshes.length;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
geometry.computeBoundingBox();
|
|
246
|
+
geometry.computeBoundingSphere();
|
|
247
|
+
const first = group.meshes[0];
|
|
248
|
+
const mesh = new THREE.SkinnedMesh(geometry, group.material);
|
|
249
|
+
// Bind mode "attached" keeps bindMatrixInverse equal to the mesh's own world matrix, which is what
|
|
250
|
+
// makes vertices baked out of bind space pose the same wherever the merged node hangs. An explicit
|
|
251
|
+
// identity bind matrix is required: bind() would otherwise recalculate rest inverses from the
|
|
252
|
+
// current pose and skin every part against the wrong rest.
|
|
253
|
+
mesh.bind(new THREE.Skeleton(slots.map((slot) => slot.bone), slots.map((slot) => slot.inverse)), new THREE.Matrix4());
|
|
254
|
+
mesh.name = first.name;
|
|
255
|
+
mesh.renderOrder = first.renderOrder;
|
|
256
|
+
mesh.frustumCulled = first.frustumCulled;
|
|
257
|
+
mesh.castShadow = first.castShadow;
|
|
258
|
+
mesh.receiveShadow = first.receiveShadow;
|
|
259
|
+
const names = [...new Set(group.meshes.map((part) => part.name).filter((name) => name.length > 0))];
|
|
260
|
+
if (names.length > 0)
|
|
261
|
+
mesh.userData[MERGED_NAMES_KEY] = names;
|
|
262
|
+
for (const source of group.meshes)
|
|
263
|
+
source.removeFromParent();
|
|
264
|
+
root.add(mesh);
|
|
265
|
+
merged += group.meshes.length - 1;
|
|
266
|
+
kept += 1;
|
|
267
|
+
}
|
|
268
|
+
return { merged, kept };
|
|
269
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Camera, Object3D } from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Where the load-time shader warmup is: waiting for the scene to stop growing, compiling a
|
|
4
|
+
* round, finished, or skipped because the renderer refused. The loading screen renders it.
|
|
5
|
+
*/
|
|
6
|
+
export interface ShaderWarmupState {
|
|
7
|
+
phase: "waiting" | "compiling" | "ready" | "failed";
|
|
8
|
+
/** Compile rounds finished. One round covers one burst of streamed models. */
|
|
9
|
+
rounds: number;
|
|
10
|
+
/** Shader programs the renderer has initialised, as of the last round. */
|
|
11
|
+
programs: number;
|
|
12
|
+
elapsedMs: number;
|
|
13
|
+
}
|
|
14
|
+
/** The renderer surface the warmup needs; a real `WebGLRenderer` satisfies it. */
|
|
15
|
+
export interface ShaderWarmupRenderer {
|
|
16
|
+
/** Links every mounted material's program; returns the materials it prepared. */
|
|
17
|
+
compile(scene: Object3D, camera: Camera): unknown;
|
|
18
|
+
/** Renderer program list; each entry answers `isReady()` once the driver finishes its link. */
|
|
19
|
+
readonly info?: {
|
|
20
|
+
readonly programs?: readonly unknown[] | null;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Defaults sized to cover an initial burst of streamed models without holding the screen. */
|
|
24
|
+
export declare const SHADER_WARMUP_DEFAULTS: {
|
|
25
|
+
/** Loader quiet time after which a round is the last one. */
|
|
26
|
+
readonly quietMs: 400;
|
|
27
|
+
/** Wall-clock cap on the whole warmup. The last round is cut off at it. */
|
|
28
|
+
readonly budgetMs: 5000;
|
|
29
|
+
/** Floor for a single round, so a driver that never reports completion cannot stall load. */
|
|
30
|
+
readonly roundFloorMs: 1500;
|
|
31
|
+
/** A scene still streaming after this long gets one last round and is revealed anyway. */
|
|
32
|
+
readonly streamWaitMs: 2000;
|
|
33
|
+
};
|
|
34
|
+
/** Options for {@link createShaderWarmup}; the clock, sleep and loader signal are injectable for tests. */
|
|
35
|
+
export interface ShaderWarmupOptions {
|
|
36
|
+
renderer: ShaderWarmupRenderer;
|
|
37
|
+
scene: Object3D;
|
|
38
|
+
camera: Camera;
|
|
39
|
+
quietMs?: number;
|
|
40
|
+
budgetMs?: number;
|
|
41
|
+
streamWaitMs?: number;
|
|
42
|
+
/** Called on every state change, so the loading screen can show the phase. */
|
|
43
|
+
onState?: (state: ShaderWarmupState) => void;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
sleep?: (ms: number) => Promise<void>;
|
|
46
|
+
/** Whether the shared model loader has been quiet long enough. */
|
|
47
|
+
loaderQuiet?: (quietMs: number) => boolean;
|
|
48
|
+
/** Changes whenever the shared loader starts a new burst of model loads. */
|
|
49
|
+
loaderStarts?: () => number;
|
|
50
|
+
}
|
|
51
|
+
/** A warmup in flight: its promise, its latest state, and a way to stop it. */
|
|
52
|
+
export interface ShaderWarmup {
|
|
53
|
+
/** Resolves when warm, capped or failed; never rejects. */
|
|
54
|
+
done: Promise<ShaderWarmupState>;
|
|
55
|
+
state(): ShaderWarmupState;
|
|
56
|
+
/** Stop after the round in flight and stop waiting; used when the view unmounts. */
|
|
57
|
+
cancel(): void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Warm the scene's shaders through the renderer's async compile, one round per burst of
|
|
61
|
+
* streamed models, so materials first seen during load never compile on a visible frame.
|
|
62
|
+
*
|
|
63
|
+
* A round is `compileAsync` over the mounted scene; the round after it runs only when the
|
|
64
|
+
* model loader started a new burst or the program count grew, which is what a scene that is
|
|
65
|
+
* still streaming looks like. Everything is bounded: the last round is raced against the
|
|
66
|
+
* budget, so a driver that never reports a finished program cannot hold the loading screen.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export declare function createShaderWarmup(options: ShaderWarmupOptions): ShaderWarmup;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { modelLoadIdleMs, modelLoadStarts } from "./modelLoad.js";
|
|
2
|
+
function programReady(program) {
|
|
3
|
+
try {
|
|
4
|
+
return program.isReady?.() !== false;
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Defaults sized to cover an initial burst of streamed models without holding the screen. */
|
|
11
|
+
export const SHADER_WARMUP_DEFAULTS = {
|
|
12
|
+
/** Loader quiet time after which a round is the last one. */
|
|
13
|
+
quietMs: 400,
|
|
14
|
+
/** Wall-clock cap on the whole warmup. The last round is cut off at it. */
|
|
15
|
+
budgetMs: 5_000,
|
|
16
|
+
/** Floor for a single round, so a driver that never reports completion cannot stall load. */
|
|
17
|
+
roundFloorMs: 1_500,
|
|
18
|
+
/** A scene still streaming after this long gets one last round and is revealed anyway. */
|
|
19
|
+
streamWaitMs: 2_000,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Warm the scene's shaders through the renderer's async compile, one round per burst of
|
|
23
|
+
* streamed models, so materials first seen during load never compile on a visible frame.
|
|
24
|
+
*
|
|
25
|
+
* A round is `compileAsync` over the mounted scene; the round after it runs only when the
|
|
26
|
+
* model loader started a new burst or the program count grew, which is what a scene that is
|
|
27
|
+
* still streaming looks like. Everything is bounded: the last round is raced against the
|
|
28
|
+
* budget, so a driver that never reports a finished program cannot hold the loading screen.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export function createShaderWarmup(options) {
|
|
32
|
+
const quietMs = options.quietMs ?? SHADER_WARMUP_DEFAULTS.quietMs;
|
|
33
|
+
const budgetMs = options.budgetMs ?? SHADER_WARMUP_DEFAULTS.budgetMs;
|
|
34
|
+
const streamWaitMs = options.streamWaitMs ?? SHADER_WARMUP_DEFAULTS.streamWaitMs;
|
|
35
|
+
const now = options.now ?? (() => performance.now());
|
|
36
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
37
|
+
const loaderQuiet = options.loaderQuiet ?? ((quiet) => modelLoadIdleMs() >= quiet);
|
|
38
|
+
const loaderStarts = options.loaderStarts ?? modelLoadStarts;
|
|
39
|
+
const startedAt = now();
|
|
40
|
+
let state = { phase: "waiting", rounds: 0, programs: 0, elapsedMs: 0 };
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
const programs = () => options.renderer.info?.programs?.length ?? 0;
|
|
43
|
+
const publish = (next) => {
|
|
44
|
+
state = { ...state, ...next, elapsedMs: Math.round(now() - startedAt) };
|
|
45
|
+
options.onState?.(state);
|
|
46
|
+
};
|
|
47
|
+
const compileRound = async () => {
|
|
48
|
+
const remaining = budgetMs - (now() - startedAt);
|
|
49
|
+
const allowance = Math.max(SHADER_WARMUP_DEFAULTS.roundFloorMs, remaining);
|
|
50
|
+
const deadline = now() + allowance;
|
|
51
|
+
const before = options.renderer.info?.programs?.length ?? 0;
|
|
52
|
+
try {
|
|
53
|
+
// `compileAsync` reports readiness through three's private program property, which throws
|
|
54
|
+
// when a material regenerated its program mid-poll (ember-command does). Poll the public
|
|
55
|
+
// program list instead: same wait, no crash path.
|
|
56
|
+
options.renderer.compile(options.scene, options.camera);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "error";
|
|
60
|
+
}
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (cancelled)
|
|
63
|
+
return "timeout";
|
|
64
|
+
const programs = options.renderer.info?.programs?.slice(before) ?? [];
|
|
65
|
+
if (programs.every(programReady))
|
|
66
|
+
return "compiled";
|
|
67
|
+
if (now() >= deadline)
|
|
68
|
+
return "timeout";
|
|
69
|
+
await sleep(10);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/** Wait (bounded) for the loader to go quiet, so a round is the last one when nothing is left. */
|
|
73
|
+
const waitLoaderQuiet = async (limitMs) => {
|
|
74
|
+
const until = startedAt + budgetMs;
|
|
75
|
+
const limit = Math.min(limitMs, until - now());
|
|
76
|
+
if (loaderQuiet(quietMs))
|
|
77
|
+
return true;
|
|
78
|
+
const deadline = now() + Math.max(0, limit);
|
|
79
|
+
while (!cancelled && now() < deadline) {
|
|
80
|
+
await sleep(100);
|
|
81
|
+
if (loaderQuiet(quietMs))
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return loaderQuiet(quietMs);
|
|
85
|
+
};
|
|
86
|
+
const done = (async () => {
|
|
87
|
+
publish({ phase: "compiling" });
|
|
88
|
+
for (;;) {
|
|
89
|
+
if (cancelled)
|
|
90
|
+
break;
|
|
91
|
+
const starts = loaderStarts();
|
|
92
|
+
const outcome = await compileRound();
|
|
93
|
+
if (outcome === "error") {
|
|
94
|
+
publish({ phase: "failed" });
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
const settled = programs();
|
|
98
|
+
publish({ rounds: state.rounds + (outcome === "compiled" ? 1 : 0), programs: settled });
|
|
99
|
+
if (outcome === "timeout" || cancelled || now() - startedAt >= budgetMs)
|
|
100
|
+
break;
|
|
101
|
+
if (!(await waitLoaderQuiet(streamWaitMs)))
|
|
102
|
+
break;
|
|
103
|
+
const streamed = loaderStarts() !== starts;
|
|
104
|
+
const linked = programs() > settled;
|
|
105
|
+
if (cancelled || (!streamed && !linked))
|
|
106
|
+
break;
|
|
107
|
+
if (now() - startedAt >= budgetMs)
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
publish({ phase: "ready" });
|
|
111
|
+
return state;
|
|
112
|
+
})();
|
|
113
|
+
return {
|
|
114
|
+
done,
|
|
115
|
+
state: () => state,
|
|
116
|
+
cancel: () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* World-space bounding-sphere radius a mesh needs before its shadow is worth a depth
|
|
4
|
+
* draw. Below this the silhouette is a couple of shadow-map texels wide at every
|
|
5
|
+
* cascade that reaches it, so the pass spends a draw for a smudge.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare const MIN_SHADOW_CASTER_RADIUS = 0.5;
|
|
9
|
+
/** A live caster cull: what it is keeping, and how to put the authored flags back. @internal */
|
|
10
|
+
export interface ShadowCasterCull {
|
|
11
|
+
/** Meshes still casting in the tree, and how many were cleared for being under the radius. */
|
|
12
|
+
stats(): {
|
|
13
|
+
casters: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
};
|
|
16
|
+
restore(): void;
|
|
17
|
+
}
|
|
18
|
+
/** Bounding-sphere radius of what a mesh draws, in world units; an instance batch counts as its whole spread. @internal */
|
|
19
|
+
export declare function shadowCasterRadius(mesh: THREE.Mesh): number | null;
|
|
20
|
+
/** True when a mesh's shadow is smaller than the map can resolve, so the depth pass should skip it. @internal */
|
|
21
|
+
export declare function isUnderShadowCasterSize(mesh: THREE.Mesh, minRadius?: number): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Clear `castShadow` on attached meshes under `minRadius` as nodes stream in, and put the
|
|
24
|
+
* authored value back when they leave. Only clears: a mesh the game keeps out of the pass
|
|
25
|
+
* is never turned back on.
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function cullSmallShadowCasters(root: THREE.Object3D, minRadius?: number): ShadowCasterCull;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* World-space bounding-sphere radius a mesh needs before its shadow is worth a depth
|
|
4
|
+
* draw. Below this the silhouette is a couple of shadow-map texels wide at every
|
|
5
|
+
* cascade that reaches it, so the pass spends a draw for a smudge.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export const MIN_SHADOW_CASTER_RADIUS = 0.5;
|
|
9
|
+
function maxAxisScale(node) {
|
|
10
|
+
const e = node.matrixWorld.elements;
|
|
11
|
+
return Math.max(Math.hypot(e[0], e[1], e[2]), Math.hypot(e[4], e[5], e[6]), Math.hypot(e[8], e[9], e[10]));
|
|
12
|
+
}
|
|
13
|
+
/** Bounding-sphere radius of what a mesh draws, in world units; an instance batch counts as its whole spread. @internal */
|
|
14
|
+
export function shadowCasterRadius(mesh) {
|
|
15
|
+
if (mesh.isInstancedMesh === true) {
|
|
16
|
+
const batch = mesh;
|
|
17
|
+
if (batch.boundingSphere === null)
|
|
18
|
+
batch.computeBoundingSphere();
|
|
19
|
+
return batch.boundingSphere === null ? null : batch.boundingSphere.radius * maxAxisScale(batch);
|
|
20
|
+
}
|
|
21
|
+
const geometry = mesh.geometry;
|
|
22
|
+
if (geometry === undefined || geometry === null)
|
|
23
|
+
return null;
|
|
24
|
+
if (geometry.boundingSphere === null)
|
|
25
|
+
geometry.computeBoundingSphere();
|
|
26
|
+
const sphere = geometry.boundingSphere;
|
|
27
|
+
return sphere === null ? null : sphere.radius * maxAxisScale(mesh);
|
|
28
|
+
}
|
|
29
|
+
/** True when a mesh's shadow is smaller than the map can resolve, so the depth pass should skip it. @internal */
|
|
30
|
+
export function isUnderShadowCasterSize(mesh, minRadius = MIN_SHADOW_CASTER_RADIUS) {
|
|
31
|
+
const radius = shadowCasterRadius(mesh);
|
|
32
|
+
return radius !== null && radius < minRadius;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Clear `castShadow` on attached meshes under `minRadius` as nodes stream in, and put the
|
|
36
|
+
* authored value back when they leave. Only clears: a mesh the game keeps out of the pass
|
|
37
|
+
* is never turned back on.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export function cullSmallShadowCasters(root, minRadius = MIN_SHADOW_CASTER_RADIUS) {
|
|
41
|
+
const cleared = new Map();
|
|
42
|
+
const listeners = new Map();
|
|
43
|
+
const apply = (node) => {
|
|
44
|
+
const mesh = node;
|
|
45
|
+
if (mesh.isMesh !== true || cleared.has(mesh) || mesh.castShadow !== true)
|
|
46
|
+
return;
|
|
47
|
+
node.updateWorldMatrix(true, false);
|
|
48
|
+
if (!isUnderShadowCasterSize(mesh, minRadius))
|
|
49
|
+
return;
|
|
50
|
+
cleared.set(mesh, mesh.castShadow);
|
|
51
|
+
mesh.castShadow = false;
|
|
52
|
+
};
|
|
53
|
+
const restoreTree = (node) => {
|
|
54
|
+
const mesh = node;
|
|
55
|
+
const authored = cleared.get(mesh);
|
|
56
|
+
if (authored !== undefined) {
|
|
57
|
+
mesh.castShadow = authored;
|
|
58
|
+
cleared.delete(mesh);
|
|
59
|
+
}
|
|
60
|
+
for (const child of node.children)
|
|
61
|
+
restoreTree(child);
|
|
62
|
+
};
|
|
63
|
+
const attach = (node) => {
|
|
64
|
+
if (listeners.has(node))
|
|
65
|
+
return;
|
|
66
|
+
apply(node);
|
|
67
|
+
const added = ({ child }) => attach(child);
|
|
68
|
+
const removed = ({ child }) => restoreTree(child);
|
|
69
|
+
node.addEventListener("childadded", added);
|
|
70
|
+
node.addEventListener("childremoved", removed);
|
|
71
|
+
listeners.set(node, () => {
|
|
72
|
+
node.removeEventListener("childadded", added);
|
|
73
|
+
node.removeEventListener("childremoved", removed);
|
|
74
|
+
});
|
|
75
|
+
for (const child of node.children)
|
|
76
|
+
attach(child);
|
|
77
|
+
};
|
|
78
|
+
attach(root);
|
|
79
|
+
return {
|
|
80
|
+
stats() {
|
|
81
|
+
let casters = 0;
|
|
82
|
+
root.traverse((node) => {
|
|
83
|
+
if (node.isMesh === true && node.castShadow)
|
|
84
|
+
casters += 1;
|
|
85
|
+
});
|
|
86
|
+
return { casters, skipped: cleared.size };
|
|
87
|
+
},
|
|
88
|
+
restore() {
|
|
89
|
+
for (const off of listeners.values())
|
|
90
|
+
off();
|
|
91
|
+
listeners.clear();
|
|
92
|
+
restoreTree(root);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Bake a subtree's world matrices from its current pose and take it out of three's per-frame
|
|
4
|
+
* `updateMatrixWorld` pass. Three recomposes every node that keeps `matrixAutoUpdate` and then forces
|
|
5
|
+
* its whole subtree to rebuild the world matrix, so authored scenery — terrain, batched props,
|
|
6
|
+
* scatter, instanced ground — pays that cost every frame for a pose it never changes (#2038).
|
|
7
|
+
*
|
|
8
|
+
* Freeze a subtree only when its pose is final and its ancestors stay put: a frozen node ignores a
|
|
9
|
+
* moving parent, and a frozen light or camera stops following the scene. Actors, their bones and
|
|
10
|
+
* anything a game writes per frame keep auto-updating; a frozen seam that moves on a rare event
|
|
11
|
+
* (a door, a lift) thaws, moves, then freezes again.
|
|
12
|
+
* @capability static-transforms
|
|
13
|
+
*/
|
|
14
|
+
export declare function freezeStaticTransforms(root: THREE.Object3D): void;
|
|
15
|
+
/**
|
|
16
|
+
* Put a subtree back on the per-frame matrix pass after {@link freezeStaticTransforms} — for the
|
|
17
|
+
* authored seam that moves on an event rather than every frame.
|
|
18
|
+
* @capability static-transforms
|
|
19
|
+
*/
|
|
20
|
+
export declare function thawStaticTransforms(root: THREE.Object3D): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Bake a subtree's world matrices from its current pose and take it out of three's per-frame
|
|
4
|
+
* `updateMatrixWorld` pass. Three recomposes every node that keeps `matrixAutoUpdate` and then forces
|
|
5
|
+
* its whole subtree to rebuild the world matrix, so authored scenery — terrain, batched props,
|
|
6
|
+
* scatter, instanced ground — pays that cost every frame for a pose it never changes (#2038).
|
|
7
|
+
*
|
|
8
|
+
* Freeze a subtree only when its pose is final and its ancestors stay put: a frozen node ignores a
|
|
9
|
+
* moving parent, and a frozen light or camera stops following the scene. Actors, their bones and
|
|
10
|
+
* anything a game writes per frame keep auto-updating; a frozen seam that moves on a rare event
|
|
11
|
+
* (a door, a lift) thaws, moves, then freezes again.
|
|
12
|
+
* @capability static-transforms
|
|
13
|
+
*/
|
|
14
|
+
export function freezeStaticTransforms(root) {
|
|
15
|
+
root.updateMatrixWorld(true);
|
|
16
|
+
root.traverse((node) => {
|
|
17
|
+
node.matrixAutoUpdate = false;
|
|
18
|
+
node.matrixWorldAutoUpdate = false;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Put a subtree back on the per-frame matrix pass after {@link freezeStaticTransforms} — for the
|
|
23
|
+
* authored seam that moves on an event rather than every frame.
|
|
24
|
+
* @capability static-transforms
|
|
25
|
+
*/
|
|
26
|
+
export function thawStaticTransforms(root) {
|
|
27
|
+
root.traverse((node) => {
|
|
28
|
+
node.matrixAutoUpdate = true;
|
|
29
|
+
node.matrixWorldAutoUpdate = true;
|
|
30
|
+
node.matrixWorldNeedsUpdate = true;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -27,6 +27,13 @@ export interface FootIkRig {
|
|
|
27
27
|
legs: IkLeg[];
|
|
28
28
|
pelvis: THREE.Object3D | null;
|
|
29
29
|
head: THREE.Object3D | null;
|
|
30
|
+
/** Yaw and pitch limits of the look-at solve, in radians; absent means unconstrained. */
|
|
31
|
+
lookAtLimits: {
|
|
32
|
+
maxYaw?: number;
|
|
33
|
+
maxPitch?: number;
|
|
34
|
+
};
|
|
35
|
+
/** How much of the solved look-at turn reaches the bone, `0..1`. */
|
|
36
|
+
lookAtWeight: number;
|
|
30
37
|
maxAdjust: number;
|
|
31
38
|
alignToGround: number;
|
|
32
39
|
/** Pose before and after last frame's correction, so a bone no clip animates never accumulates it. */
|
|
@@ -45,10 +52,11 @@ export declare function resolveFootIkRig(scene: THREE.Object3D, config: FootIkCo
|
|
|
45
52
|
/**
|
|
46
53
|
* Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
|
|
47
54
|
* resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
|
|
48
|
-
* toward the animated knee, and tilts planted feet to the ground.
|
|
49
|
-
*
|
|
55
|
+
* toward the animated knee, and tilts planted feet to the ground. Turns the look-at bone toward
|
|
56
|
+
* `lookAtTarget` when the rig has one. Returns whether the feet are grounded; `state` carries the
|
|
57
|
+
* faded weight between frames. Exported for tests and custom hosts.
|
|
50
58
|
*/
|
|
51
|
-
export declare function applyFootIk(rig: FootIkRig, originY: number, probe: GroundProbe, state: FootIkState, delta: number,
|
|
59
|
+
export declare function applyFootIk(rig: FootIkRig, originY: number, probe: GroundProbe, state: FootIkState, delta: number, lookAtTarget?: readonly [number, number, number]): boolean;
|
|
52
60
|
/**
|
|
53
61
|
* Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
|
|
54
62
|
* blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
|