@jgengine/shell 0.18.1 → 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 (112) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/GamePlayerShell.js +20 -3
  3. package/dist/Shell3dPresentation.d.ts +3 -9
  4. package/dist/Shell3dPresentation.js +26 -6
  5. package/dist/ShellHudPresentation.d.ts +9 -1
  6. package/dist/ShellHudPresentation.js +21 -0
  7. package/dist/audio/AudioComponents.js +6 -2
  8. package/dist/audio/audioEngine.d.ts +8 -0
  9. package/dist/audio/audioEngine.js +65 -6
  10. package/dist/audio/audioWire.d.ts +3 -0
  11. package/dist/audio/audioWire.js +7 -1
  12. package/dist/audio/loopParams.d.ts +39 -0
  13. package/dist/audio/loopParams.js +43 -0
  14. package/dist/camera/GameCameraRig.js +1 -1
  15. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  16. package/dist/camera/GameFirstPersonCamera.js +52 -15
  17. package/dist/camera/Viewports.d.ts +34 -0
  18. package/dist/camera/Viewports.js +123 -0
  19. package/dist/camera/cameraRigs.js +70 -62
  20. package/dist/camera/orbitCameraMath.js +2 -2
  21. package/dist/camera/rigMath.d.ts +82 -4
  22. package/dist/camera/rigMath.js +133 -15
  23. package/dist/defineGame.d.ts +9 -0
  24. package/dist/defineGame.js +20 -3
  25. package/dist/devtools/DevtoolsOverlay.js +2 -0
  26. package/dist/devtools/agentBridge.d.ts +6 -0
  27. package/dist/devtools/agentBridge.js +15 -1
  28. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  29. package/dist/diagnostics/FpsCounter.js +32 -0
  30. package/dist/drivers/FrameDriver.js +13 -2
  31. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  32. package/dist/drivers/FrameRateLimiter.js +39 -0
  33. package/dist/drivers/HudOnlyDriver.js +1 -1
  34. package/dist/environment/DayNightSky.js +5 -5
  35. package/dist/environment/Daylight.d.ts +35 -1
  36. package/dist/environment/Daylight.js +98 -28
  37. package/dist/environment/VolumetricClouds.js +20 -2
  38. package/dist/environment/daylightCycle.d.ts +20 -0
  39. package/dist/environment/daylightCycle.js +59 -7
  40. package/dist/environment/sunShadowMath.d.ts +45 -0
  41. package/dist/environment/sunShadowMath.js +50 -0
  42. package/dist/input/gamepadPoll.d.ts +47 -0
  43. package/dist/input/gamepadPoll.js +142 -0
  44. package/dist/input/gamepadSource.d.ts +11 -1
  45. package/dist/input/gamepadSource.js +117 -71
  46. package/dist/input/mouseLook.js +2 -1
  47. package/dist/input/padHaptics.d.ts +17 -0
  48. package/dist/input/padHaptics.js +32 -0
  49. package/dist/input/pointerLock.d.ts +11 -0
  50. package/dist/input/pointerLock.js +19 -0
  51. package/dist/postfx/PostProcessing.d.ts +1 -1
  52. package/dist/postfx/PostProcessing.js +5 -1
  53. package/dist/postfx/stylizePass.d.ts +23 -0
  54. package/dist/postfx/stylizePass.js +216 -0
  55. package/dist/render/SceneModels.js +1 -1
  56. package/dist/render/SkinnedInstances.d.ts +91 -0
  57. package/dist/render/SkinnedInstances.js +249 -0
  58. package/dist/render/TextureFiltering.d.ts +16 -0
  59. package/dist/render/TextureFiltering.js +64 -0
  60. package/dist/render/displayColor.d.ts +33 -0
  61. package/dist/render/displayColor.js +138 -0
  62. package/dist/render/modelLoad.d.ts +29 -0
  63. package/dist/render/modelLoad.js +61 -1
  64. package/dist/render/resolveModel.js +4 -0
  65. package/dist/render/useFootIk.d.ts +53 -4
  66. package/dist/render/useFootIk.js +197 -70
  67. package/dist/render/useModelAnimation.d.ts +24 -0
  68. package/dist/render/useModelAnimation.js +61 -40
  69. package/dist/scene/AuthoredScene.d.ts +9 -0
  70. package/dist/scene/AuthoredScene.js +17 -1
  71. package/dist/scene/CityRenderer.js +6 -6
  72. package/dist/settings/QuickControls.js +1 -1
  73. package/dist/settings/SettingsMenu.js +1 -1
  74. package/dist/settings/appliedSettings.d.ts +9 -3
  75. package/dist/settings/appliedSettings.js +6 -5
  76. package/dist/settings/settingsController.d.ts +3 -0
  77. package/dist/settings/settingsController.js +47 -3
  78. package/dist/structures/BuildingKitBatch.js +26 -22
  79. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  80. package/dist/structures/GeneratedBuilding.js +21 -15
  81. package/dist/structures/buildingSurface.d.ts +21 -0
  82. package/dist/structures/buildingSurface.js +134 -0
  83. package/dist/useShellMultiplayerSync.d.ts +1 -1
  84. package/dist/useShellMultiplayerSync.js +5 -3
  85. package/dist/world/WorldParticles.js +1 -1
  86. package/package.json +4 -4
  87. package/skills/ce-handoff/SKILL.md +1 -1
  88. package/skills/game-design/SKILL.md +2 -2
  89. package/skills/jgengine/SKILL.md +7 -5
  90. package/skills/jgengine/api.md +118 -89
  91. package/skills/jgengine/capabilities.md +28 -0
  92. package/skills/jgengine/recipes/minimal-game.md +3 -0
  93. package/skills/jgengine-ui/SKILL.md +13 -16
  94. package/skills/jgengine-ui/api.md +418 -317
  95. package/skills/jgengine-ui/capabilities.md +16 -0
  96. package/skills/jgengine-ui/reference-primitives.md +89 -0
  97. package/skills/jgengine-ui/reference.md +2 -81
  98. package/skills/jgengine-world/SKILL.md +15 -3
  99. package/skills/jgengine-world/api.md +873 -626
  100. package/skills/jgengine-world/capabilities.md +130 -6
  101. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  102. package/skills/jgengine-world/recipes/controls.md +44 -0
  103. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  104. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  105. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  106. package/skills/jgengine-world/reference.md +31 -2
  107. package/skills/jgengine-world/visibility.md +112 -0
  108. package/skills/level-design/SKILL.md +1 -1
  109. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  110. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  111. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  112. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -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
