@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.
Files changed (261) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +132 -17
  5. package/dist/Shell3dPresentation.d.ts +5 -11
  6. package/dist/Shell3dPresentation.js +73 -11
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +20 -2
  10. package/dist/ShellHudPresentation.js +41 -6
  11. package/dist/audio/AudioComponents.js +6 -2
  12. package/dist/audio/audioEngine.d.ts +24 -0
  13. package/dist/audio/audioEngine.js +231 -83
  14. package/dist/audio/audioWire.d.ts +3 -0
  15. package/dist/audio/audioWire.js +9 -3
  16. package/dist/audio/loopParams.d.ts +39 -0
  17. package/dist/audio/loopParams.js +43 -0
  18. package/dist/audio/synthEngine.d.ts +7 -1
  19. package/dist/audio/synthEngine.js +103 -38
  20. package/dist/camera/GameCameraRig.js +3 -2
  21. package/dist/camera/GameFirstPersonCamera.d.ts +6 -4
  22. package/dist/camera/GameFirstPersonCamera.js +68 -27
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +76 -62
  26. package/dist/camera/chaseHeading.d.ts +9 -0
  27. package/dist/camera/chaseHeading.js +10 -0
  28. package/dist/camera/customMuzzle.d.ts +14 -0
  29. package/dist/camera/customMuzzle.js +68 -0
  30. package/dist/camera/index.d.ts +1 -0
  31. package/dist/camera/index.js +1 -0
  32. package/dist/camera/orbitCameraMath.js +2 -2
  33. package/dist/camera/rigMath.d.ts +82 -4
  34. package/dist/camera/rigMath.js +133 -15
  35. package/dist/camera/rigResolve.d.ts +3 -0
  36. package/dist/camera/rigResolve.js +11 -0
  37. package/dist/combat/ProjectileModels.d.ts +31 -0
  38. package/dist/combat/ProjectileModels.js +70 -0
  39. package/dist/commandSink.d.ts +1 -2
  40. package/dist/commandSink.js +3 -2
  41. package/dist/defineGame.d.ts +19 -3
  42. package/dist/defineGame.js +39 -7
  43. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  44. package/dist/devtools/DevtoolsOverlay.js +15 -167
  45. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  46. package/dist/devtools/DevtoolsRuntime.js +61 -0
  47. package/dist/devtools/agentBridge.d.ts +6 -0
  48. package/dist/devtools/agentBridge.js +16 -2
  49. package/dist/devtools/devtoolsReports.d.ts +68 -0
  50. package/dist/devtools/devtoolsReports.js +100 -0
  51. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  52. package/dist/devtools/latencyInstrumentation.js +11 -0
  53. package/dist/devtools/playtestProbe.d.ts +4 -0
  54. package/dist/devtools/playtestProbe.js +22 -0
  55. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  56. package/dist/diagnostics/FpsCounter.js +32 -0
  57. package/dist/drivers/FrameDriver.js +35 -20
  58. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  59. package/dist/drivers/FrameRateLimiter.js +39 -0
  60. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  61. package/dist/drivers/HudOnlyDriver.js +32 -14
  62. package/dist/environment/DayNightSky.js +5 -5
  63. package/dist/environment/Daylight.d.ts +35 -1
  64. package/dist/environment/Daylight.js +98 -28
  65. package/dist/environment/EnvironmentScene.js +11 -10
  66. package/dist/environment/VolumetricClouds.js +20 -2
  67. package/dist/environment/daylightCycle.d.ts +20 -0
  68. package/dist/environment/daylightCycle.js +59 -7
  69. package/dist/environment/sunShadowMath.d.ts +45 -0
  70. package/dist/environment/sunShadowMath.js +50 -0
  71. package/dist/input/gamepadPoll.d.ts +47 -0
  72. package/dist/input/gamepadPoll.js +142 -0
  73. package/dist/input/gamepadSource.d.ts +11 -1
  74. package/dist/input/gamepadSource.js +117 -71
  75. package/dist/input/mouseLook.js +2 -1
  76. package/dist/input/padHaptics.d.ts +17 -0
  77. package/dist/input/padHaptics.js +32 -0
  78. package/dist/input/pointerLock.d.ts +11 -0
  79. package/dist/input/pointerLock.js +19 -0
  80. package/dist/inputSink.d.ts +6 -6
  81. package/dist/inputSink.js +60 -21
  82. package/dist/lazyShell3dPresentation.d.ts +9 -0
  83. package/dist/lazyShell3dPresentation.js +24 -0
  84. package/dist/materialOverride.d.ts +42 -0
  85. package/dist/materialOverride.js +95 -36
  86. package/dist/multiplayer.d.ts +33 -3
  87. package/dist/multiplayer.js +105 -22
  88. package/dist/pointer/pointerService.d.ts +2 -0
  89. package/dist/pointer/pointerService.js +12 -2
  90. package/dist/postfx/PostProcessing.d.ts +11 -1
  91. package/dist/postfx/PostProcessing.js +14 -42
  92. package/dist/postfx/gradeShader.d.ts +1 -1
  93. package/dist/postfx/gradeShader.js +30 -2
  94. package/dist/postfx/shadowPrepass.d.ts +3 -0
  95. package/dist/postfx/shadowPrepass.js +18 -0
  96. package/dist/postfx/stylizePass.d.ts +23 -0
  97. package/dist/postfx/stylizePass.js +216 -0
  98. package/dist/presentationRecovery.d.ts +75 -0
  99. package/dist/presentationRecovery.js +204 -0
  100. package/dist/presentationRecovery3d.d.ts +4 -0
  101. package/dist/presentationRecovery3d.js +32 -0
  102. package/dist/presentationResolve.d.ts +8 -1
  103. package/dist/presentationResolve.js +5 -2
  104. package/dist/render/CascadedShadows.d.ts +7 -2
  105. package/dist/render/CascadedShadows.js +98 -20
  106. package/dist/render/EnvironmentLighting.js +74 -35
  107. package/dist/render/HairCards.d.ts +7 -0
  108. package/dist/render/HairCards.js +23 -0
  109. package/dist/render/SceneModels.js +52 -78
  110. package/dist/render/SkinnedInstances.d.ts +91 -0
  111. package/dist/render/SkinnedInstances.js +249 -0
  112. package/dist/render/TextureFiltering.d.ts +16 -0
  113. package/dist/render/TextureFiltering.js +64 -0
  114. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  115. package/dist/render/authoredSurfaceMaterial.js +108 -0
  116. package/dist/render/displayColor.d.ts +33 -0
  117. package/dist/render/displayColor.js +138 -0
  118. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  119. package/dist/render/materialAppearanceSignals.js +58 -0
  120. package/dist/render/materialAsset.d.ts +98 -0
  121. package/dist/render/materialAsset.js +454 -0
  122. package/dist/render/measureBounds.js +16 -3
  123. package/dist/render/measureCollisionMesh.js +8 -1
  124. package/dist/render/modelAssets.d.ts +8 -0
  125. package/dist/render/modelAssets.js +43 -0
  126. package/dist/render/modelBindPose.d.ts +5 -0
  127. package/dist/render/modelBindPose.js +35 -0
  128. package/dist/render/modelLoad.d.ts +32 -0
  129. package/dist/render/modelLoad.js +70 -1
  130. package/dist/render/modelNodeVisibility.d.ts +6 -0
  131. package/dist/render/modelNodeVisibility.js +18 -0
  132. package/dist/render/modelRender.d.ts +18 -1
  133. package/dist/render/modelRender.js +81 -3
  134. package/dist/render/projectedOverdraw.d.ts +35 -0
  135. package/dist/render/projectedOverdraw.js +204 -0
  136. package/dist/render/resolveModel.d.ts +12 -2
  137. package/dist/render/resolveModel.js +25 -4
  138. package/dist/render/rigNode.d.ts +26 -0
  139. package/dist/render/rigNode.js +32 -0
  140. package/dist/render/spriteRender.d.ts +4 -0
  141. package/dist/render/spriteRender.js +5 -0
  142. package/dist/render/staticShapeInstances.d.ts +34 -0
  143. package/dist/render/staticShapeInstances.js +78 -0
  144. package/dist/render/useFootIk.d.ts +53 -4
  145. package/dist/render/useFootIk.js +262 -70
  146. package/dist/render/useModelAnimation.d.ts +50 -0
  147. package/dist/render/useModelAnimation.js +245 -99
  148. package/dist/render/useModelInstance.d.ts +25 -0
  149. package/dist/render/useModelInstance.js +43 -0
  150. package/dist/scatter/scatterModels.d.ts +3 -2
  151. package/dist/scatter/scatterModels.js +15 -7
  152. package/dist/scene/AuthoredScene.d.ts +23 -6
  153. package/dist/scene/AuthoredScene.js +46 -9
  154. package/dist/scene/CityRenderer.js +6 -6
  155. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  156. package/dist/settings/QuickControls.js +1 -1
  157. package/dist/settings/SettingsMenu.js +11 -18
  158. package/dist/settings/SettingsRuntime.d.ts +5 -0
  159. package/dist/settings/SettingsRuntime.js +29 -2
  160. package/dist/settings/appliedSettings.d.ts +9 -3
  161. package/dist/settings/appliedSettings.js +6 -5
  162. package/dist/settings/settingsController.d.ts +3 -0
  163. package/dist/settings/settingsController.js +47 -3
  164. package/dist/shellControlSuspension.d.ts +18 -0
  165. package/dist/shellControlSuspension.js +28 -0
  166. package/dist/shellInputPublication.d.ts +28 -0
  167. package/dist/shellInputPublication.js +49 -0
  168. package/dist/shellPointerInput.d.ts +18 -0
  169. package/dist/shellPointerInput.js +49 -0
  170. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  171. package/dist/structures/BuildingKitBatch.js +35 -56
  172. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  173. package/dist/structures/BuildingRenderBudget.js +54 -0
  174. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  175. package/dist/structures/GeneratedBuilding.js +33 -22
  176. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  177. package/dist/structures/buildingKitMaterials.js +70 -0
  178. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  179. package/dist/structures/buildingSpatialBatch.js +45 -0
  180. package/dist/structures/buildingSurface.d.ts +21 -0
  181. package/dist/structures/buildingSurface.js +134 -0
  182. package/dist/terrain/GrassField.d.ts +2 -1
  183. package/dist/terrain/GrassField.js +59 -5
  184. package/dist/terrain/grassGeometry.d.ts +10 -0
  185. package/dist/terrain/grassGeometry.js +55 -0
  186. package/dist/terrain/grassMaterial.js +2 -2
  187. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  188. package/dist/terrain/terrainDetailMaterial.js +7 -7
  189. package/dist/touch/TouchControlsOverlay.js +4 -4
  190. package/dist/useShellMultiplayerSync.d.ts +1 -1
  191. package/dist/useShellMultiplayerSync.js +9 -3
  192. package/dist/vfx/ParticleField.d.ts +23 -20
  193. package/dist/vfx/ParticleField.js +168 -50
  194. package/dist/visibility/CullingProvider.js +2 -62
  195. package/dist/visibility/cullingDriver.d.ts +9 -0
  196. package/dist/visibility/cullingDriver.js +68 -0
  197. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  198. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  199. package/dist/weather/DustField.d.ts +2 -1
  200. package/dist/weather/DustField.js +17 -3
  201. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  202. package/dist/weather/FireSpreadLayer.js +5 -11
  203. package/dist/weather/LightningStrike.d.ts +4 -1
  204. package/dist/weather/LightningStrike.js +13 -17
  205. package/dist/weather/RainField.d.ts +2 -1
  206. package/dist/weather/RainField.js +17 -3
  207. package/dist/weather/RainImpactField.d.ts +16 -0
  208. package/dist/weather/RainImpactField.js +89 -0
  209. package/dist/weather/SnowField.d.ts +2 -1
  210. package/dist/weather/SnowField.js +17 -3
  211. package/dist/weather/WeatherLayer.d.ts +11 -2
  212. package/dist/weather/WeatherLayer.js +30 -6
  213. package/dist/weather/index.d.ts +2 -0
  214. package/dist/weather/index.js +2 -0
  215. package/dist/weather/weatherMath.d.ts +2 -0
  216. package/dist/weather/weatherMath.js +8 -0
  217. package/dist/weather/weatherShelter.d.ts +18 -0
  218. package/dist/weather/weatherShelter.js +75 -0
  219. package/dist/weather/weatherUniforms.d.ts +40 -1
  220. package/dist/weather/weatherUniforms.js +29 -9
  221. package/dist/world/AuthoredFlocks.d.ts +22 -0
  222. package/dist/world/AuthoredFlocks.js +87 -0
  223. package/dist/world/StaticObjectModels.d.ts +8 -0
  224. package/dist/world/StaticObjectModels.js +75 -0
  225. package/dist/world/WorldHud.d.ts +14 -6
  226. package/dist/world/WorldHud.js +25 -12
  227. package/dist/world/WorldParticles.d.ts +10 -23
  228. package/dist/world/WorldParticles.js +210 -62
  229. package/dist/world/WorldScene.d.ts +3 -1
  230. package/dist/world/WorldScene.js +41 -26
  231. package/dist/world/staticObjectBatches.d.ts +35 -0
  232. package/dist/world/staticObjectBatches.js +163 -0
  233. package/dist/world/worldBarSamples.d.ts +37 -33
  234. package/dist/world/worldBarSamples.js +117 -75
  235. package/package.json +4 -4
  236. package/skills/ce-handoff/SKILL.md +2 -2
  237. package/skills/game-design/SKILL.md +2 -2
  238. package/skills/jgengine/SKILL.md +28 -6
  239. package/skills/jgengine/api.md +139 -99
  240. package/skills/jgengine/capabilities.md +28 -0
  241. package/skills/jgengine/recipes/minimal-game.md +10 -2
  242. package/skills/jgengine-ui/SKILL.md +16 -17
  243. package/skills/jgengine-ui/api.md +603 -365
  244. package/skills/jgengine-ui/capabilities.md +131 -1
  245. package/skills/jgengine-ui/reference-primitives.md +179 -0
  246. package/skills/jgengine-ui/reference.md +4 -81
  247. package/skills/jgengine-world/SKILL.md +25 -4
  248. package/skills/jgengine-world/api.md +1045 -655
  249. package/skills/jgengine-world/capabilities.md +246 -9
  250. package/skills/jgengine-world/recipes/character-animation.md +62 -0
  251. package/skills/jgengine-world/recipes/controls.md +48 -0
  252. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  253. package/skills/jgengine-world/recipes/npc-senses.md +55 -0
  254. package/skills/jgengine-world/recipes/vehicle-feel.md +87 -0
  255. package/skills/jgengine-world/reference.md +180 -4
  256. package/skills/jgengine-world/visibility.md +139 -0
  257. package/skills/level-design/SKILL.md +1 -1
  258. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  259. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  260. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  261. 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, locomotionGraph } from "@jgengine/core/anim/locomotionGraph";
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
- * An explicit `animation` config always wins over `"auto"`, and both the state machine and the
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
- * Warn once per mount with the names that missed and the names the rig actually has, so the fix
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 warnMissingClips(animation, clips) {
32
- if (typeof console === "undefined")
33
- return;
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 { states, oneShots, graph } = animation;
37
- if (states !== undefined) {
38
- for (const name of [states.idle, states.walk, states.run]) {
39
- if (name !== undefined && !available.has(name))
40
- missing.add(name);
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 (oneShots !== undefined) {
44
- for (const spec of Object.values(oneShots)) {
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 === 0)
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
- console.warn(`[jgengine] model animation: clip(s) ${[...missing].map((name) => `"${name}"`).join(", ")} ` +
61
- `not found on this rig. Available: ${clips.map((clip) => clip.name).join(", ")}. ` +
62
- `States fall back to the first clip and one-shots are skipped — set animation: "auto" to ` +
63
- `derive states/one-shots from the rig's own clip names.`);
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
- function buildGraphPlayback(scene, mixer, graph, clips) {
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
- let clip = source;
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
- const animation = useMemo(() => resolveAnimationConfig(animationInput, clips.map((clip) => clip.name)), [animationInput, clips]);
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 graph = useMemo(() => {
151
- if (animation?.graph !== undefined)
152
- return animation.graph;
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
- graphRef.current = buildGraphPlayback(scene, mixer, graph, clips);
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 = false;
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 = (animation.clip !== undefined ? THREE.AnimationClip.findByName(clips, animation.clip) : undefined) ??
191
- clips[0];
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 && delta > 0) {
401
+ if (entity !== null && playbackDelta > 0) {
253
402
  const [x, , z] = entity.position;
254
403
  if (playback.lastPos !== null) {
255
- const instantSpeed = Math.hypot(x - playback.lastPos[0], z - playback.lastPos[2]) / delta;
256
- playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, delta * 12);
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
- const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
261
- if (extra !== undefined)
262
- Object.assign(params, extra);
418
+ readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
263
419
  }
264
- params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
265
- const out = playback.runtime.advance(delta * (animation?.timeScale ?? 1), params, playback.durations);
266
- for (const action of playback.actions.values())
267
- action.weight = 0;
268
- for (const entry of out.clips) {
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
- const currentPlayback = graphRef.current;
277
- if (currentPlayback !== null && currentPlayback.rootBone !== null && currentPlayback.rootBindPosition !== null) {
278
- currentPlayback.rootBone.position.copy(currentPlayback.rootBindPosition);
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 && !animationPausedRef.current)
295
- mixerRef.current.update(delta);
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
- * Disposes the cloned materials harvested by {@link buildScatterModelSources}; never disposes geometry
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, disposeClonedMaterials } from "../render/modelRender.js";
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
- const root = cloneModelScene(gltfScene);
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.traverse((node) => {
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
- * Disposes the cloned materials harvested by {@link buildScatterModelSources}; never disposes geometry
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
- disposeClonedMaterials(root);
68
+ disposeModelScene(root);
61
69
  }