@jgengine/shell 0.18.0 → 0.19.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 (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -0,0 +1,64 @@
1
+ import { useThree } from "@react-three/fiber";
2
+ import { useLayoutEffect } from "react";
3
+ import * as THREE from "three";
4
+ const TEXTURE_SLOTS = [
5
+ "map",
6
+ "normalMap",
7
+ "roughnessMap",
8
+ "metalnessMap",
9
+ "aoMap",
10
+ "emissiveMap",
11
+ "bumpMap",
12
+ "alphaMap",
13
+ "displacementMap",
14
+ ];
15
+ /**
16
+ * Retune already-loaded textures that still carry the old default anisotropy. Textures a
17
+ * system set on purpose (pixel-art sprites pin 1) keep their value.
18
+ * @internal
19
+ */
20
+ export function retuneTextureAnisotropy(root, from, to) {
21
+ const seen = new Set();
22
+ root.traverse((node) => {
23
+ const mesh = node;
24
+ if (mesh.material === undefined)
25
+ return;
26
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
27
+ for (const material of materials) {
28
+ const slots = material;
29
+ for (const slot of TEXTURE_SLOTS) {
30
+ const texture = slots[slot];
31
+ if (texture === null || texture === undefined || seen.has(texture))
32
+ continue;
33
+ seen.add(texture);
34
+ if (texture.anisotropy !== from)
35
+ continue;
36
+ texture.anisotropy = to;
37
+ texture.needsUpdate = true;
38
+ }
39
+ }
40
+ });
41
+ return seen.size;
42
+ }
43
+ /**
44
+ * Applies the player's texture-filtering choice. Sets three's construction-time default so
45
+ * every texture loaded afterwards (GLTF, texture loaders) samples anisotropically, and walks the
46
+ * scene once per change for textures loaded before. No per-frame work.
47
+ * @internal shell-internal; driven by `GraphicsProfile.anisotropy`.
48
+ */
49
+ export function TextureFiltering({ anisotropy }) {
50
+ const gl = useThree((state) => state.gl);
51
+ const scene = useThree((state) => state.scene);
52
+ useLayoutEffect(() => {
53
+ const target = Math.max(1, Math.min(anisotropy, gl.capabilities.getMaxAnisotropy()));
54
+ const previous = THREE.Texture.DEFAULT_ANISOTROPY;
55
+ if (previous === target)
56
+ return;
57
+ THREE.Texture.DEFAULT_ANISOTROPY = target;
58
+ retuneTextureAnisotropy(scene, previous, target);
59
+ return () => {
60
+ THREE.Texture.DEFAULT_ANISOTROPY = previous;
61
+ };
62
+ }, [gl, scene, anisotropy]);
63
+ return null;
64
+ }
@@ -0,0 +1,33 @@
1
+ import { type Color, type ToneMapping } from "three";
2
+ type Rgb = [number, number, number];
3
+ /**
4
+ * three.js r182's tone-mapping curve for `mode`, applied to a linear scene color at `exposure`.
5
+ * Mirrors `tonemapping_pars_fragment`; `NoToneMapping` and custom curves pass through.
6
+ * @internal
7
+ */
8
+ export declare function toneMapColor(color: readonly [number, number, number], mode: ToneMapping, exposure?: number): Rgb;
9
+ /**
10
+ * The linear scene color that `mode` at `exposure` displays as `display` (linear sRGB, 0..1).
11
+ * Sky and fog colors are picked as on-screen swatches, but the output pass tone-maps them like
12
+ * lit surfaces, which turns a pale horizon grey. Solved by fixed-point iteration because AgX and
13
+ * ACES mix channels; a swatch a curve cannot reach (pure white, a saturated highlight under AgX)
14
+ * lands on the closest color it can show.
15
+ * @internal
16
+ */
17
+ export declare function sceneColorForDisplay(display: readonly [number, number, number], mode: ToneMapping, exposure?: number): Rgb;
18
+ /**
19
+ * Writes the scene color that displays as `display` into `target`, recomputing only when the
20
+ * swatch or the renderer's tone mapping changes, so per-frame sky drivers stay allocation-free.
21
+ * @internal
22
+ */
23
+ export declare class DisplayColorWriter {
24
+ private hex;
25
+ private mode;
26
+ private exposure;
27
+ private scene;
28
+ write(target: Color, display: Color, renderer: {
29
+ toneMapping: ToneMapping;
30
+ toneMappingExposure: number;
31
+ }): void;
32
+ }
33
+ export {};
@@ -0,0 +1,138 @@
1
+ import { ACESFilmicToneMapping, AgXToneMapping, CineonToneMapping, LinearToneMapping, NeutralToneMapping, ReinhardToneMapping, } from "three";
2
+ const saturate = (x) => Math.min(1, Math.max(0, x));
3
+ /** GLSL `mat3(c0, c1, c2) * v` with the columns three's tonemapping chunk declares. */
4
+ function mat3(c, v) {
5
+ return [
6
+ c[0][0] * v[0] + c[1][0] * v[1] + c[2][0] * v[2],
7
+ c[0][1] * v[0] + c[1][1] * v[1] + c[2][1] * v[2],
8
+ c[0][2] * v[0] + c[1][2] * v[1] + c[2][2] * v[2],
9
+ ];
10
+ }
11
+ const ACES_IN = [[0.59719, 0.076, 0.0284], [0.35458, 0.90834, 0.13383], [0.04823, 0.01566, 0.83777]];
12
+ const ACES_OUT = [[1.60475, -0.10208, -0.00327], [-0.53108, 1.10813, -0.07276], [-0.07367, -0.00605, 1.07602]];
13
+ const REC2020_TO_SRGB = [[1.6605, -0.1246, -0.0182], [-0.5876, 1.1329, -0.1006], [-0.0728, -0.0083, 1.1187]];
14
+ const SRGB_TO_REC2020 = [[0.6274, 0.0691, 0.0164], [0.3293, 0.9195, 0.088], [0.0433, 0.0113, 0.8956]];
15
+ const AGX_INSET = [
16
+ [0.856627153315983, 0.137318972929847, 0.11189821299995],
17
+ [0.0951212405381588, 0.761241990602591, 0.0767994186031903],
18
+ [0.0482516061458583, 0.101439036467562, 0.811302368396859],
19
+ ];
20
+ const AGX_OUTSET = [
21
+ [1.1271005818144368, -0.1413297634984383, -0.14132976349843826],
22
+ [-0.11060664309660323, 1.157823702216272, -0.11060664309660294],
23
+ [-0.016493938717834573, -0.016493938717834257, 1.2519364065950405],
24
+ ];
25
+ const AGX_MIN_EV = -12.47393;
26
+ const AGX_MAX_EV = 4.026069;
27
+ function aces(c) {
28
+ const v = mat3(ACES_IN, [c[0] / 0.6, c[1] / 0.6, c[2] / 0.6]);
29
+ const fit = v.map((x) => (x * (x + 0.0245786) - 0.000090537) / (x * (0.983729 * x + 0.432951) + 0.238081));
30
+ return mat3(ACES_OUT, fit).map(saturate);
31
+ }
32
+ function agx(c) {
33
+ const inset = mat3(AGX_INSET, mat3(SRGB_TO_REC2020, c));
34
+ const curved = inset.map((x) => {
35
+ const t = saturate((Math.log2(Math.max(x, 1e-10)) - AGX_MIN_EV) / (AGX_MAX_EV - AGX_MIN_EV));
36
+ const t2 = t * t;
37
+ const t4 = t2 * t2;
38
+ return 15.5 * t4 * t2 - 40.14 * t4 * t + 31.96 * t4 - 6.868 * t2 * t + 0.4298 * t2 + 0.1191 * t - 0.00232;
39
+ });
40
+ const out = mat3(AGX_OUTSET, curved).map((x) => Math.pow(Math.max(0, x), 2.2));
41
+ return mat3(REC2020_TO_SRGB, out).map(saturate);
42
+ }
43
+ function neutral(input) {
44
+ const x = Math.min(input[0], input[1], input[2]);
45
+ const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
46
+ const c = input.map((v) => v - offset);
47
+ const peak = Math.max(c[0], c[1], c[2]);
48
+ const start = 0.76;
49
+ if (peak < start)
50
+ return c;
51
+ const d = 1 - start;
52
+ const newPeak = 1 - (d * d) / (peak + d - start);
53
+ const g = 1 - 1 / (0.15 * (peak - newPeak) + 1);
54
+ return c.map((v) => v * (newPeak / peak) * (1 - g) + newPeak * g);
55
+ }
56
+ /**
57
+ * three.js r182's tone-mapping curve for `mode`, applied to a linear scene color at `exposure`.
58
+ * Mirrors `tonemapping_pars_fragment`; `NoToneMapping` and custom curves pass through.
59
+ * @internal
60
+ */
61
+ export function toneMapColor(color, mode, exposure = 1) {
62
+ const c = [color[0] * exposure, color[1] * exposure, color[2] * exposure];
63
+ switch (mode) {
64
+ case LinearToneMapping:
65
+ return c.map(saturate);
66
+ case ReinhardToneMapping:
67
+ return c.map((x) => saturate(x / (1 + x)));
68
+ case CineonToneMapping:
69
+ return c.map((v) => {
70
+ const x = Math.max(0, v - 0.004);
71
+ return Math.pow((x * (6.2 * x + 0.5)) / (x * (6.2 * x + 1.7) + 0.06), 2.2);
72
+ });
73
+ case ACESFilmicToneMapping:
74
+ return aces(c);
75
+ case AgXToneMapping:
76
+ return agx(c);
77
+ case NeutralToneMapping:
78
+ return neutral(c);
79
+ default:
80
+ return c;
81
+ }
82
+ }
83
+ const CURVED = new Set([ReinhardToneMapping, CineonToneMapping, ACESFilmicToneMapping, AgXToneMapping, NeutralToneMapping]);
84
+ /** Keeps a compensated sky inside half-float range; the brightest pastel under AgX needs ~13. */
85
+ const MAX_SCENE_VALUE = 32;
86
+ /**
87
+ * The linear scene color that `mode` at `exposure` displays as `display` (linear sRGB, 0..1).
88
+ * Sky and fog colors are picked as on-screen swatches, but the output pass tone-maps them like
89
+ * lit surfaces, which turns a pale horizon grey. Solved by fixed-point iteration because AgX and
90
+ * ACES mix channels; a swatch a curve cannot reach (pure white, a saturated highlight under AgX)
91
+ * lands on the closest color it can show.
92
+ * @internal
93
+ */
94
+ export function sceneColorForDisplay(display, mode, exposure = 1) {
95
+ const target = [saturate(display[0]), saturate(display[1]), saturate(display[2])];
96
+ if (mode === LinearToneMapping)
97
+ return target.map((v) => v / Math.max(exposure, 1e-6));
98
+ if (!CURVED.has(mode))
99
+ return target;
100
+ const scene = [...target];
101
+ let best = [...target];
102
+ let bestError = Number.POSITIVE_INFINITY;
103
+ for (let iteration = 0; iteration < 48; iteration += 1) {
104
+ const shown = toneMapColor(scene, mode, exposure);
105
+ const error = Math.abs(shown[0] - target[0]) + Math.abs(shown[1] - target[1]) + Math.abs(shown[2] - target[2]);
106
+ if (error < bestError) {
107
+ bestError = error;
108
+ best = [scene[0], scene[1], scene[2]];
109
+ }
110
+ for (let channel = 0; channel < 3; channel += 1) {
111
+ // Clamped so a channel a curve's toe or shoulder cannot reach does not blow up its neighbors.
112
+ const ratio = Math.min(2, Math.max(0.5, Math.max(target[channel], 1e-5) / Math.max(shown[channel], 1e-5)));
113
+ scene[channel] = Math.min(MAX_SCENE_VALUE, Math.max(1e-5, scene[channel] * Math.pow(ratio, 0.8)));
114
+ }
115
+ }
116
+ return best;
117
+ }
118
+ /**
119
+ * Writes the scene color that displays as `display` into `target`, recomputing only when the
120
+ * swatch or the renderer's tone mapping changes, so per-frame sky drivers stay allocation-free.
121
+ * @internal
122
+ */
123
+ export class DisplayColorWriter {
124
+ hex = -1;
125
+ mode = -1;
126
+ exposure = Number.NaN;
127
+ scene = [0, 0, 0];
128
+ write(target, display, renderer) {
129
+ const hex = display.getHex();
130
+ if (hex !== this.hex || renderer.toneMapping !== this.mode || renderer.toneMappingExposure !== this.exposure) {
131
+ this.hex = hex;
132
+ this.mode = renderer.toneMapping;
133
+ this.exposure = renderer.toneMappingExposure;
134
+ this.scene = sceneColorForDisplay([display.r, display.g, display.b], this.mode, this.exposure);
135
+ }
136
+ target.setRGB(this.scene[0], this.scene[1], this.scene[2]);
137
+ }
138
+ }
@@ -1,3 +1,4 @@
1
+ import * as THREE from "three";
1
2
  import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
2
3
  import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
3
4
  /**
@@ -60,6 +61,35 @@ export declare function modelLoadFallbacks(): readonly {
60
61
  }[];
61
62
  /** Drops the recorded placeholder list. For tests and between capture runs. @internal */
62
63
  export declare function clearModelLoadFallbacks(): void;
64
+ /** Per-model triangle ceiling before the loader warns. A prop is a few thousand triangles; a hero rig tens of thousands. */
65
+ export declare const DEFAULT_MODEL_TRIANGLE_BUDGET = 100000;
66
+ /** Retune the per-model triangle budget (a cinematic game may raise it; a mobile target lowers it). Non-finite or non-positive values restore the default. */
67
+ export declare function setModelTriangleBudget(triangles: number): void;
68
+ /** Triangles a loaded model contributes per draw, summing every mesh (instanced meshes count once per instance). @internal */
69
+ export declare function countSceneTriangles(root: THREE.Object3D): number;
70
+ /** One over-budget model as {@link heavyModels} reports it: the URL the loader fetched, its triangle count, and the budget it exceeded. */
71
+ export interface HeavyModelRecord {
72
+ url: string;
73
+ triangles: number;
74
+ budget: number;
75
+ }
76
+ /**
77
+ * Record a loaded model's triangle count against the budget: over budget warns once per URL and lands in
78
+ * {@link heavyModels}; under budget is silent. Split out so the wiring is unit-testable without a GL context.
79
+ * @internal
80
+ */
81
+ export declare function recordModelTriangles(url: string, triangles: number, overrides?: {
82
+ warn?: (record: HeavyModelRecord) => void;
83
+ }): void;
84
+ /**
85
+ * Every model the shared loader resolved whose triangle count exceeds the budget, in load order. A heavy
86
+ * prop is invisible in a screenshot and only shows up as a slow frame, so the loader measures each GLB as it
87
+ * lands and this exposes the verdict for `debug_snapshot` probes and smoke tests to assert against.
88
+ * @capability heavy-models models over the per-model triangle budget, for perf probes and smoke assertions
89
+ */
90
+ export declare function heavyModels(): readonly HeavyModelRecord[];
91
+ /** Drops the heavy-model list and the once-per-URL warning memory. For tests and between capture runs. @internal */
92
+ export declare function clearHeavyModels(): void;
63
93
  /**
64
94
  * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
65
95
  * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
@@ -72,6 +102,15 @@ export declare function clearModelLoadFallbacks(): void;
72
102
  export declare class DiagnosticGLTFLoader extends GLTFLoader {
73
103
  load(url: string, onLoad: (gltf: GLTF) => void, onProgress?: (event: ProgressEvent) => void, onError?: (event: unknown) => void): void;
74
104
  }
105
+ /** CDN-backed decoder configuration for compressed model and texture assets. */
106
+ export interface ModelLoaderConfig {
107
+ dracoDecoderPath?: string;
108
+ ktx2TranscoderPath?: string;
109
+ }
110
+ /** Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. */
111
+ export declare function configureModelLoaders(options?: ModelLoaderConfig): Readonly<Required<ModelLoaderConfig>>;
112
+ /** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
113
+ export declare function detectKtx2Support(renderer: THREE.WebGLRenderer): void;
75
114
  /**
76
115
  * How long the shared GLB loader has been idle, in ms — `0` while any model is still
77
116
  * in flight. A capture host reads this to wait for streaming to finish instead of
@@ -1,5 +1,7 @@
1
1
  import * as THREE from "three";
2
2
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
3
+ import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
4
+ import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
3
5
  import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
4
6
  import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
5
7
  import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
@@ -138,6 +140,63 @@ export function modelLoadFallbacks() {
138
140
  export function clearModelLoadFallbacks() {
139
141
  servedFallbacks.length = 0;
140
142
  }
143
+ /** Per-model triangle ceiling before the loader warns. A prop is a few thousand triangles; a hero rig tens of thousands. */
144
+ export const DEFAULT_MODEL_TRIANGLE_BUDGET = 100_000;
145
+ let modelTriangleBudget = DEFAULT_MODEL_TRIANGLE_BUDGET;
146
+ /** Retune the per-model triangle budget (a cinematic game may raise it; a mobile target lowers it). Non-finite or non-positive values restore the default. */
147
+ export function setModelTriangleBudget(triangles) {
148
+ modelTriangleBudget = Number.isFinite(triangles) && triangles > 0 ? triangles : DEFAULT_MODEL_TRIANGLE_BUDGET;
149
+ }
150
+ /** Triangles a loaded model contributes per draw, summing every mesh (instanced meshes count once per instance). @internal */
151
+ export function countSceneTriangles(root) {
152
+ let total = 0;
153
+ root.traverse((node) => {
154
+ const mesh = node;
155
+ if (mesh.isMesh !== true || mesh.geometry === undefined)
156
+ return;
157
+ const geometry = mesh.geometry;
158
+ const vertices = geometry.index !== null ? geometry.index.count : geometry.attributes.position?.count ?? 0;
159
+ const instances = mesh.isInstancedMesh === true && typeof mesh.count === "number" ? mesh.count : 1;
160
+ total += Math.floor(vertices / 3) * instances;
161
+ });
162
+ return total;
163
+ }
164
+ const heavyModelRecords = [];
165
+ const warnedHeavyUrls = new Set();
166
+ function warnHeavyModel(record) {
167
+ if (typeof console === "undefined")
168
+ return;
169
+ console.warn(`[jgengine] model ${record.url} has ${record.triangles.toLocaleString()} triangles (budget ${record.budget.toLocaleString()}). A game prop should be a few thousand — pick a lighter asset or decimate it.`);
170
+ }
171
+ /**
172
+ * Record a loaded model's triangle count against the budget: over budget warns once per URL and lands in
173
+ * {@link heavyModels}; under budget is silent. Split out so the wiring is unit-testable without a GL context.
174
+ * @internal
175
+ */
176
+ export function recordModelTriangles(url, triangles, overrides = {}) {
177
+ if (triangles <= modelTriangleBudget)
178
+ return;
179
+ const record = { url, triangles, budget: modelTriangleBudget };
180
+ heavyModelRecords.push(record);
181
+ if (warnedHeavyUrls.has(url))
182
+ return;
183
+ warnedHeavyUrls.add(url);
184
+ (overrides.warn ?? warnHeavyModel)(record);
185
+ }
186
+ /**
187
+ * Every model the shared loader resolved whose triangle count exceeds the budget, in load order. A heavy
188
+ * prop is invisible in a screenshot and only shows up as a slow frame, so the loader measures each GLB as it
189
+ * lands and this exposes the verdict for `debug_snapshot` probes and smoke tests to assert against.
190
+ * @capability heavy-models models over the per-model triangle budget, for perf probes and smoke assertions
191
+ */
192
+ export function heavyModels() {
193
+ return heavyModelRecords;
194
+ }
195
+ /** Drops the heavy-model list and the once-per-URL warning memory. For tests and between capture runs. @internal */
196
+ export function clearHeavyModels() {
197
+ heavyModelRecords.length = 0;
198
+ warnedHeavyUrls.clear();
199
+ }
141
200
  /**
142
201
  * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
143
202
  * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
@@ -149,7 +208,10 @@ export function clearModelLoadFallbacks() {
149
208
  */
150
209
  export class DiagnosticGLTFLoader extends GLTFLoader {
151
210
  load(url, onLoad, onProgress, onError) {
152
- super.load(url, onLoad, onProgress, (event) => {
211
+ super.load(url, (gltf) => {
212
+ recordModelTriangles(url, countSceneTriangles(gltf.scene));
213
+ onLoad(gltf);
214
+ }, onProgress, (event) => {
153
215
  if (onError === undefined)
154
216
  return;
155
217
  // Probe the URL the loader actually fetched (base-resolved), not the raw input.
@@ -157,6 +219,31 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
157
219
  });
158
220
  }
159
221
  }
222
+ const DEFAULT_DRACO_DECODER_PATH = "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/";
223
+ const DEFAULT_KTX2_TRANSCODER_PATH = "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/basis/";
224
+ let configuredModelLoaders;
225
+ let ktx2Loader;
226
+ /** Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. */
227
+ export function configureModelLoaders(options = {}) {
228
+ const next = {
229
+ dracoDecoderPath: options.dracoDecoderPath ?? DEFAULT_DRACO_DECODER_PATH,
230
+ ktx2TranscoderPath: options.ktx2TranscoderPath ?? DEFAULT_KTX2_TRANSCODER_PATH,
231
+ };
232
+ if (configuredModelLoaders?.dracoDecoderPath === next.dracoDecoderPath && configuredModelLoaders.ktx2TranscoderPath === next.ktx2TranscoderPath) {
233
+ return configuredModelLoaders;
234
+ }
235
+ const draco = new DRACOLoader().setDecoderPath(next.dracoDecoderPath);
236
+ ktx2Loader = new KTX2Loader().setTranscoderPath(next.ktx2TranscoderPath);
237
+ sharedGltfLoader.setDRACOLoader(draco);
238
+ sharedGltfLoader.setKTX2Loader(ktx2Loader);
239
+ configuredModelLoaders = next;
240
+ return configuredModelLoaders;
241
+ }
242
+ /** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
243
+ export function detectKtx2Support(renderer) {
244
+ configureModelLoaders();
245
+ ktx2Loader?.detectSupport(renderer);
246
+ }
160
247
  // LoadingManager reports queue transitions, not per-item events: `onStart` fires when the
161
248
  // queue goes busy and `onLoad` when it drains (errors included, via itemEnd), so a flag plus
162
249
  // a drain timestamp is the whole state.
@@ -187,3 +274,4 @@ export function modelLoadIdleMs() {
187
274
  /** Shared GLTF loader used by shell entity/object model mounts. @internal */
188
275
  export const sharedGltfLoader = new DiagnosticGLTFLoader(modelLoadingManager);
189
276
  sharedGltfLoader.setMeshoptDecoder(MeshoptDecoder);
277
+ configureModelLoaders();
@@ -1,9 +1,12 @@
1
1
  import * as THREE from "three";
2
2
  import type { PaintStroke } from "@jgengine/core/scene/paintLayer";
3
3
  export declare const PAINT_TEXTURE_SIZE = 512;
4
+ /** Shadow participation applied to every mesh of a cloned model; mirrors `ModelConfig.shadows`. */
5
+ export type ModelShadowMode = "cast" | "receive" | "both" | "none";
4
6
  /** @internal */
5
7
  export declare function cloneModelScene(source: THREE.Object3D, options?: {
6
8
  cloneMaterials?: boolean;
9
+ shadows?: ModelShadowMode;
7
10
  }): THREE.Object3D;
8
11
  /** @internal */
9
12
  export declare function disposeClonedMaterials(root: THREE.Object3D): void;
@@ -4,12 +4,16 @@ export const PAINT_TEXTURE_SIZE = 512;
4
4
  /** @internal */
5
5
  export function cloneModelScene(source, options) {
6
6
  const clone = cloneSkinned(source);
7
- if (options?.cloneMaterials === false)
8
- return clone;
7
+ const shadows = options?.shadows ?? "both";
8
+ const cloneMaterials = options?.cloneMaterials !== false;
9
9
  clone.traverse((node) => {
10
10
  const mesh = node;
11
11
  if (!mesh.isMesh)
12
12
  return;
13
+ mesh.castShadow = shadows === "cast" || shadows === "both";
14
+ mesh.receiveShadow = shadows === "receive" || shadows === "both";
15
+ if (!cloneMaterials)
16
+ return;
13
17
  mesh.material = Array.isArray(mesh.material)
14
18
  ? mesh.material.map((material) => material.clone())
15
19
  : mesh.material.clone();
@@ -1,3 +1,4 @@
1
+ import { warnOnce } from "@jgengine/core/devtools/warnOnce";
1
2
  /**
2
3
  * The `ModelConfig` a resolved catalog ref renders as. Stamps `animation: "auto"` so any rigged
3
4
  * catalog asset animates by default — the shell derives idle/walk/run states and one-shots from
@@ -74,6 +75,9 @@ export function resolveEntityModel(value, assets, key) {
74
75
  * @internal
75
76
  */
76
77
  export function pickModel(assets, pick) {
78
+ if (pick.fallbackModel !== undefined && !assets.has(pick.fallbackModel)) {
79
+ warnOnce(`pickModel:fallback:${pick.fallbackModel}`, `[jgengine] fallbackModel "${pick.fallbackModel}" is not in the asset catalog, so it can never stand in for "${pick.model ?? "(none)"}" — fix the id or declare its pack in the catalog sources.`);
80
+ }
77
81
  for (const id of [pick.model, pick.fallbackModel]) {
78
82
  if (id === undefined)
79
83
  continue;
@@ -0,0 +1,58 @@
1
+ import * as THREE from "three";
2
+ import type { GameContext } from "@jgengine/core/runtime/gameContextTypes";
3
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
4
+ import type { SceneRaycastHit, SceneRaycastInput } from "@jgengine/core/scene/sceneRaycast";
5
+ type FootIkConfig = NonNullable<ModelConfig["ik"]>;
6
+ type GroundProbe = (input: SceneRaycastInput) => SceneRaycastHit | null;
7
+ interface IkLeg {
8
+ root: THREE.Object3D;
9
+ mid: THREE.Object3D;
10
+ tip: THREE.Object3D;
11
+ /** Rest-pose ankle height above the sole divided by rest leg length, so it survives any scale. */
12
+ ankleRatio: number;
13
+ }
14
+ interface PoseRecord {
15
+ bone: THREE.Object3D;
16
+ animated: {
17
+ q: THREE.Quaternion;
18
+ p: THREE.Vector3;
19
+ };
20
+ written: {
21
+ q: THREE.Quaternion;
22
+ p: THREE.Vector3;
23
+ };
24
+ }
25
+ /** Bones resolved once per loaded scene for {@link applyFootIk}. */
26
+ export interface FootIkRig {
27
+ legs: IkLeg[];
28
+ pelvis: THREE.Object3D | null;
29
+ head: THREE.Object3D | null;
30
+ maxAdjust: number;
31
+ alignToGround: number;
32
+ /** Pose before and after last frame's correction, so a bone no clip animates never accumulates it. */
33
+ poses: PoseRecord[];
34
+ }
35
+ /** Per-model state carried between frames: the faded weight and the smoothed pelvis drop. */
36
+ export interface FootIkState {
37
+ weight: number;
38
+ pelvis: number;
39
+ }
40
+ /**
41
+ * Resolves foot-IK bones on a loaded rig: explicit chains, or legs found by bone name for `"auto"`
42
+ * and configs without `feet`. Returns `null` when no leg resolves.
43
+ */
44
+ export declare function resolveFootIkRig(scene: THREE.Object3D, config: FootIkConfig): FootIkRig | null;
45
+ /**
46
+ * Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
47
+ * resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
48
+ * toward the animated knee, and tilts planted feet to the ground. Returns whether the feet are
49
+ * grounded; `state` carries the faded weight between frames. Exported for tests and custom hosts.
50
+ */
51
+ export declare function applyFootIk(rig: FootIkRig, originY: number, probe: GroundProbe, state: FootIkState, delta: number, cameraTarget?: readonly [number, number, number]): boolean;
52
+ /**
53
+ * Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
54
+ * blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
55
+ * its soles rest on (`ModelConfig.y`).
56
+ */
57
+ export declare function useFootIk(scene: THREE.Object3D, config: FootIkConfig | undefined, ctx: GameContext | null, instanceId?: string, groundOffset?: number): void;
58
+ export {};