@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,48 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useThree } from "@react-three/fiber";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
5
|
+
import { createShaderWarmup } from "./shaderWarmup.js";
|
|
6
|
+
/** Warmup state before the gate has run, so the loading screen covers the very first frame. @internal */
|
|
7
|
+
export const SCENE_WARMUP_PENDING = { phase: "waiting", rounds: 0, programs: 0, elapsedMs: 0 };
|
|
8
|
+
/**
|
|
9
|
+
* Compiles the mounted scene's shaders behind the loading screen and reports progress to it.
|
|
10
|
+
* Mounted inside the canvas, after the world, so the first round sees the world's materials.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function SceneWarmupGate({ onState }) {
|
|
14
|
+
const gl = useThree((state) => state.gl);
|
|
15
|
+
const scene = useThree((state) => state.scene);
|
|
16
|
+
const camera = useThree((state) => state.camera);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const warmup = createShaderWarmup({ renderer: gl, scene, camera, onState });
|
|
19
|
+
const unregister = devtools.probes.register("sceneWarmup", () => {
|
|
20
|
+
const state = warmup.state();
|
|
21
|
+
return `${state.phase}, ${state.rounds} rounds, ${state.programs} programs, ${state.elapsedMs}ms`;
|
|
22
|
+
});
|
|
23
|
+
return () => {
|
|
24
|
+
warmup.cancel();
|
|
25
|
+
unregister();
|
|
26
|
+
};
|
|
27
|
+
}, [gl, scene, camera, onState]);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Opaque cover over the canvas while the scene warms: the first frames of a scene compile its
|
|
32
|
+
* materials, and a player should not see those frames. Game UI and menus mount after this and
|
|
33
|
+
* paint over it, so a title screen stays usable.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export function SceneWarmupCover({ state }) {
|
|
37
|
+
if (state.phase === "ready" || state.phase === "failed")
|
|
38
|
+
return null;
|
|
39
|
+
return (_jsx("div", { "data-shell-code-loading": true, "data-shell-warmup": true, role: "status", "aria-live": "polite", style: {
|
|
40
|
+
position: "absolute",
|
|
41
|
+
inset: 0,
|
|
42
|
+
display: "grid",
|
|
43
|
+
placeItems: "center",
|
|
44
|
+
background: "#0f172a",
|
|
45
|
+
color: "#e2e8f0",
|
|
46
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
47
|
+
}, children: state.phase === "compiling" && state.programs > 0 ? `Preparing scene (${state.programs} shaders)` : "Preparing scene." }));
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ModelLodConfig } from "@jgengine/core/game/playableGame";
|
|
2
|
+
import type { SystemProbe } from "@jgengine/core/devtools/types";
|
|
3
|
+
/** Distance policy a character model's {@link ModelLodConfig} resolves to; every distance is metres. */
|
|
4
|
+
export interface CharacterLodPolicy {
|
|
5
|
+
fullRateDistance: number;
|
|
6
|
+
halfRateDistance: number;
|
|
7
|
+
crowdDistance: number;
|
|
8
|
+
crowdHysteresis: number;
|
|
9
|
+
}
|
|
10
|
+
/** The engine's default character tiers: full under 15 m, half under 40 m, GPU crowd beyond 25 m. */
|
|
11
|
+
export declare const DEFAULT_CHARACTER_LOD: CharacterLodPolicy;
|
|
12
|
+
/** What a character's distance from the camera costs it this frame. */
|
|
13
|
+
export type CharacterLodTier = "full" | "half" | "quarter" | "crowd";
|
|
14
|
+
/**
|
|
15
|
+
* Fills a model's authoring gaps with {@link DEFAULT_CHARACTER_LOD}. A missing, negative or
|
|
16
|
+
* non-finite distance falls back to the default rather than producing a tier that never matches.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare function characterLodPolicy(config: ModelLodConfig | undefined): CharacterLodPolicy;
|
|
20
|
+
/**
|
|
21
|
+
* The tier a character at `distance` falls in. Passing the tier it held last frame applies the crowd
|
|
22
|
+
* hysteresis, so a character walking along the crowd boundary does not swap representation every frame.
|
|
23
|
+
*/
|
|
24
|
+
export declare function characterLodTier(distance: number, config: ModelLodConfig | undefined, previous?: CharacterLodTier): CharacterLodTier;
|
|
25
|
+
/** Share of frames a tier poses the rig: 1, 0.5, 0.25, and 0 once the GPU crowd draws it. */
|
|
26
|
+
export declare function characterLodRate(tier: CharacterLodTier): number;
|
|
27
|
+
/** Frames between pose updates for a tier: 1, 2, 4, and `0` meaning "never" for the crowd tier. */
|
|
28
|
+
export declare function characterLodInterval(tier: CharacterLodTier): number;
|
|
29
|
+
/** Record a mounted character rig so the probe can report its parts and bones. @internal */
|
|
30
|
+
export declare function registerCharacterRig(instanceId: string, parts: number, bones: number): void;
|
|
31
|
+
/** Drop a character rig that unmounted. @internal */
|
|
32
|
+
export declare function unregisterCharacterRig(instanceId: string): void;
|
|
33
|
+
/** The tier the world marker decided this frame; the animation driver reads it instead of measuring distance twice. @internal */
|
|
34
|
+
export declare function setCharacterLodTier(instanceId: string, tier: CharacterLodTier): void;
|
|
35
|
+
/** Forget a tier when its marker unmounts. @internal */
|
|
36
|
+
export declare function clearCharacterLodTier(instanceId: string): void;
|
|
37
|
+
/** The tier a mounted character is on; full rate when nothing decided (previews, custom renderers). @internal */
|
|
38
|
+
export declare function characterLodTierOf(instanceId: string | undefined): CharacterLodTier;
|
|
39
|
+
/** Record one pose write (a mixer update) for a rig, in bones. @internal */
|
|
40
|
+
export declare function noteCharacterPose(instanceId: string): void;
|
|
41
|
+
/** Publish the clip and time a rig is posing at, so the crowd can continue the same pose at the swap. @internal */
|
|
42
|
+
export declare function noteAnimationPhase(instanceId: string, clip: string, time: number): void;
|
|
43
|
+
/** The clip phase a character was last posed at, or `undefined` when it never posed. @internal */
|
|
44
|
+
export declare function animationPhaseOf(instanceId: string): {
|
|
45
|
+
clip: string;
|
|
46
|
+
time: number;
|
|
47
|
+
} | undefined;
|
|
48
|
+
/** Record how many crowd members each batch drew this frame. @internal */
|
|
49
|
+
export declare function noteCrowdInstances(kind: string, count: number): void;
|
|
50
|
+
/** Drop a crowd batch's instance count when it unmounts. @internal */
|
|
51
|
+
export declare function clearCrowdKind(kind: string): void;
|
|
52
|
+
/** Count one animated frame, so pose writes report as a per-frame average. @internal */
|
|
53
|
+
export declare function noteCharacterFrame(): void;
|
|
54
|
+
/** Clear every character counter. For tests and between capture runs. @internal */
|
|
55
|
+
export declare function resetCharacterLodStats(): void;
|
|
56
|
+
/**
|
|
57
|
+
* The character-LOD system probe: rigs by tier, skinned parts, and the bone pose writes per frame the
|
|
58
|
+
* distance tiers leave. Mounted by the world's crowd layer, so `debug_snapshot` names the cost a
|
|
59
|
+
* character change removed instead of leaving it to a profile.
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export declare function characterLodProbe(): SystemProbe;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** The engine's default character tiers: full under 15 m, half under 40 m, GPU crowd beyond 25 m. */
|
|
2
|
+
export const DEFAULT_CHARACTER_LOD = {
|
|
3
|
+
fullRateDistance: 15,
|
|
4
|
+
halfRateDistance: 40,
|
|
5
|
+
crowdDistance: 25,
|
|
6
|
+
crowdHysteresis: 2,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Fills a model's authoring gaps with {@link DEFAULT_CHARACTER_LOD}. A missing, negative or
|
|
10
|
+
* non-finite distance falls back to the default rather than producing a tier that never matches.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function characterLodPolicy(config) {
|
|
14
|
+
const finite = (value, fallback) => value !== undefined && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
15
|
+
const policy = {
|
|
16
|
+
fullRateDistance: finite(config?.fullRateDistance, DEFAULT_CHARACTER_LOD.fullRateDistance),
|
|
17
|
+
halfRateDistance: finite(config?.halfRateDistance, DEFAULT_CHARACTER_LOD.halfRateDistance),
|
|
18
|
+
crowdDistance: finite(config?.crowdDistance, DEFAULT_CHARACTER_LOD.crowdDistance),
|
|
19
|
+
crowdHysteresis: finite(config?.crowdHysteresis, DEFAULT_CHARACTER_LOD.crowdHysteresis),
|
|
20
|
+
};
|
|
21
|
+
// Tiers read in order, so a game that sets half below full would otherwise make "full" unreachable.
|
|
22
|
+
policy.halfRateDistance = Math.max(policy.halfRateDistance, policy.fullRateDistance);
|
|
23
|
+
return policy;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The tier a character at `distance` falls in. Passing the tier it held last frame applies the crowd
|
|
27
|
+
* hysteresis, so a character walking along the crowd boundary does not swap representation every frame.
|
|
28
|
+
*/
|
|
29
|
+
export function characterLodTier(distance, config, previous) {
|
|
30
|
+
const policy = characterLodPolicy(config);
|
|
31
|
+
const crowdBoundary = previous === "crowd" ? policy.crowdDistance - policy.crowdHysteresis : policy.crowdDistance;
|
|
32
|
+
if (distance >= crowdBoundary)
|
|
33
|
+
return "crowd";
|
|
34
|
+
if (distance < policy.fullRateDistance)
|
|
35
|
+
return "full";
|
|
36
|
+
if (distance < policy.halfRateDistance)
|
|
37
|
+
return "half";
|
|
38
|
+
return "quarter";
|
|
39
|
+
}
|
|
40
|
+
/** Share of frames a tier poses the rig: 1, 0.5, 0.25, and 0 once the GPU crowd draws it. */
|
|
41
|
+
export function characterLodRate(tier) {
|
|
42
|
+
switch (tier) {
|
|
43
|
+
case "full":
|
|
44
|
+
return 1;
|
|
45
|
+
case "half":
|
|
46
|
+
return 0.5;
|
|
47
|
+
case "quarter":
|
|
48
|
+
return 0.25;
|
|
49
|
+
case "crowd":
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Frames between pose updates for a tier: 1, 2, 4, and `0` meaning "never" for the crowd tier. */
|
|
54
|
+
export function characterLodInterval(tier) {
|
|
55
|
+
const rate = characterLodRate(tier);
|
|
56
|
+
return rate === 0 ? 0 : Math.round(1 / rate);
|
|
57
|
+
}
|
|
58
|
+
const rigs = new Map();
|
|
59
|
+
const tiers = new Map();
|
|
60
|
+
const phases = new Map();
|
|
61
|
+
let poseWrites = 0;
|
|
62
|
+
let poses = 0;
|
|
63
|
+
let frames = 0;
|
|
64
|
+
const crowdInstances = new Map();
|
|
65
|
+
/** Record a mounted character rig so the probe can report its parts and bones. @internal */
|
|
66
|
+
export function registerCharacterRig(instanceId, parts, bones) {
|
|
67
|
+
rigs.set(instanceId, { parts, bones });
|
|
68
|
+
}
|
|
69
|
+
/** Drop a character rig that unmounted. @internal */
|
|
70
|
+
export function unregisterCharacterRig(instanceId) {
|
|
71
|
+
rigs.delete(instanceId);
|
|
72
|
+
tiers.delete(instanceId);
|
|
73
|
+
phases.delete(instanceId);
|
|
74
|
+
}
|
|
75
|
+
/** The tier the world marker decided this frame; the animation driver reads it instead of measuring distance twice. @internal */
|
|
76
|
+
export function setCharacterLodTier(instanceId, tier) {
|
|
77
|
+
tiers.set(instanceId, tier);
|
|
78
|
+
}
|
|
79
|
+
/** Forget a tier when its marker unmounts. @internal */
|
|
80
|
+
export function clearCharacterLodTier(instanceId) {
|
|
81
|
+
tiers.delete(instanceId);
|
|
82
|
+
}
|
|
83
|
+
/** The tier a mounted character is on; full rate when nothing decided (previews, custom renderers). @internal */
|
|
84
|
+
export function characterLodTierOf(instanceId) {
|
|
85
|
+
return instanceId === undefined ? "full" : tiers.get(instanceId) ?? "full";
|
|
86
|
+
}
|
|
87
|
+
/** Record one pose write (a mixer update) for a rig, in bones. @internal */
|
|
88
|
+
export function noteCharacterPose(instanceId) {
|
|
89
|
+
const rig = rigs.get(instanceId);
|
|
90
|
+
if (rig === undefined)
|
|
91
|
+
return;
|
|
92
|
+
poseWrites += rig.bones;
|
|
93
|
+
poses += 1;
|
|
94
|
+
}
|
|
95
|
+
/** Publish the clip and time a rig is posing at, so the crowd can continue the same pose at the swap. @internal */
|
|
96
|
+
export function noteAnimationPhase(instanceId, clip, time) {
|
|
97
|
+
phases.set(instanceId, { clip, time });
|
|
98
|
+
}
|
|
99
|
+
/** The clip phase a character was last posed at, or `undefined` when it never posed. @internal */
|
|
100
|
+
export function animationPhaseOf(instanceId) {
|
|
101
|
+
return phases.get(instanceId);
|
|
102
|
+
}
|
|
103
|
+
/** Record how many crowd members each batch drew this frame. @internal */
|
|
104
|
+
export function noteCrowdInstances(kind, count) {
|
|
105
|
+
crowdInstances.set(kind, count);
|
|
106
|
+
}
|
|
107
|
+
/** Drop a crowd batch's instance count when it unmounts. @internal */
|
|
108
|
+
export function clearCrowdKind(kind) {
|
|
109
|
+
crowdInstances.delete(kind);
|
|
110
|
+
}
|
|
111
|
+
/** Count one animated frame, so pose writes report as a per-frame average. @internal */
|
|
112
|
+
export function noteCharacterFrame() {
|
|
113
|
+
frames += 1;
|
|
114
|
+
}
|
|
115
|
+
/** Clear every character counter. For tests and between capture runs. @internal */
|
|
116
|
+
export function resetCharacterLodStats() {
|
|
117
|
+
rigs.clear();
|
|
118
|
+
tiers.clear();
|
|
119
|
+
phases.clear();
|
|
120
|
+
poseWrites = 0;
|
|
121
|
+
poses = 0;
|
|
122
|
+
frames = 0;
|
|
123
|
+
crowdInstances.clear();
|
|
124
|
+
}
|
|
125
|
+
function readCharacterLod() {
|
|
126
|
+
const counts = { full: 0, half: 0, quarter: 0, crowd: 0 };
|
|
127
|
+
let parts = 0;
|
|
128
|
+
let bones = 0;
|
|
129
|
+
for (const [id, rig] of rigs) {
|
|
130
|
+
parts += rig.parts;
|
|
131
|
+
bones += rig.bones;
|
|
132
|
+
counts[tiers.get(id) ?? "full"] += 1;
|
|
133
|
+
}
|
|
134
|
+
const boneWrites = frames === 0 ? 0 : Math.round(poseWrites / frames);
|
|
135
|
+
let crowd = 0;
|
|
136
|
+
for (const count of crowdInstances.values())
|
|
137
|
+
crowd += count;
|
|
138
|
+
return {
|
|
139
|
+
counters: {
|
|
140
|
+
rigs: rigs.size,
|
|
141
|
+
skinnedParts: parts,
|
|
142
|
+
bones,
|
|
143
|
+
boneWritesPerFrame: boneWrites,
|
|
144
|
+
poses,
|
|
145
|
+
crowdInstances: crowd,
|
|
146
|
+
fullRate: counts.full,
|
|
147
|
+
halfRate: counts.half,
|
|
148
|
+
quarterRate: counts.quarter,
|
|
149
|
+
crowdRate: counts.crowd,
|
|
150
|
+
},
|
|
151
|
+
line: `${rigs.size} rigs · ${parts} skinned parts · ${boneWrites}/${bones} bone writes/frame · crowd ${crowd}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The character-LOD system probe: rigs by tier, skinned parts, and the bone pose writes per frame the
|
|
156
|
+
* distance tiers leave. Mounted by the world's crowd layer, so `debug_snapshot` names the cost a
|
|
157
|
+
* character change removed instead of leaving it to a profile.
|
|
158
|
+
* @internal
|
|
159
|
+
*/
|
|
160
|
+
export function characterLodProbe() {
|
|
161
|
+
return {
|
|
162
|
+
counters: () => readCharacterLod().counters,
|
|
163
|
+
state: () => readCharacterLod().line,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
2
|
+
/** One character the GPU crowd path is drawing instead of its own rig. @internal */
|
|
3
|
+
export interface CrowdMember {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
z: number;
|
|
7
|
+
rotationY: number;
|
|
8
|
+
/** Ground speed in metres per second, which picks the crowd's idle/walk/run clip. */
|
|
9
|
+
speed: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The identity a character model shares with every other model the GPU crowd can draw: rig url,
|
|
13
|
+
* height normalization and material tint. Two entity kinds with the same key can share one instanced
|
|
14
|
+
* draw; a different tint or height needs its own.
|
|
15
|
+
*/
|
|
16
|
+
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. */
|
|
18
|
+
export declare function crowdEligible(model: ModelConfig | undefined): model is ModelConfig;
|
|
19
|
+
/**
|
|
20
|
+
* 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.
|
|
23
|
+
*/
|
|
24
|
+
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;
|
|
27
|
+
/** Mark a crowd batch drawable, so markers may hand it characters. @internal */
|
|
28
|
+
export declare function setCrowdKindReady(kind: string, ready: boolean): void;
|
|
29
|
+
/** Whether a character kind has a crowd batch that can draw it. @internal */
|
|
30
|
+
export declare function crowdKindReady(kind: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The characters the crowd layer is currently drawing, written by their entity markers each frame.
|
|
33
|
+
* It holds poses rather than React state: 71 pedestrians writing matrices must not re-render the
|
|
34
|
+
* world, and the crowd batch reads the same entries back to fill its instance buffers.
|
|
35
|
+
*/
|
|
36
|
+
export declare class CharacterCrowdRegistry {
|
|
37
|
+
private readonly members;
|
|
38
|
+
/** Record the pose an entity is showing as a crowd instance this frame. */
|
|
39
|
+
set(id: string, kind: string, member: CrowdMember): void;
|
|
40
|
+
/** The entity went back to its own rig, or unmounted. */
|
|
41
|
+
delete(id: string): void;
|
|
42
|
+
/** How many characters this kind is drawing; zero means the batch can skip its work. */
|
|
43
|
+
count(kind: string): number;
|
|
44
|
+
/** Visit every character of one kind, in insertion order. */
|
|
45
|
+
forEach(kind: string, visit: (id: string, member: CrowdMember) => void): void;
|
|
46
|
+
/** Drop every entry; called when the crowd layer unmounts. */
|
|
47
|
+
clear(): void;
|
|
48
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
|
|
2
|
+
/** Below this ground speed a crowd character idles; a walk until the run speed. */
|
|
3
|
+
const WALK_CLIP_SPEED = 0.25;
|
|
4
|
+
const RUN_CLIP_SPEED = 2.6;
|
|
5
|
+
/**
|
|
6
|
+
* The identity a character model shares with every other model the GPU crowd can draw: rig url,
|
|
7
|
+
* height normalization and material tint. Two entity kinds with the same key can share one instanced
|
|
8
|
+
* draw; a different tint or height needs its own.
|
|
9
|
+
*/
|
|
10
|
+
export function crowdKindOf(model) {
|
|
11
|
+
return `${model.url}\u0000${model.targetHeight ?? 0}\u0000${model.scale ?? 1}\u0000${JSON.stringify(model.material ?? null)}`;
|
|
12
|
+
}
|
|
13
|
+
/** Whether a model can leave its own rig for the GPU crowd path: a rigged, clip-played, plain model. */
|
|
14
|
+
export function crowdEligible(model) {
|
|
15
|
+
if (model === undefined || model.lod === false)
|
|
16
|
+
return false;
|
|
17
|
+
if (model.ik !== undefined || model.attachments !== undefined || model.parts !== undefined)
|
|
18
|
+
return false;
|
|
19
|
+
return model.animation !== undefined && model.animation !== "none";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The clips a crowd batch bakes for a model: its resolved idle, walk and run, in that order. Empty
|
|
23
|
+
* when the rig's clip names do not resolve to locomotion roles, which leaves the character on its own
|
|
24
|
+
* rig instead of drawing a frozen crowd copy.
|
|
25
|
+
*/
|
|
26
|
+
export function crowdClipsOf(model, clipNames) {
|
|
27
|
+
const animation = resolveAnimationConfig(model.animation, clipNames);
|
|
28
|
+
const states = animation?.states;
|
|
29
|
+
const clips = [];
|
|
30
|
+
for (const name of [states?.idle, states?.walk, states?.run]) {
|
|
31
|
+
if (typeof name === "string" && name.length > 0 && clipNames.includes(name) && !clips.includes(name))
|
|
32
|
+
clips.push(name);
|
|
33
|
+
}
|
|
34
|
+
return clips;
|
|
35
|
+
}
|
|
36
|
+
/** The baked clip a crowd member at `speed` plays; clips are `[idle, walk, run]` as {@link crowdClipsOf} orders them. */
|
|
37
|
+
export function crowdClipForSpeed(clips, speed) {
|
|
38
|
+
if (clips.length === 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (speed >= RUN_CLIP_SPEED)
|
|
41
|
+
return clips[2] ?? clips[1] ?? clips[0];
|
|
42
|
+
if (speed >= WALK_CLIP_SPEED)
|
|
43
|
+
return clips[1] ?? clips[0];
|
|
44
|
+
return clips[0];
|
|
45
|
+
}
|
|
46
|
+
/** Kind keys whose crowd batch has a baked rig and can draw; a character stays on its own rig until then. @internal */
|
|
47
|
+
const readyKinds = new Set();
|
|
48
|
+
/** Mark a crowd batch drawable, so markers may hand it characters. @internal */
|
|
49
|
+
export function setCrowdKindReady(kind, ready) {
|
|
50
|
+
if (ready)
|
|
51
|
+
readyKinds.add(kind);
|
|
52
|
+
else
|
|
53
|
+
readyKinds.delete(kind);
|
|
54
|
+
}
|
|
55
|
+
/** Whether a character kind has a crowd batch that can draw it. @internal */
|
|
56
|
+
export function crowdKindReady(kind) {
|
|
57
|
+
return readyKinds.has(kind);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The characters the crowd layer is currently drawing, written by their entity markers each frame.
|
|
61
|
+
* It holds poses rather than React state: 71 pedestrians writing matrices must not re-render the
|
|
62
|
+
* world, and the crowd batch reads the same entries back to fill its instance buffers.
|
|
63
|
+
*/
|
|
64
|
+
export class CharacterCrowdRegistry {
|
|
65
|
+
members = new Map();
|
|
66
|
+
/** Record the pose an entity is showing as a crowd instance this frame. */
|
|
67
|
+
set(id, kind, member) {
|
|
68
|
+
const existing = this.members.get(id);
|
|
69
|
+
if (existing !== undefined && existing.kind === kind) {
|
|
70
|
+
Object.assign(existing.member, member);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.members.set(id, { kind, member: { ...member } });
|
|
74
|
+
}
|
|
75
|
+
/** The entity went back to its own rig, or unmounted. */
|
|
76
|
+
delete(id) {
|
|
77
|
+
this.members.delete(id);
|
|
78
|
+
}
|
|
79
|
+
/** How many characters this kind is drawing; zero means the batch can skip its work. */
|
|
80
|
+
count(kind) {
|
|
81
|
+
let count = 0;
|
|
82
|
+
for (const entry of this.members.values())
|
|
83
|
+
if (entry.kind === kind)
|
|
84
|
+
count += 1;
|
|
85
|
+
return count;
|
|
86
|
+
}
|
|
87
|
+
/** Visit every character of one kind, in insertion order. */
|
|
88
|
+
forEach(kind, visit) {
|
|
89
|
+
for (const [id, entry] of this.members)
|
|
90
|
+
if (entry.kind === kind)
|
|
91
|
+
visit(id, entry.member);
|
|
92
|
+
}
|
|
93
|
+
/** Drop every entry; called when the crowd layer unmounts. */
|
|
94
|
+
clear() {
|
|
95
|
+
this.members.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -6,6 +6,7 @@ import { MATERIAL_TEXTURE_SEMANTICS, validateMaterialAsset, validateMaterialAssi
|
|
|
6
6
|
import { applyMaterialOverrideToMaterial, MATERIAL_TEXTURE_PROPERTIES, requiresPhysicalMaterial } from "../materialOverride.js";
|
|
7
7
|
import { configureAuthoredSurface } from "./authoredSurfaceMaterial.js";
|
|
8
8
|
import { ownModelMaterial } from "./modelRender.js";
|
|
9
|
+
import { meshMatchesName } from "./modelMeshMerge.js";
|
|
9
10
|
import { useDisposable } from "./useDisposable.js";
|
|
10
11
|
import { detectKtx2Support, materialKtx2Loader } from "./modelLoad.js";
|
|
11
12
|
import { resolveAssetBaseUrl } from "./assetBase.js";
|
|
@@ -200,7 +201,7 @@ function materialAssignmentTargets(root, assets, assignments, options = {}) {
|
|
|
200
201
|
const selected = [];
|
|
201
202
|
root.traverse(node => {
|
|
202
203
|
const mesh = node;
|
|
203
|
-
if (!mesh.isMesh || (assignment.selector.mesh !== undefined && mesh
|
|
204
|
+
if (!mesh.isMesh || (assignment.selector.mesh !== undefined && !meshMatchesName(mesh, assignment.selector.mesh)))
|
|
204
205
|
return;
|
|
205
206
|
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
206
207
|
materials.forEach((material, index) => {
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -6,6 +6,7 @@ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.j
|
|
|
6
6
|
import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
|
|
7
7
|
import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
|
|
8
8
|
import { resolveAssetBaseUrl } from "./assetBase.js";
|
|
9
|
+
import { mergeModelSubMeshes, mergeSkinnedModelParts } from "./modelMeshMerge.js";
|
|
9
10
|
import { applyModelSurface } from "./modelSurface.js";
|
|
10
11
|
/** Model containers already covered by the whole-model fallback probe; not "texture" errors. @internal */
|
|
11
12
|
const MODEL_URL_RE = /\.(glb|gltf)(\?|#|$)/i;
|
|
@@ -212,6 +213,8 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
|
|
|
212
213
|
super.load(url, (gltf) => {
|
|
213
214
|
recordModelTriangles(url, countSceneTriangles(gltf.scene));
|
|
214
215
|
applyModelSurface(gltf.scene);
|
|
216
|
+
mergeSkinnedModelParts(gltf.scene);
|
|
217
|
+
mergeModelSubMeshes(gltf.scene, gltf.animations);
|
|
215
218
|
onLoad(gltf);
|
|
216
219
|
}, onProgress, (event) => {
|
|
217
220
|
if (onError === undefined)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/**
|
|
3
|
+
* Turn load-time sub-mesh merging on or off. On (the default) every loaded model whose meshes share a
|
|
4
|
+
* material collapses to one mesh per material, which is what a game with many instances of a
|
|
5
|
+
* multi-mesh prop needs; a game that must keep per-mesh nodes (named material slots, node visibility)
|
|
6
|
+
* turns it off.
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export declare function setModelSubMeshMerge(enabled: boolean): void;
|
|
10
|
+
/** The node names a merged mesh absorbed, in the order they were merged. Empty for an unmerged mesh. @internal */
|
|
11
|
+
export declare function mergedMeshNames(mesh: THREE.Object3D): readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Whether a mesh answers to `name`: its own name, or one of the sub-meshes a load-time merge folded
|
|
14
|
+
* into it. Name-based lookups (material assignment selectors) go through this so a merged model keeps
|
|
15
|
+
* resolving the names its source meshes had.
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export declare function meshMatchesName(mesh: THREE.Object3D, name: string): boolean;
|
|
19
|
+
/** What {@link mergeModelSubMeshes} did to one loaded model. @internal */
|
|
20
|
+
export interface ModelMeshMergeReport {
|
|
21
|
+
/** Meshes removed from the graph because their geometry joined a same-material sibling. */
|
|
22
|
+
merged: number;
|
|
23
|
+
/** Meshes left in place. */
|
|
24
|
+
kept: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Collapse a freshly loaded model's meshes that share a material into one mesh per material, baking
|
|
28
|
+
* each mesh's transform relative to `root` into the merged vertices. Runs once per load, so every
|
|
29
|
+
* instance cloned from the model inherits the lower draw count.
|
|
30
|
+
*
|
|
31
|
+
* Skinned meshes, instanced meshes, meshes with morph targets or rigged ancestors, meshes with
|
|
32
|
+
* children, hidden meshes, mirrored meshes, and every model carrying an animation clip are left
|
|
33
|
+
* untouched: a clip can move any node in the file, so baking an animated model's transforms would
|
|
34
|
+
* freeze it.
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export declare function mergeModelSubMeshes(root: THREE.Object3D, animations?: readonly THREE.AnimationClip[]): ModelMeshMergeReport;
|
|
38
|
+
/**
|
|
39
|
+
* Collapse a rigged model's skinned parts that share a skeleton and a material into one skinned mesh,
|
|
40
|
+
* so one character is one draw and one bone matrix upload instead of one per body part. Vertices move
|
|
41
|
+
* into bind space (each part's own `bindMatrix` baked in) and skin indices are remapped onto the
|
|
42
|
+
* union of the parts' bones, which leaves the posed result unchanged. Runs once per load, before any
|
|
43
|
+
* clone, so every instance inherits the lower part count.
|
|
44
|
+
*
|
|
45
|
+
* Parts with a different material, a different attribute layout, morph targets, several materials or
|
|
46
|
+
* children stay as they are. A model that only has one part is untouched.
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export declare function mergeSkinnedModelParts(root: THREE.Object3D): ModelMeshMergeReport;
|