@jgengine/shell 0.18.1 → 0.21.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 +504 -7
- package/dist/GameHost.d.ts +12 -1
- package/dist/GameHost.js +14 -1
- package/dist/GamePlayerShell.js +132 -17
- package/dist/Shell3dPresentation.d.ts +5 -11
- package/dist/Shell3dPresentation.js +73 -11
- package/dist/ShellChrome.d.ts +2 -1
- package/dist/ShellChrome.js +22 -6
- package/dist/ShellHudPresentation.d.ts +20 -2
- package/dist/ShellHudPresentation.js +41 -6
- package/dist/audio/AudioComponents.js +6 -2
- package/dist/audio/audioEngine.d.ts +24 -0
- package/dist/audio/audioEngine.js +231 -83
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +9 -3
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/audio/synthEngine.d.ts +7 -1
- package/dist/audio/synthEngine.js +103 -38
- package/dist/camera/GameCameraRig.js +3 -2
- package/dist/camera/GameFirstPersonCamera.d.ts +6 -4
- package/dist/camera/GameFirstPersonCamera.js +68 -27
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +76 -62
- package/dist/camera/chaseHeading.d.ts +9 -0
- package/dist/camera/chaseHeading.js +10 -0
- package/dist/camera/customMuzzle.d.ts +14 -0
- package/dist/camera/customMuzzle.js +68 -0
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/camera/rigResolve.d.ts +3 -0
- package/dist/camera/rigResolve.js +11 -0
- package/dist/combat/ProjectileModels.d.ts +31 -0
- package/dist/combat/ProjectileModels.js +70 -0
- package/dist/commandSink.d.ts +1 -2
- package/dist/commandSink.js +3 -2
- package/dist/defineGame.d.ts +19 -3
- package/dist/defineGame.js +39 -7
- package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
- package/dist/devtools/DevtoolsOverlay.js +15 -167
- package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
- package/dist/devtools/DevtoolsRuntime.js +61 -0
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +16 -2
- package/dist/devtools/devtoolsReports.d.ts +68 -0
- package/dist/devtools/devtoolsReports.js +100 -0
- package/dist/devtools/latencyInstrumentation.d.ts +3 -0
- package/dist/devtools/latencyInstrumentation.js +11 -0
- package/dist/devtools/playtestProbe.d.ts +4 -0
- package/dist/devtools/playtestProbe.js +22 -0
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.js +35 -20
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.d.ts +6 -1
- package/dist/drivers/HudOnlyDriver.js +32 -14
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/EnvironmentScene.js +11 -10
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +11 -1
- package/dist/input/gamepadSource.js +117 -71
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/inputSink.d.ts +6 -6
- package/dist/inputSink.js +60 -21
- package/dist/lazyShell3dPresentation.d.ts +9 -0
- package/dist/lazyShell3dPresentation.js +24 -0
- package/dist/materialOverride.d.ts +42 -0
- package/dist/materialOverride.js +95 -36
- package/dist/multiplayer.d.ts +33 -3
- package/dist/multiplayer.js +105 -22
- package/dist/pointer/pointerService.d.ts +2 -0
- package/dist/pointer/pointerService.js +12 -2
- package/dist/postfx/PostProcessing.d.ts +11 -1
- package/dist/postfx/PostProcessing.js +14 -42
- package/dist/postfx/gradeShader.d.ts +1 -1
- package/dist/postfx/gradeShader.js +30 -2
- package/dist/postfx/shadowPrepass.d.ts +3 -0
- package/dist/postfx/shadowPrepass.js +18 -0
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/presentationRecovery.d.ts +75 -0
- package/dist/presentationRecovery.js +204 -0
- package/dist/presentationRecovery3d.d.ts +4 -0
- package/dist/presentationRecovery3d.js +32 -0
- package/dist/presentationResolve.d.ts +8 -1
- package/dist/presentationResolve.js +5 -2
- package/dist/render/CascadedShadows.d.ts +7 -2
- package/dist/render/CascadedShadows.js +98 -20
- package/dist/render/EnvironmentLighting.js +74 -35
- package/dist/render/HairCards.d.ts +7 -0
- package/dist/render/HairCards.js +23 -0
- package/dist/render/SceneModels.js +52 -78
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
- package/dist/render/authoredSurfaceMaterial.js +108 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/materialAppearanceSignals.d.ts +23 -0
- package/dist/render/materialAppearanceSignals.js +58 -0
- package/dist/render/materialAsset.d.ts +98 -0
- package/dist/render/materialAsset.js +454 -0
- package/dist/render/measureBounds.js +16 -3
- package/dist/render/measureCollisionMesh.js +8 -1
- package/dist/render/modelAssets.d.ts +8 -0
- package/dist/render/modelAssets.js +43 -0
- package/dist/render/modelBindPose.d.ts +5 -0
- package/dist/render/modelBindPose.js +35 -0
- package/dist/render/modelLoad.d.ts +32 -0
- package/dist/render/modelLoad.js +70 -1
- package/dist/render/modelNodeVisibility.d.ts +6 -0
- package/dist/render/modelNodeVisibility.js +18 -0
- package/dist/render/modelRender.d.ts +18 -1
- package/dist/render/modelRender.js +81 -3
- package/dist/render/projectedOverdraw.d.ts +35 -0
- package/dist/render/projectedOverdraw.js +204 -0
- package/dist/render/resolveModel.d.ts +12 -2
- package/dist/render/resolveModel.js +25 -4
- package/dist/render/rigNode.d.ts +26 -0
- package/dist/render/rigNode.js +32 -0
- package/dist/render/spriteRender.d.ts +4 -0
- package/dist/render/spriteRender.js +5 -0
- package/dist/render/staticShapeInstances.d.ts +34 -0
- package/dist/render/staticShapeInstances.js +78 -0
- package/dist/render/useFootIk.d.ts +53 -4
- package/dist/render/useFootIk.js +262 -70
- package/dist/render/useModelAnimation.d.ts +50 -0
- package/dist/render/useModelAnimation.js +245 -99
- package/dist/render/useModelInstance.d.ts +25 -0
- package/dist/render/useModelInstance.js +43 -0
- package/dist/scatter/scatterModels.d.ts +3 -2
- package/dist/scatter/scatterModels.js +15 -7
- package/dist/scene/AuthoredScene.d.ts +23 -6
- package/dist/scene/AuthoredScene.js +46 -9
- package/dist/scene/CityRenderer.js +6 -6
- package/dist/scene/GeneratedAssetRenderer.js +11 -42
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +11 -18
- package/dist/settings/SettingsRuntime.d.ts +5 -0
- package/dist/settings/SettingsRuntime.js +29 -2
- package/dist/settings/appliedSettings.d.ts +9 -3
- package/dist/settings/appliedSettings.js +6 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/shellControlSuspension.d.ts +18 -0
- package/dist/shellControlSuspension.js +28 -0
- package/dist/shellInputPublication.d.ts +28 -0
- package/dist/shellInputPublication.js +49 -0
- package/dist/shellPointerInput.d.ts +18 -0
- package/dist/shellPointerInput.js +49 -0
- package/dist/structures/BuildingKitBatch.d.ts +3 -1
- package/dist/structures/BuildingKitBatch.js +35 -56
- package/dist/structures/BuildingRenderBudget.d.ts +23 -0
- package/dist/structures/BuildingRenderBudget.js +54 -0
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +33 -22
- package/dist/structures/buildingKitMaterials.d.ts +20 -0
- package/dist/structures/buildingKitMaterials.js +70 -0
- package/dist/structures/buildingSpatialBatch.d.ts +11 -0
- package/dist/structures/buildingSpatialBatch.js +45 -0
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/terrain/GrassField.d.ts +2 -1
- package/dist/terrain/GrassField.js +59 -5
- package/dist/terrain/grassGeometry.d.ts +10 -0
- package/dist/terrain/grassGeometry.js +55 -0
- package/dist/terrain/grassMaterial.js +2 -2
- package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
- package/dist/terrain/terrainDetailMaterial.js +7 -7
- package/dist/touch/TouchControlsOverlay.js +4 -4
- package/dist/useShellMultiplayerSync.d.ts +1 -1
- package/dist/useShellMultiplayerSync.js +9 -3
- package/dist/vfx/ParticleField.d.ts +23 -20
- package/dist/vfx/ParticleField.js +168 -50
- package/dist/visibility/CullingProvider.js +2 -62
- package/dist/visibility/cullingDriver.d.ts +9 -0
- package/dist/visibility/cullingDriver.js +68 -0
- package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
- package/dist/weather/AuthoredWeatherLayer.js +31 -0
- package/dist/weather/DustField.d.ts +2 -1
- package/dist/weather/DustField.js +17 -3
- package/dist/weather/FireSpreadLayer.d.ts +4 -2
- package/dist/weather/FireSpreadLayer.js +5 -11
- package/dist/weather/LightningStrike.d.ts +4 -1
- package/dist/weather/LightningStrike.js +13 -17
- package/dist/weather/RainField.d.ts +2 -1
- package/dist/weather/RainField.js +17 -3
- package/dist/weather/RainImpactField.d.ts +16 -0
- package/dist/weather/RainImpactField.js +89 -0
- package/dist/weather/SnowField.d.ts +2 -1
- package/dist/weather/SnowField.js +17 -3
- package/dist/weather/WeatherLayer.d.ts +11 -2
- package/dist/weather/WeatherLayer.js +30 -6
- package/dist/weather/index.d.ts +2 -0
- package/dist/weather/index.js +2 -0
- package/dist/weather/weatherMath.d.ts +2 -0
- package/dist/weather/weatherMath.js +8 -0
- package/dist/weather/weatherShelter.d.ts +18 -0
- package/dist/weather/weatherShelter.js +75 -0
- package/dist/weather/weatherUniforms.d.ts +40 -1
- package/dist/weather/weatherUniforms.js +29 -9
- package/dist/world/AuthoredFlocks.d.ts +22 -0
- package/dist/world/AuthoredFlocks.js +87 -0
- package/dist/world/StaticObjectModels.d.ts +8 -0
- package/dist/world/StaticObjectModels.js +75 -0
- package/dist/world/WorldHud.d.ts +14 -6
- package/dist/world/WorldHud.js +25 -12
- package/dist/world/WorldParticles.d.ts +10 -23
- package/dist/world/WorldParticles.js +210 -62
- package/dist/world/WorldScene.d.ts +3 -1
- package/dist/world/WorldScene.js +41 -26
- package/dist/world/staticObjectBatches.d.ts +35 -0
- package/dist/world/staticObjectBatches.js +163 -0
- package/dist/world/worldBarSamples.d.ts +37 -33
- package/dist/world/worldBarSamples.js +117 -75
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-ui/SKILL.md +16 -17
- package/skills/jgengine-ui/api.md +603 -365
- package/skills/jgengine-ui/capabilities.md +131 -1
- package/skills/jgengine-ui/reference-primitives.md +179 -0
- package/skills/jgengine-ui/reference.md +4 -81
- package/skills/jgengine-world/SKILL.md +25 -4
- package/skills/jgengine-world/api.md +1045 -655
- package/skills/jgengine-world/capabilities.md +246 -9
- package/skills/jgengine-world/recipes/character-animation.md +62 -0
- package/skills/jgengine-world/recipes/controls.md +48 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +55 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +87 -0
- package/skills/jgengine-world/reference.md +180 -4
- package/skills/jgengine-world/visibility.md +139 -0
- package/skills/level-design/SKILL.md +1 -1
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
- package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { useFrame } from "@react-three/fiber";
|
|
1
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
2
2
|
import { useEffect, useMemo, useRef } from "react";
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
|
|
5
5
|
import { ANIM_PARAMS_KEY, createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
|
|
6
|
-
import { LOCOMOTION_SPEED_PARAM,
|
|
6
|
+
import { animGraphFromConfig, LOCOMOTION_CROUCHED_PARAM, LOCOMOTION_GROUNDED_PARAM, LOCOMOTION_SPEED_PARAM, LOCOMOTION_VERTICAL_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
|
|
7
|
+
import { playerMovementTelemetry } from "@jgengine/core/movement/playerMovement";
|
|
8
|
+
import { seededRng } from "@jgengine/core/random/rng";
|
|
7
9
|
import { useOptionalGameContext } from "@jgengine/react/provider";
|
|
8
10
|
function graphClipNames(graph) {
|
|
9
11
|
const names = new Set();
|
|
10
12
|
for (const layer of graph.layers) {
|
|
11
13
|
for (const state of Object.values(layer.states)) {
|
|
12
|
-
if (state.kind === "clip")
|
|
14
|
+
if (state.kind === "clip") {
|
|
13
15
|
names.add(state.clip);
|
|
16
|
+
for (const variant of state.variants ?? [])
|
|
17
|
+
names.add(variant);
|
|
18
|
+
}
|
|
14
19
|
else
|
|
15
20
|
for (const point of state.points)
|
|
16
21
|
names.add(point.clip);
|
|
@@ -19,54 +24,165 @@ function graphClipNames(graph) {
|
|
|
19
24
|
return names;
|
|
20
25
|
}
|
|
21
26
|
/**
|
|
22
|
-
*
|
|
23
|
-
* one-shot table degrade quietly when a named clip is absent: states fall back to `clips[0]` (often
|
|
24
|
-
* an attack clip, so an actor "idles" mid-swing) and one-shots are dropped, so deaths and attacks
|
|
25
|
-
* never play. Neither leaves a trace, which makes a mistyped or wrong-pack clip name one of the
|
|
26
|
-
* hardest render bugs to see — the model loads, animates, and is simply wrong forever.
|
|
27
|
+
* Checks clip mappings, masked layers and root-motion assumptions without starting a mixer.
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
-
* (usually deleting the hand-written config and letting `"auto"` derive it) is obvious.
|
|
29
|
+
* @capability model-animation-diagnostics inspect imported clip mappings, layer masks and root-track compatibility
|
|
30
30
|
*/
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
export function diagnoseModelAnimation(scene, animation, clips) {
|
|
32
|
+
const diagnostics = [];
|
|
33
|
+
if (animation.auto === true) {
|
|
34
|
+
const automatic = resolveAnimationConfig(animation, clips.map((clip) => clip.name));
|
|
35
|
+
if (automatic === undefined)
|
|
36
|
+
return [{
|
|
37
|
+
code: "missing-auto-idle",
|
|
38
|
+
message: `explicit automatic playback needs an identifiable idle clip on this rig. Available: ${clips.length === 0 ? "none" : clips.map((clip) => clip.name).join(", ")}. The bind pose is retained; use a supported animated asset or disable automatic derivation and author this rig's clip mapping.`,
|
|
39
|
+
}];
|
|
40
|
+
animation = automatic;
|
|
41
|
+
}
|
|
34
42
|
const available = new Set(clips.map((clip) => clip.name));
|
|
35
43
|
const missing = new Set();
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
const resolved = animGraphFromConfig(animation);
|
|
45
|
+
const { states } = animation;
|
|
46
|
+
if (animation.graph === undefined && states !== undefined) {
|
|
47
|
+
if (typeof states.idle !== "string" || states.idle.trim().length === 0)
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
code: "incomplete-locomotion", message: `locomotion needs a nonempty idle clip. Configure this rig's idle role; ${animation.clip === undefined ? "the bind pose is retained" : `single clip "${animation.clip}" remains active`} until then.`,
|
|
50
|
+
});
|
|
51
|
+
else if (typeof states.walk !== "string" || states.walk.trim().length === 0)
|
|
52
|
+
diagnostics.push({
|
|
53
|
+
code: "incomplete-locomotion", message: `locomotion has no walk clip; idle "${states.idle}" is held at walking speeds until this rig's walk role is configured.`,
|
|
54
|
+
});
|
|
42
55
|
}
|
|
43
|
-
if (
|
|
44
|
-
for (const
|
|
45
|
-
for (const name of typeof spec === "string" ? [spec] : spec) {
|
|
46
|
-
if (!available.has(name))
|
|
47
|
-
missing.add(name);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (graph !== undefined) {
|
|
52
|
-
for (const name of graphClipNames(graph))
|
|
56
|
+
if (resolved !== undefined) {
|
|
57
|
+
for (const name of graphClipNames(resolved))
|
|
53
58
|
if (!available.has(name))
|
|
54
59
|
missing.add(name);
|
|
55
60
|
}
|
|
56
|
-
if (animation.clip !== undefined && !available.has(animation.clip))
|
|
61
|
+
else if (animation.clip !== undefined && !available.has(animation.clip))
|
|
57
62
|
missing.add(animation.clip);
|
|
58
|
-
if (missing.size
|
|
63
|
+
if (missing.size > 0)
|
|
64
|
+
diagnostics.push({ code: "missing-clip", message: `clip(s) ${[...missing].map((name) => `"${name}"`).join(", ")} ` +
|
|
65
|
+
`not found on this rig. Available: ${clips.map((clip) => clip.name).join(", ")}. ` +
|
|
66
|
+
`Missing clips contribute no pose; an invalid single-clip config retains the bind pose. Set animation: "auto" to ` +
|
|
67
|
+
`derive states/one-shots from the rig's own clip names.`,
|
|
68
|
+
});
|
|
69
|
+
if (resolved !== undefined) {
|
|
70
|
+
let root;
|
|
71
|
+
scene.traverse((node) => { if (root === undefined && node.isBone === true)
|
|
72
|
+
root = node; });
|
|
73
|
+
const rootTrackName = root === undefined ? undefined : `${root.name}.position`;
|
|
74
|
+
for (const layer of resolved.layers) {
|
|
75
|
+
const layerNames = graphClipNames({ layers: [layer] });
|
|
76
|
+
if (layer.mask !== undefined && !clips.some((clip) => layerNames.has(clip.name) && clip.tracks.some((track) => layer.mask.some((prefix) => track.name.startsWith(prefix))))) {
|
|
77
|
+
diagnostics.push({ code: "empty-layer-mask", message: `layer "${layer.id}" mask matches no imported animation tracks. Use this rig's bone-name prefixes.` });
|
|
78
|
+
}
|
|
79
|
+
for (const [id, state] of Object.entries(layer.states)) {
|
|
80
|
+
if (state.rootMotion !== true)
|
|
81
|
+
continue;
|
|
82
|
+
const names = state.kind === "clip" ? state.variants ?? [state.clip] : state.points.map((point) => point.clip);
|
|
83
|
+
const missingTracks = names.filter((name) => {
|
|
84
|
+
const clip = clips.find((candidate) => candidate.name === name);
|
|
85
|
+
return clip !== undefined && (rootTrackName === undefined || !clip.tracks.some((track) => track.name === rootTrackName && (layer.mask === undefined || layer.mask.some((prefix) => track.name.startsWith(prefix)))));
|
|
86
|
+
});
|
|
87
|
+
if (missingTracks.length > 0)
|
|
88
|
+
diagnostics.push({ code: "missing-root-track", message: `root-motion state "${layer.id}/${id}" has no assigned position track for ${root === undefined ? "a root bone" : `first bone "${root.name}"`} in ${missingTracks.join(", ")}. ` +
|
|
89
|
+
`Root travel cannot be extracted; use an in-place state or supply a supported root-bone track.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return diagnostics;
|
|
95
|
+
}
|
|
96
|
+
function warnAnimationDiagnostics(scene, animation, clips) {
|
|
97
|
+
if (typeof console === "undefined")
|
|
59
98
|
return;
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
63
|
-
|
|
99
|
+
for (const diagnostic of diagnoseModelAnimation(scene, animation, clips))
|
|
100
|
+
console.warn(`[jgengine] model animation: ${diagnostic.message}`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Refreshes a caller-owned graph parameter dictionary from authored extras and known physical movement.
|
|
104
|
+
* Grounded, verticalSpeed and crouched use the shared motor's resolved state when available; custom movers
|
|
105
|
+
* retain their authored values. Speed always uses the supplied measured ground speed. Does not write game state.
|
|
106
|
+
*
|
|
107
|
+
* @capability animation-motion-params read resolved player movement into reusable animation graph parameters
|
|
108
|
+
*/
|
|
109
|
+
export function readModelAnimationParams(ctx, instanceId, speed, out) {
|
|
110
|
+
const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
|
|
111
|
+
const motion = playerMovementTelemetry(ctx, instanceId);
|
|
112
|
+
if (out === extra || out === motion)
|
|
113
|
+
throw new Error("readModelAnimationParams requires a caller-owned output dictionary distinct from the entity blackboard and movement telemetry");
|
|
114
|
+
for (const key in out)
|
|
115
|
+
if (Object.hasOwn(out, key))
|
|
116
|
+
delete out[key];
|
|
117
|
+
if (extra !== undefined)
|
|
118
|
+
Object.assign(out, extra);
|
|
119
|
+
if (motion !== null) {
|
|
120
|
+
out[LOCOMOTION_GROUNDED_PARAM] = motion.grounded;
|
|
121
|
+
out[LOCOMOTION_VERTICAL_SPEED_PARAM] = motion.verticalVelocity;
|
|
122
|
+
out[LOCOMOTION_CROUCHED_PARAM] = motion.crouching;
|
|
123
|
+
}
|
|
124
|
+
out[LOCOMOTION_SPEED_PARAM] = speed;
|
|
125
|
+
return out;
|
|
64
126
|
}
|
|
65
127
|
/** Per-layer action key: a masked or additive layer needs its own clip variant even for a clip another layer plays. */
|
|
66
128
|
function actionKey(layer, clip) {
|
|
67
129
|
return `${layer}:${clip}`;
|
|
68
130
|
}
|
|
69
|
-
|
|
131
|
+
const rootBasis = new THREE.Matrix3();
|
|
132
|
+
/**
|
|
133
|
+
* For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
|
|
134
|
+
* its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
|
|
135
|
+
* horizontal delta (through the rig's parent transform, so the entity's facing and the model's
|
|
136
|
+
* scale apply). The root bone's vertical motion stays in the clip.
|
|
137
|
+
*/
|
|
138
|
+
export function takeRootMotion(rootBone, bind, localDelta, out) {
|
|
139
|
+
rootBone.position.x = bind.x;
|
|
140
|
+
rootBone.position.z = bind.z;
|
|
141
|
+
out.set(0, 0, 0);
|
|
142
|
+
if (localDelta === undefined || rootBone.parent === null)
|
|
143
|
+
return out;
|
|
144
|
+
rootBone.parent.updateWorldMatrix(true, false);
|
|
145
|
+
out.set(localDelta[0], localDelta[1], localDelta[2]).applyMatrix3(rootBasis.setFromMatrix4(rootBone.parent.matrixWorld));
|
|
146
|
+
out.y = 0;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function applyGraphClips(actions, clips) {
|
|
150
|
+
for (const action of actions.values())
|
|
151
|
+
action.weight = 0;
|
|
152
|
+
for (const entry of clips) {
|
|
153
|
+
const action = actions.get(actionKey(entry.layer, entry.clip));
|
|
154
|
+
if (action === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
action.weight += entry.weight;
|
|
157
|
+
action.time = entry.time;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
|
|
162
|
+
* clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
|
|
163
|
+
* the editor's graph preview, poses the rig from the runtime's output.
|
|
164
|
+
*/
|
|
165
|
+
export function createGraphPose(scene, graph, clips) {
|
|
166
|
+
warnAnimationDiagnostics(scene, { graph }, clips);
|
|
167
|
+
const mixer = new THREE.AnimationMixer(scene);
|
|
168
|
+
const playback = buildGraphPlayback(scene, mixer, graph, clips);
|
|
169
|
+
return {
|
|
170
|
+
durations: playback.durations,
|
|
171
|
+
apply(output, rootMotion = false) {
|
|
172
|
+
applyGraphClips(playback.actions, output);
|
|
173
|
+
mixer.update(0);
|
|
174
|
+
if (rootMotion && playback.rootBone !== null && playback.rootBindPosition !== null) {
|
|
175
|
+
playback.rootBone.position.x = playback.rootBindPosition.x;
|
|
176
|
+
playback.rootBone.position.z = playback.rootBindPosition.z;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
dispose() {
|
|
180
|
+
mixer.stopAllAction();
|
|
181
|
+
mixer.uncacheRoot(scene);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function buildGraphPlayback(scene, mixer, graph, clips, rng) {
|
|
70
186
|
const actions = new Map();
|
|
71
187
|
const durations = {};
|
|
72
188
|
let resolvedRootBone = null;
|
|
@@ -85,8 +201,11 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
|
|
|
85
201
|
for (const layer of graph.layers) {
|
|
86
202
|
const names = new Set();
|
|
87
203
|
for (const state of Object.values(layer.states)) {
|
|
88
|
-
if (state.kind === "clip")
|
|
204
|
+
if (state.kind === "clip") {
|
|
89
205
|
names.add(state.clip);
|
|
206
|
+
for (const variant of state.variants ?? [])
|
|
207
|
+
names.add(variant);
|
|
208
|
+
}
|
|
90
209
|
else
|
|
91
210
|
for (const point of state.points)
|
|
92
211
|
names.add(point.clip);
|
|
@@ -95,13 +214,13 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
|
|
|
95
214
|
const source = THREE.AnimationClip.findByName(clips, name);
|
|
96
215
|
if (source === null)
|
|
97
216
|
continue;
|
|
98
|
-
|
|
217
|
+
// Mixer actions are cached by clip UUID; each layer needs independent time and weight.
|
|
218
|
+
let clip = source.clone();
|
|
99
219
|
if (layer.mask !== undefined) {
|
|
100
220
|
const mask = layer.mask;
|
|
101
221
|
clip = new THREE.AnimationClip(`${source.name}|${layer.id}`, source.duration, source.tracks.filter((track) => mask.some((prefix) => track.name.startsWith(prefix))));
|
|
102
222
|
}
|
|
103
223
|
if (layer.additive === true) {
|
|
104
|
-
clip = clip === source ? clip.clone() : clip;
|
|
105
224
|
THREE.AnimationUtils.makeClipAdditive(clip);
|
|
106
225
|
}
|
|
107
226
|
const action = mixer.clipAction(clip);
|
|
@@ -116,13 +235,14 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
|
|
|
116
235
|
}
|
|
117
236
|
}
|
|
118
237
|
return {
|
|
119
|
-
runtime: createAnimGraphRuntime(graph),
|
|
238
|
+
runtime: createAnimGraphRuntime(graph, { rng }),
|
|
120
239
|
actions,
|
|
121
240
|
durations,
|
|
122
241
|
rootBone,
|
|
123
242
|
rootBindPosition: rootBone?.position.clone() ?? null,
|
|
124
243
|
lastPos: null,
|
|
125
244
|
smoothedSpeed: 0,
|
|
245
|
+
params: {},
|
|
126
246
|
};
|
|
127
247
|
}
|
|
128
248
|
/**
|
|
@@ -133,62 +253,67 @@ function buildGraphPlayback(scene, mixer, graph, clips) {
|
|
|
133
253
|
* `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
|
|
134
254
|
* With `animation.graph` set, the headless `AnimGraph` runtime owns every clip's time and weight
|
|
135
255
|
* and the mixer only applies them; clip events surface as `animation.event`.
|
|
256
|
+
* Equivalent same-order config data retains playback across rerenders; actual data edits reset it.
|
|
257
|
+
* Entity-bound playback holds while context game time is frozen; unbound previews keep playing.
|
|
136
258
|
*/
|
|
137
259
|
export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
260
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
138
261
|
// Optional: a model must still animate its bind pose / auto clip in a preview or inspector that
|
|
139
262
|
// has no running game — a hard context requirement made every part composition unviewable outside
|
|
140
263
|
// the world, which is half of why #1588 took a session to see.
|
|
141
264
|
const ctx = useOptionalGameContext();
|
|
142
265
|
// "auto" (stamped by catalog resolution, or set inline) derives states/one-shots from the
|
|
143
266
|
// loaded GLB's actual clip names; "none" and absent render the bind pose.
|
|
144
|
-
|
|
267
|
+
// Equivalent fresh JSON data retains playback, pending triggers and visual RNG.
|
|
268
|
+
// The private snapshot observes edits on render, including reused nested objects.
|
|
269
|
+
// Preserve one-shot insertion order: simultaneous non-death triggers depend on it.
|
|
270
|
+
const animationKey = JSON.stringify(animationInput);
|
|
271
|
+
const animation = useMemo(() => resolveAnimationConfig(animationKey === undefined ? undefined : JSON.parse(animationKey), clips.map((clip) => clip.name)), [animationKey, clips]);
|
|
145
272
|
const mixerRef = useRef(null);
|
|
273
|
+
const actionRef = useRef(null);
|
|
146
274
|
const animationPausedRef = useRef(false);
|
|
147
275
|
const graphRef = useRef(null);
|
|
276
|
+
const advancedSecondsRef = useRef(undefined);
|
|
148
277
|
const states = animation?.states;
|
|
149
278
|
const oneShots = animation?.oneShots;
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (states === undefined)
|
|
154
|
-
return undefined;
|
|
155
|
-
const graphOneShots = {};
|
|
156
|
-
for (const [event, spec] of Object.entries(oneShots ?? {})) {
|
|
157
|
-
const clip = typeof spec === "string" ? spec : spec[0];
|
|
158
|
-
if (clip !== undefined)
|
|
159
|
-
graphOneShots[event] = clip;
|
|
160
|
-
}
|
|
161
|
-
return locomotionGraph({
|
|
162
|
-
idle: states.idle,
|
|
163
|
-
walk: states.walk,
|
|
164
|
-
...(states.run === undefined ? {} : { run: states.run }),
|
|
165
|
-
walkSpeed: states.walkSpeed,
|
|
166
|
-
runSpeed: states.runSpeed,
|
|
167
|
-
fadeSec: states.fadeSec,
|
|
168
|
-
...(Object.keys(graphOneShots).length === 0 ? {} : { oneShots: graphOneShots }),
|
|
169
|
-
});
|
|
170
|
-
}, [animation?.graph, states, oneShots]);
|
|
279
|
+
const authoredGraph = animation?.graph;
|
|
280
|
+
const graph = useMemo(() => animGraphFromConfig({ graph: authoredGraph, states, oneShots }), [authoredGraph, states, oneShots]);
|
|
281
|
+
const usesGameClock = animation?.clock === "game" && ctx !== null && instanceId !== undefined;
|
|
171
282
|
useEffect(() => {
|
|
283
|
+
advancedSecondsRef.current = usesGameClock ? ctx.time.advancedSeconds?.() : undefined;
|
|
284
|
+
if (usesGameClock && advancedSecondsRef.current === undefined) {
|
|
285
|
+
console.warn('[jgengine] model animation: bound game playback requires time.advancedSeconds() accepted progress. Playback is held; provide an engine clock or select clock: "real".');
|
|
286
|
+
}
|
|
287
|
+
if (animation !== undefined)
|
|
288
|
+
warnAnimationDiagnostics(scene, animation, clips);
|
|
289
|
+
else if (typeof animationInput === "object" && animationInput.auto === true)
|
|
290
|
+
warnAnimationDiagnostics(scene, animationInput, clips);
|
|
172
291
|
if (animation === undefined || clips.length === 0) {
|
|
173
292
|
mixerRef.current = null;
|
|
293
|
+
actionRef.current = null;
|
|
174
294
|
graphRef.current = null;
|
|
175
295
|
return;
|
|
176
296
|
}
|
|
177
|
-
warnMissingClips(animation, clips);
|
|
178
297
|
const mixer = new THREE.AnimationMixer(scene);
|
|
179
298
|
if (graph !== undefined) {
|
|
180
|
-
|
|
299
|
+
// Rendering/culling must never advance simulation randomness. Each model has its own
|
|
300
|
+
// reproducible visual stream, independent of other mounted models and gameplay draws.
|
|
301
|
+
const visualRng = seededRng(JSON.stringify(["model-animation", instanceId ?? "preview", clips.map((clip) => clip.name).sort()]));
|
|
302
|
+
graphRef.current = buildGraphPlayback(scene, mixer, graph, clips, visualRng);
|
|
181
303
|
mixer.update(0);
|
|
182
304
|
mixerRef.current = mixer;
|
|
183
|
-
animationPausedRef.current =
|
|
305
|
+
animationPausedRef.current = animation.paused === true;
|
|
306
|
+
invalidate();
|
|
184
307
|
return () => {
|
|
185
308
|
mixer.stopAllAction();
|
|
309
|
+
mixer.uncacheRoot(scene);
|
|
186
310
|
mixerRef.current = null;
|
|
187
311
|
graphRef.current = null;
|
|
188
312
|
};
|
|
189
313
|
}
|
|
190
|
-
const clip =
|
|
191
|
-
|
|
314
|
+
const clip = animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : states === undefined ? clips[0] : undefined;
|
|
315
|
+
if (clip === null || clip === undefined)
|
|
316
|
+
return;
|
|
192
317
|
const action = mixer.clipAction(clip);
|
|
193
318
|
action.setLoop(animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
|
|
194
319
|
action.clampWhenFinished = animation.loop === false;
|
|
@@ -200,14 +325,19 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
200
325
|
action.time = animation.time;
|
|
201
326
|
mixer.update(0);
|
|
202
327
|
mixerRef.current = mixer;
|
|
328
|
+
actionRef.current = action;
|
|
203
329
|
animationPausedRef.current = animation.paused === true;
|
|
330
|
+
invalidate();
|
|
204
331
|
return () => {
|
|
205
332
|
mixer.stopAllAction();
|
|
333
|
+
mixer.uncacheRoot(scene);
|
|
206
334
|
mixerRef.current = null;
|
|
335
|
+
actionRef.current = null;
|
|
207
336
|
};
|
|
208
337
|
}, [
|
|
209
338
|
scene,
|
|
210
339
|
clips,
|
|
340
|
+
animationKey,
|
|
211
341
|
animation?.clip,
|
|
212
342
|
animation?.loop,
|
|
213
343
|
animation?.timeScale,
|
|
@@ -216,14 +346,19 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
216
346
|
states,
|
|
217
347
|
oneShots,
|
|
218
348
|
graph,
|
|
349
|
+
ctx,
|
|
350
|
+
instanceId,
|
|
351
|
+
invalidate,
|
|
219
352
|
]);
|
|
220
353
|
useEffect(() => {
|
|
221
354
|
if (ctx === null || instanceId === undefined || (oneShots === undefined && graph === undefined))
|
|
222
355
|
return;
|
|
223
356
|
const fire = (event) => {
|
|
224
357
|
const playback = graphRef.current;
|
|
225
|
-
if (playback !== null)
|
|
358
|
+
if (playback !== null) {
|
|
226
359
|
playback.runtime.trigger(event);
|
|
360
|
+
invalidate();
|
|
361
|
+
}
|
|
227
362
|
};
|
|
228
363
|
const offAnimation = ctx.game.events.on("entity.animation", (event) => {
|
|
229
364
|
if (event.instanceId === instanceId)
|
|
@@ -242,56 +377,67 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
242
377
|
offHit();
|
|
243
378
|
offDied();
|
|
244
379
|
};
|
|
245
|
-
}, [ctx, instanceId, oneShots, graph]);
|
|
380
|
+
}, [ctx, instanceId, oneShots, graph, invalidate]);
|
|
246
381
|
useFrame((_state, delta) => {
|
|
382
|
+
// Checkpoint held frames too, so resuming never replays their accepted time.
|
|
383
|
+
const advancedSeconds = usesGameClock ? ctx.time.advancedSeconds?.() : undefined;
|
|
384
|
+
const previousSeconds = advancedSecondsRef.current;
|
|
385
|
+
advancedSecondsRef.current = advancedSeconds;
|
|
386
|
+
if (animationPausedRef.current || animation?.timeScale === 0)
|
|
387
|
+
return;
|
|
388
|
+
if (ctx !== null && instanceId !== undefined && (ctx.time.speed() === 0 || ctx.time.timescale() === 0))
|
|
389
|
+
return;
|
|
390
|
+
let playbackDelta = delta;
|
|
391
|
+
if (usesGameClock) {
|
|
392
|
+
if (advancedSeconds === undefined || previousSeconds === undefined || advancedSeconds <= previousSeconds)
|
|
393
|
+
return;
|
|
394
|
+
playbackDelta = advancedSeconds - previousSeconds;
|
|
395
|
+
}
|
|
247
396
|
const playback = graphRef.current;
|
|
248
397
|
if (playback !== null && mixerRef.current !== null) {
|
|
249
|
-
const params =
|
|
398
|
+
const params = playback.params;
|
|
250
399
|
if (ctx !== null && instanceId !== undefined) {
|
|
251
400
|
const entity = ctx.scene.entity.get(instanceId);
|
|
252
|
-
if (entity !== null &&
|
|
401
|
+
if (entity !== null && playbackDelta > 0) {
|
|
253
402
|
const [x, , z] = entity.position;
|
|
254
403
|
if (playback.lastPos !== null) {
|
|
255
|
-
const
|
|
256
|
-
|
|
404
|
+
const dx = x - playback.lastPos[0];
|
|
405
|
+
const dy = entity.position[1] - playback.lastPos[1];
|
|
406
|
+
const dz = z - playback.lastPos[2];
|
|
407
|
+
const snapDistance = ctx.sim.loop.config().snapDistance;
|
|
408
|
+
if (dx * dx + dy * dy + dz * dz > snapDistance * snapDistance) {
|
|
409
|
+
playback.smoothedSpeed = 0;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
const instantSpeed = Math.hypot(dx, dz) / playbackDelta;
|
|
413
|
+
playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, playbackDelta * 12);
|
|
414
|
+
}
|
|
257
415
|
}
|
|
258
416
|
playback.lastPos = [x, entity.position[1], z];
|
|
259
417
|
}
|
|
260
|
-
|
|
261
|
-
if (extra !== undefined)
|
|
262
|
-
Object.assign(params, extra);
|
|
418
|
+
readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
|
|
263
419
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const action = playback.actions.get(actionKey(entry.layer, entry.clip));
|
|
270
|
-
if (action === undefined)
|
|
271
|
-
continue;
|
|
272
|
-
action.weight += entry.weight;
|
|
273
|
-
action.time = entry.time;
|
|
420
|
+
else {
|
|
421
|
+
for (const key in params)
|
|
422
|
+
if (Object.hasOwn(params, key))
|
|
423
|
+
delete params[key];
|
|
424
|
+
params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
|
|
274
425
|
}
|
|
426
|
+
const out = playback.runtime.advance(playbackDelta * (animation?.timeScale ?? 1), params, playback.durations);
|
|
427
|
+
applyGraphClips(playback.actions, out.clips);
|
|
275
428
|
mixerRef.current.update(0);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
429
|
+
if (out.rootMotion === true && playback.rootBone !== null && playback.rootBindPosition !== null) {
|
|
430
|
+
playback.rootBone.position.x = playback.rootBindPosition.x;
|
|
431
|
+
playback.rootBone.position.z = playback.rootBindPosition.z;
|
|
279
432
|
}
|
|
280
433
|
if (ctx !== null && instanceId !== undefined) {
|
|
281
|
-
const rootDelta = out.rootDelta;
|
|
282
|
-
const entity = rootDelta === undefined ? null : ctx.scene.entity.get(instanceId);
|
|
283
|
-
if (entity !== null && rootDelta !== undefined) {
|
|
284
|
-
ctx.scene.entity.setPose(instanceId, {
|
|
285
|
-
position: [entity.position[0] + rootDelta[0], entity.position[1] + rootDelta[1], entity.position[2] + rootDelta[2]],
|
|
286
|
-
dt: delta,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
434
|
for (const event of out.events)
|
|
290
435
|
ctx.game.events.emit("animation.event", { instanceId, name: event.name, clip: event.clip });
|
|
291
436
|
}
|
|
292
437
|
return;
|
|
293
438
|
}
|
|
294
|
-
if (mixerRef.current !== null &&
|
|
295
|
-
mixerRef.current.update(
|
|
439
|
+
if (mixerRef.current !== null && actionRef.current?.isRunning()) {
|
|
440
|
+
mixerRef.current.update(playbackDelta);
|
|
441
|
+
}
|
|
296
442
|
});
|
|
297
443
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Group, type Object3D } from "three";
|
|
2
|
+
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
/** Model loading, placement, shadows and animation for a custom renderer. */
|
|
4
|
+
export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes">;
|
|
5
|
+
/** Caller-owned styling runs on the isolated clone, never the loader cache. */
|
|
6
|
+
export interface ModelInstanceOptions {
|
|
7
|
+
instanceId?: string;
|
|
8
|
+
/** Configure each new clone before placement measurement. Memoize this callback; changing it replaces the instance. Materials are isolated; geometry/textures stay borrowed. */
|
|
9
|
+
configure?: (content: Object3D) => void;
|
|
10
|
+
}
|
|
11
|
+
/** Render `scene` with `position` and uniform `scale`; animate or deform `content` inside that placement frame. */
|
|
12
|
+
export interface ModelInstance {
|
|
13
|
+
content: Object3D;
|
|
14
|
+
scene: Group;
|
|
15
|
+
scale: number;
|
|
16
|
+
position: [number, number, number];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Load and own an isolated model instance, preserving imported transforms and measuring placement at bind pose.
|
|
20
|
+
* Apply hiddenNodes to the configured clone before measurement; equivalent name lists retain the instance.
|
|
21
|
+
* Mount `scene` with the returned position/scale. Custom materials, deformation and surrounding geometry stay caller-owned.
|
|
22
|
+
* Must run inside an R3F canvas and Suspense boundary; a game provider is optional.
|
|
23
|
+
* @capability model-instance own loading, clone cleanup, placement and animation for a custom model renderer
|
|
24
|
+
*/
|
|
25
|
+
export declare function useModelInstance(model: ModelInstanceConfig, options?: ModelInstanceOptions): ModelInstance;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useLoader, useThree } from "@react-three/fiber";
|
|
2
|
+
import { useEffect, useMemo } from "react";
|
|
3
|
+
import { Group } from "three";
|
|
4
|
+
import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
|
|
5
|
+
import { measureLocalBounds } from "./measureBounds.js";
|
|
6
|
+
import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
|
|
7
|
+
import { cloneModelScene, disposeModelScene, modelPlacementTransform } from "./modelRender.js";
|
|
8
|
+
import { useModelAnimation } from "./useModelAnimation.js";
|
|
9
|
+
import { applyModelNodeVisibility, modelHiddenNodesKey } from "./modelNodeVisibility.js";
|
|
10
|
+
/**
|
|
11
|
+
* Load and own an isolated model instance, preserving imported transforms and measuring placement at bind pose.
|
|
12
|
+
* Apply hiddenNodes to the configured clone before measurement; equivalent name lists retain the instance.
|
|
13
|
+
* Mount `scene` with the returned position/scale. Custom materials, deformation and surrounding geometry stay caller-owned.
|
|
14
|
+
* Must run inside an R3F canvas and Suspense boundary; a game provider is optional.
|
|
15
|
+
* @capability model-instance own loading, clone cleanup, placement and animation for a custom model renderer
|
|
16
|
+
*/
|
|
17
|
+
export function useModelInstance(model, options = {}) {
|
|
18
|
+
const hiddenNodes = parseModelHiddenNodes(model.hiddenNodes);
|
|
19
|
+
const renderer = useThree(state => state.gl);
|
|
20
|
+
if (renderer.isWebGLRenderer)
|
|
21
|
+
detectKtx2Support(renderer);
|
|
22
|
+
const gltf = useLoader(sharedGltfLoader, model.url);
|
|
23
|
+
const { configure, instanceId } = options;
|
|
24
|
+
const hiddenNodesKey = modelHiddenNodesKey(hiddenNodes);
|
|
25
|
+
const { content, scene, bindBounds } = useMemo(() => {
|
|
26
|
+
const content = cloneModelScene(gltf.scene, { shadows: model.shadows });
|
|
27
|
+
try {
|
|
28
|
+
configure?.(content);
|
|
29
|
+
for (const diagnostic of applyModelNodeVisibility(content, hiddenNodes))
|
|
30
|
+
console.warn(`Model ${model.url} hiddenNodes: ${diagnostic.message}`);
|
|
31
|
+
const scene = new Group().add(content);
|
|
32
|
+
return { content, scene, bindBounds: measureLocalBounds(scene) };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
disposeModelScene(content);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}, [gltf.scene, model.shadows, configure, hiddenNodesKey]);
|
|
39
|
+
const placement = useMemo(() => modelPlacementTransform(scene, model, bindBounds), [scene, bindBounds, model.scale, model.targetHeight, model.y, model.anchor, model.dims]);
|
|
40
|
+
useEffect(() => () => disposeModelScene(content), [content]);
|
|
41
|
+
useModelAnimation(content, gltf.animations, model.animation, instanceId);
|
|
42
|
+
return { content, scene, ...placement };
|
|
43
|
+
}
|
|
@@ -5,6 +5,8 @@ export interface ScatterModelSource {
|
|
|
5
5
|
geometry: THREE.BufferGeometry;
|
|
6
6
|
material: THREE.Material | THREE.Material[];
|
|
7
7
|
localMatrix: THREE.Matrix4;
|
|
8
|
+
castShadow?: boolean;
|
|
9
|
+
receiveShadow?: boolean;
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Clones a loaded GLTF scene (so shared cache materials stay untouched), applies the model's material
|
|
@@ -18,8 +20,7 @@ export declare function buildScatterModelSources(gltfScene: THREE.Object3D, mode
|
|
|
18
20
|
root: THREE.Object3D;
|
|
19
21
|
};
|
|
20
22
|
/**
|
|
21
|
-
*
|
|
22
|
-
* (still owned by the loader cache).
|
|
23
|
+
* Releases the instance resources harvested by {@link buildScatterModelSources}, retaining cached assets.
|
|
23
24
|
* @internal
|
|
24
25
|
*/
|
|
25
26
|
export declare function disposeScatterModelSources(root: THREE.Object3D): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
|
+
import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
|
|
2
3
|
import { applyMaterialOverride } from "../materialOverride.js";
|
|
3
|
-
import { cloneModelScene,
|
|
4
|
+
import { cloneModelScene, disposeModelScene, ownModelMaterial } from "../render/modelRender.js";
|
|
4
5
|
/** The scale + position offset a `ModelConfig` applies before any per-instance transform — same anchor/targetHeight rules as the single-instance entity renderer. */
|
|
5
6
|
function scatterModelBaseTransform(scene, model) {
|
|
6
7
|
let scale = model.scale ?? 1;
|
|
@@ -33,13 +34,19 @@ function scatterModelBaseTransform(scene, model) {
|
|
|
33
34
|
* @internal — the GLB-harvesting behind `InstancedScatter`'s `resolveItem` override.
|
|
34
35
|
*/
|
|
35
36
|
export function buildScatterModelSources(gltfScene, model) {
|
|
36
|
-
|
|
37
|
+
if (parseModelHiddenNodes(model.hiddenNodes)?.length)
|
|
38
|
+
throw new Error("Instanced model node visibility requires the individual model renderer; use EntityModel for hidden nodes");
|
|
39
|
+
if (model.materialAssignments?.length)
|
|
40
|
+
throw new Error("Instanced model material assignments require the individual model renderer; use EntityModel for authored slots");
|
|
41
|
+
if (model.material?.maps !== undefined)
|
|
42
|
+
throw new Error("Instanced model override maps require an owned texture loader; use EntityModel for authored maps");
|
|
43
|
+
const root = cloneModelScene(gltfScene, { shadows: model.shadows });
|
|
37
44
|
if (model.material !== undefined)
|
|
38
|
-
applyMaterialOverride(root, model.material, { clone: false });
|
|
45
|
+
applyMaterialOverride(root, model.material, { clone: false, ownMaterial: material => ownModelMaterial(root, material) });
|
|
39
46
|
root.updateMatrixWorld(true);
|
|
40
47
|
const base = scatterModelBaseTransform(root, model);
|
|
41
48
|
const sources = [];
|
|
42
|
-
root.
|
|
49
|
+
root.traverseVisible((node) => {
|
|
43
50
|
const mesh = node;
|
|
44
51
|
if (!mesh.isMesh)
|
|
45
52
|
return;
|
|
@@ -47,15 +54,16 @@ export function buildScatterModelSources(gltfScene, model) {
|
|
|
47
54
|
geometry: mesh.geometry,
|
|
48
55
|
material: mesh.material,
|
|
49
56
|
localMatrix: base.clone().multiply(mesh.matrixWorld),
|
|
57
|
+
castShadow: mesh.castShadow,
|
|
58
|
+
receiveShadow: mesh.receiveShadow,
|
|
50
59
|
});
|
|
51
60
|
});
|
|
52
61
|
return { sources, root };
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
55
|
-
*
|
|
56
|
-
* (still owned by the loader cache).
|
|
64
|
+
* Releases the instance resources harvested by {@link buildScatterModelSources}, retaining cached assets.
|
|
57
65
|
* @internal
|
|
58
66
|
*/
|
|
59
67
|
export function disposeScatterModelSources(root) {
|
|
60
|
-
|
|
68
|
+
disposeModelScene(root);
|
|
61
69
|
}
|