+ }
@@ -61,6 +61,35 @@ export declare function modelLoadFallbacks(): readonly {
61
61
  }[];
62
62
  /** Drops the recorded placeholder list. For tests and between capture runs. @internal */
63
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;
64
93
  /**
65
94
  * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
66
95
  * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
@@ -140,6 +140,63 @@ export function modelLoadFallbacks() {
140
140
  export function clearModelLoadFallbacks() {
141
141
  servedFallbacks.length = 0;
142
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
+ }
143
200
  /**
144
201
  * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
145
202
  * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
@@ -151,7 +208,10 @@ export function clearModelLoadFallbacks() {
151
208
  */
152
209
  export class DiagnosticGLTFLoader extends GLTFLoader {
153
210
  load(url, onLoad, onProgress, onError) {
154
- super.load(url, onLoad, onProgress, (event) => {
211
+ super.load(url, (gltf) => {
212
+ recordModelTriangles(url, countSceneTriangles(gltf.scene));
213
+ onLoad(gltf);
214
+ }, onProgress, (event) => {
155
215
  if (onError === undefined)
156
216
  return;
157
217
  // Probe the URL the loader actually fetched (base-resolved), not the raw input.
@@ -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;
@@ -1,9 +1,58 @@
1
1
  import * as THREE from "three";
2
2
  import type { GameContext } from "@jgengine/core/runtime/gameContextTypes";
3
3
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
4
+ import type { SceneRaycastHit, SceneRaycastInput } from "@jgengine/core/scene/sceneRaycast";
4
5
  type FootIkConfig = NonNullable<ModelConfig["ik"]>;
5
- /** Applies one frame of foot IK to a loaded rig. Exported for renderer tests and custom model hosts. */
6
- export declare function applyFootIk(scene: THREE.Object3D, config: FootIkConfig, raycast: GameContext["scene"]["raycast"], weight: number, cameraTarget?: readonly [number, number, number]): boolean;
7
- /** Runs foot IK after the model animation mixer, fading the correction out while the entity is airborne. */
8
- export declare function useFootIk(scene: THREE.Object3D, config: FootIkConfig | undefined, ctx: GameContext | null, instanceId?: string): void;
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;
9
58
  export {};
@@ -1,89 +1,216 @@
1
1
  import { useFrame, useThree } from "@react-three/fiber";
2
- import { useRef } from "react";
2
+ import { useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
+ import { inferLegChains, placeFeet } from "@jgengine/core/anim/footPlacement";
4
5
  import { lookAt, solveTwoBone } from "@jgengine/core/anim/ikSolver";
5
- function tuple(value) {
6
- return [value.x, value.y, value.z];
6
+ const DEFAULT_MAX_ADJUST = 0.4;
7
+ const UP = new THREE.Vector3(0, 1, 0);
8
+ const scratch = {
9
+ root: new THREE.Vector3(),
10
+ mid: new THREE.Vector3(),
11
+ tip: new THREE.Vector3(),
12
+ a: new THREE.Vector3(),
13
+ b: new THREE.Vector3(),
14
+ q: new THREE.Quaternion(),
15
+ q2: new THREE.Quaternion(),
16
+ parent: new THREE.Quaternion(),
17
+ m: new THREE.Matrix4(),
18
+ };
19
+ const solved = { mid: [0, 0, 0], tip: [0, 0, 0] };
20
+ function bindPosition(skeleton, bone, out) {
21
+ const index = skeleton.bones.indexOf(bone);
22
+ if (index < 0)
23
+ return null;
24
+ return out.setFromMatrixPosition(scratch.m.copy(skeleton.boneInverses[index]).invert());
7
25
  }
8
- function worldQuaternion(bone) {
9
- const result = new THREE.Quaternion();
10
- bone.getWorldQuaternion(result);
11
- return result;
26
+ function restAnkleRatio(scene, leg) {
27
+ let soleY = Infinity;
28
+ let ratio = 0;
29
+ const root = new THREE.Vector3();
30
+ const mid = new THREE.Vector3();
31
+ const tip = new THREE.Vector3();
32
+ scene.traverse((object) => {
33
+ const mesh = object;
34
+ if (mesh.isSkinnedMesh !== true || !(leg.tip instanceof THREE.Bone) || !(leg.mid instanceof THREE.Bone) || !(leg.root instanceof THREE.Bone))
35
+ return;
36
+ if (bindPosition(mesh.skeleton, leg.tip, tip) === null || bindPosition(mesh.skeleton, leg.mid, mid) === null || bindPosition(mesh.skeleton, leg.root, root) === null)
37
+ return;
38
+ if (mesh.geometry.boundingBox === null)
39
+ mesh.geometry.computeBoundingBox();
40
+ const box = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.bindMatrix);
41
+ soleY = Math.min(soleY, box.min.y);
42
+ const length = root.distanceTo(mid) + mid.distanceTo(tip);
43
+ if (length > 1e-6)
44
+ ratio = Math.max(0, tip.y - soleY) / length;
45
+ });
46
+ return ratio;
47
+ }
48
+ /**
49
+ * Resolves foot-IK bones on a loaded rig: explicit chains, or legs found by bone name for `"auto"`
50
+ * and configs without `feet`. Returns `null` when no leg resolves.
51
+ */
52
+ export function resolveFootIkRig(scene, config) {
53
+ const options = config === "auto" ? {} : config;
54
+ let chains = options.feet;
55
+ if (chains === undefined) {
56
+ const bones = [];
57
+ scene.traverse((object) => {
58
+ if (object.isBone === true)
59
+ bones.push({ name: object.name, parent: object.parent?.isBone === true ? object.parent.name : null });
60
+ });
61
+ chains = inferLegChains(bones);
62
+ }
63
+ const legs = [];
64
+ for (const chain of chains) {
65
+ const root = scene.getObjectByName(chain.root);
66
+ const mid = scene.getObjectByName(chain.mid);
67
+ const tip = scene.getObjectByName(chain.tip);
68
+ if (root === undefined || mid === undefined || tip === undefined)
69
+ continue;
70
+ legs.push({ root, mid, tip, ankleRatio: restAnkleRatio(scene, { root, mid, tip }) });
71
+ }
72
+ const head = options.lookAt === undefined ? null : (scene.getObjectByName(options.lookAt.bone) ?? null);
73
+ if (legs.length === 0 && head === null)
74
+ return null;
75
+ const found = options.pelvis !== undefined ? (scene.getObjectByName(options.pelvis) ?? null) : (legs[0]?.root.parent ?? null);
76
+ const pelvis = found === scene ? null : found;
77
+ const bones = new Set(legs.flatMap((leg) => [leg.root, leg.mid, leg.tip]));
78
+ if (pelvis !== null)
79
+ bones.add(pelvis);
80
+ if (head !== null)
81
+ bones.add(head);
82
+ return {
83
+ legs,
84
+ pelvis,
85
+ head,
86
+ maxAdjust: options.maxAdjust ?? DEFAULT_MAX_ADJUST,
87
+ alignToGround: Math.max(0, Math.min(1, options.alignToGround ?? 1)),
88
+ poses: [...bones].map((bone) => ({
89
+ bone,
90
+ animated: { q: bone.quaternion.clone(), p: bone.position.clone() },
91
+ written: { q: bone.quaternion.clone(), p: bone.position.clone() },
92
+ })),
93
+ };
12
94
  }
13
95
  function writeWorldQuaternion(bone, world) {
14
96
  if (bone.parent === null)
15
97
  bone.quaternion.copy(world);
16
- else {
17
- const parent = worldQuaternion(bone.parent).invert();
18
- bone.quaternion.copy(parent.multiply(world));
19
- }
98
+ else
99
+ bone.quaternion.copy(bone.parent.getWorldQuaternion(scratch.parent).invert().multiply(world));
20
100
  bone.updateMatrixWorld(true);
21
101
  }
22
- function aimSegment(bone, from, currentTo, solvedTo, weight) {
23
- const current = currentTo.clone().sub(from).normalize();
24
- const solved = new THREE.Vector3(solvedTo[0] - from.x, solvedTo[1] - from.y, solvedTo[2] - from.z).normalize();
25
- if (current.lengthSq() < 1e-8 || solved.lengthSq() < 1e-8)
102
+ function aimSegment(bone, from, currentTo, solvedTo) {
103
+ const current = scratch.a.copy(currentTo).sub(from);
104
+ const desired = scratch.b.set(solvedTo[0] - from.x, solvedTo[1] - from.y, solvedTo[2] - from.z);
105
+ if (current.lengthSq() < 1e-10 || desired.lengthSq() < 1e-10)
26
106
  return;
27
- const correction = new THREE.Quaternion().setFromUnitVectors(current, solved);
28
- const currentWorld = worldQuaternion(bone);
29
- const desiredWorld = correction.multiply(currentWorld);
30
- currentWorld.slerp(desiredWorld, weight);
31
- writeWorldQuaternion(bone, currentWorld);
107
+ const correction = scratch.q.setFromUnitVectors(current.normalize(), desired.normalize());
108
+ writeWorldQuaternion(bone, correction.multiply(bone.getWorldQuaternion(scratch.q2)));
32
109
  }
33
- /** Applies one frame of foot IK to a loaded rig. Exported for renderer tests and custom model hosts. */
34
- export function applyFootIk(scene, config, raycast, weight, cameraTarget) {
35
- let grounded = false;
36
- scene.updateMatrixWorld(true);
37
- for (const foot of config.feet) {
38
- const root = scene.getObjectByName(foot.root);
39
- const mid = scene.getObjectByName(foot.mid);
40
- const tip = scene.getObjectByName(foot.tip);
41
- if (root === undefined || mid === undefined || tip === undefined)
42
- continue;
43
- const rootPosition = root.getWorldPosition(new THREE.Vector3());
44
- const midPosition = mid.getWorldPosition(new THREE.Vector3());
45
- const tipPosition = tip.getWorldPosition(new THREE.Vector3());
46
- const hit = raycast({
47
- origin: [tipPosition.x, tipPosition.y + 0.75, tipPosition.z],
48
- direction: [0, -1, 0],
49
- maxDistance: 2,
50
- filter: { entities: false, objects: false, walls: false, terrain: true },
51
- });
52
- if (hit === null)
53
- continue;
54
- grounded = true;
55
- const target = new THREE.Vector3(hit.point[0], hit.point[1], hit.point[2]);
56
- const output = { mid: [0, 0, 0], tip: [0, 0, 0] };
57
- solveTwoBone({ root: tuple(rootPosition), mid: tuple(midPosition), tip: tuple(tipPosition), target: tuple(target), pole: [rootPosition.x, rootPosition.y, rootPosition.z + 1] }, output);
58
- aimSegment(root, rootPosition, midPosition, output.mid, weight);
59
- root.updateMatrixWorld(true);
60
- const solvedMid = mid.getWorldPosition(new THREE.Vector3());
61
- // Rotating the root also moves the tip; aim the shin from that updated pose.
62
- const currentTip = tip.getWorldPosition(new THREE.Vector3());
63
- aimSegment(mid, solvedMid, currentTip, output.tip, weight);
110
+ function legLength(leg) {
111
+ leg.root.getWorldPosition(scratch.root);
112
+ leg.mid.getWorldPosition(scratch.mid);
113
+ leg.tip.getWorldPosition(scratch.tip);
114
+ return scratch.root.distanceTo(scratch.mid) + scratch.mid.distanceTo(scratch.tip);
115
+ }
116
+ /**
117
+ * Applies one frame of foot IK after the animation mixer: probes the ground under each foot,
118
+ * resolves targets with `placeFeet`, lowers the pelvis, solves each leg with `solveTwoBone` bending
119
+ * toward the animated knee, and tilts planted feet to the ground. Returns whether the feet are
120
+ * grounded; `state` carries the faded weight between frames. Exported for tests and custom hosts.
121
+ */
122
+ export function applyFootIk(rig, originY, probe, state, delta, cameraTarget) {
123
+ for (const record of rig.poses) {
124
+ if (record.bone.quaternion.equals(record.written.q))
125
+ record.bone.quaternion.copy(record.animated.q);
126
+ if (record.bone.position.equals(record.written.p))
127
+ record.bone.position.copy(record.animated.p);
128
+ record.animated.q.copy(record.bone.quaternion);
129
+ record.animated.p.copy(record.bone.position);
64
130
  }
65
- if (config.lookAt !== undefined && cameraTarget !== undefined) {
66
- const bone = scene.getObjectByName(config.lookAt.bone);
67
- if (bone !== undefined) {
68
- const from = bone.getWorldPosition(new THREE.Vector3());
69
- const q = lookAt({ from: tuple(from), target: cameraTarget, up: [0, 1, 0] });
70
- const desired = new THREE.Quaternion(q[0], q[1], q[2], q[3]);
71
- const current = worldQuaternion(bone);
72
- current.slerp(desired, weight);
73
- writeWorldQuaternion(bone, current);
131
+ const samples = [];
132
+ const normals = [];
133
+ let length = 0;
134
+ let ankleHeight = 0;
135
+ for (const leg of rig.legs) {
136
+ leg.root.updateWorldMatrix(true, true);
137
+ const legSize = legLength(leg);
138
+ length = Math.max(length, legSize);
139
+ ankleHeight = Math.max(ankleHeight, leg.ankleRatio * legSize);
140
+ const from = Math.max(scratch.tip.y, originY) + legSize * 0.5;
141
+ const hit = probe({ origin: [scratch.tip.x, from, scratch.tip.z], direction: [0, -1, 0], maxDistance: legSize * 2 });
142
+ samples.push({ ankleY: scratch.tip.y, groundY: hit === null ? null : hit.point[1] });
143
+ normals.push(hit === null ? null : hit.normal);
144
+ }
145
+ const placement = placeFeet({ feet: samples, originY, ankleHeight, maxAdjust: rig.maxAdjust * length, airborneGap: length * 0.3 });
146
+ const blend = Math.min(1, delta * (placement.grounded ? 12 : 6));
147
+ state.weight += ((placement.grounded ? 1 : 0) - state.weight) * blend;
148
+ state.pelvis += (placement.pelvisOffset - state.pelvis) * Math.min(1, delta * 15);
149
+ const weight = state.weight;
150
+ if (rig.pelvis !== null && weight > 1e-3 && Math.abs(state.pelvis) > 1e-6) {
151
+ const world = rig.pelvis.getWorldPosition(scratch.a);
152
+ world.y += state.pelvis * weight;
153
+ rig.pelvis.position.copy(rig.pelvis.parent === null ? world : rig.pelvis.parent.worldToLocal(world));
154
+ rig.pelvis.updateMatrixWorld(true);
155
+ }
156
+ for (let i = 0; i < rig.legs.length && weight > 1e-3; i += 1) {
157
+ const target = placement.ankleTargets[i];
158
+ if (target === null || target === undefined)
159
+ continue;
160
+ const leg = rig.legs[i];
161
+ const root = leg.root.getWorldPosition(scratch.root);
162
+ const mid = leg.mid.getWorldPosition(scratch.mid);
163
+ const tip = leg.tip.getWorldPosition(scratch.tip);
164
+ const bend = scratch.a.copy(root).add(tip).multiplyScalar(0.5);
165
+ bend.subVectors(mid, bend);
166
+ if (bend.lengthSq() < 1e-8)
167
+ bend.set(0, 0, 1).applyQuaternion(leg.root.parent?.getWorldQuaternion(scratch.q) ?? scratch.q.identity());
168
+ const pole = bend.normalize().multiplyScalar(length).add(mid);
169
+ const targetY = tip.y + (target - tip.y) * weight;
170
+ solveTwoBone({ root: [root.x, root.y, root.z], mid: [mid.x, mid.y, mid.z], tip: [tip.x, tip.y, tip.z], target: [tip.x, targetY, tip.z], pole: [pole.x, pole.y, pole.z] }, solved);
171
+ aimSegment(leg.root, root, mid, solved.mid);
172
+ // Rotating the thigh also moved the tip; aim the shin from that updated pose.
173
+ aimSegment(leg.mid, leg.mid.getWorldPosition(scratch.mid), leg.tip.getWorldPosition(scratch.tip), solved.tip);
174
+ const normal = normals[i];
175
+ if (normal !== null && normal !== undefined && rig.alignToGround > 0) {
176
+ const planted = 1 - Math.min(1, Math.max(0, samples[i].ankleY - ankleHeight - samples[i].groundY) / Math.max(ankleHeight, length * 0.05));
177
+ const tilt = scratch.q.setFromUnitVectors(UP, scratch.b.set(normal[0], normal[1], normal[2]).normalize());
178
+ const current = leg.tip.getWorldQuaternion(scratch.q2);
179
+ writeWorldQuaternion(leg.tip, current.slerp(tilt.multiply(current), planted * weight * rig.alignToGround));
74
180
  }
75
181
  }
76
- return grounded;
182
+ if (rig.head !== null && cameraTarget !== undefined) {
183
+ const from = rig.head.getWorldPosition(scratch.a);
184
+ const q = lookAt({ from: [from.x, from.y, from.z], target: cameraTarget, up: [0, 1, 0] });
185
+ const current = rig.head.getWorldQuaternion(scratch.q2);
186
+ writeWorldQuaternion(rig.head, current.slerp(scratch.q.set(q[0], q[1], q[2], q[3]), Math.max(weight, rig.legs.length === 0 ? 1 : 0)));
187
+ }
188
+ for (const record of rig.poses) {
189
+ record.written.q.copy(record.bone.quaternion);
190
+ record.written.p.copy(record.bone.position);
191
+ }
192
+ return placement.grounded;
77
193
  }
78
- /** Runs foot IK after the model animation mixer, fading the correction out while the entity is airborne. */
79
- export function useFootIk(scene, config, ctx, instanceId) {
80
- const camera = useThree((state) => state.camera);
81
- const weight = useRef(0);
82
- useFrame((_state, delta) => {
83
- if (config === undefined || ctx === null || instanceId === undefined)
194
+ /**
195
+ * Runs foot IK after the model's animation mixer for `ModelConfig.ik`. Ground probes hit terrain and
196
+ * blocking physical objects, never the model's own entity. `groundOffset` is the model-space height
197
+ * its soles rest on (`ModelConfig.y`).
198
+ */
199
+ export function useFootIk(scene, config, ctx, instanceId, groundOffset = 0) {
200
+ const camera = useThree((three) => three.camera);
201
+ const rig = useMemo(() => (config === undefined ? null : resolveFootIkRig(scene, config)), [scene, config]);
202
+ const state = useRef({ weight: 0, pelvis: 0 });
203
+ useFrame((_three, delta) => {
204
+ if (rig === null || ctx === null || instanceId === undefined)
84
205
  return;
85
- const grounded = applyFootIk(scene, config, (input) => ctx.scene.raycast({ ...input, excludeInstanceIds: [instanceId] }), weight.current, tuple(camera.getWorldPosition(new THREE.Vector3())));
86
- const target = grounded ? 1 : 0;
87
- weight.current += (target - weight.current) * Math.min(1, delta * (grounded ? 12 : 5));
206
+ const parent = scene.parent;
207
+ const originY = parent === null ? scene.position.y : parent.localToWorld(scratch.a.set(0, groundOffset, 0)).y;
208
+ const exclude = [instanceId];
209
+ applyFootIk(rig, originY, (input) => ctx.scene.raycast({
210
+ ...input,
211
+ excludeInstanceIds: exclude,
212
+ filter: { entities: false, objects: true, walls: false, terrain: true },
213
+ accept: (hit) => hit.blocks && hit.purpose === "physical",
214
+ }), state.current, delta, rig.head === null ? undefined : camera.getWorldPosition(scratch.b).toArray());
88
215
  });
89
216
  }