@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.
- package/CHANGELOG.md +158 -7
- package/dist/GamePlayerShell.js +20 -3
- package/dist/Shell3dPresentation.d.ts +3 -9
- package/dist/Shell3dPresentation.js +26 -6
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/audio/AudioComponents.js +6 -2
- package/dist/audio/audioEngine.d.ts +8 -0
- package/dist/audio/audioEngine.js +65 -6
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +70 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +20 -3
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.js +13 -2
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +1 -1
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +11 -1
- package/dist/input/gamepadSource.js +117 -71
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/postfx/PostProcessing.d.ts +1 -1
- package/dist/postfx/PostProcessing.js +5 -1
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/SceneModels.js +1 -1
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +29 -0
- package/dist/render/modelLoad.js +61 -1
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +53 -4
- package/dist/render/useFootIk.js +197 -70
- package/dist/render/useModelAnimation.d.ts +24 -0
- package/dist/render/useModelAnimation.js +61 -40
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +6 -6
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +9 -3
- package/dist/settings/appliedSettings.js +6 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/useShellMultiplayerSync.d.ts +1 -1
- package/dist/useShellMultiplayerSync.js +5 -3
- package/dist/world/WorldParticles.js +1 -1
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +7 -5
- package/skills/jgengine/api.md +118 -89
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +13 -16
- package/skills/jgengine-ui/api.md +418 -317
- package/skills/jgengine-ui/capabilities.md +16 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -81
- package/skills/jgengine-world/SKILL.md +15 -3
- package/skills/jgengine-world/api.md +873 -626
- package/skills/jgengine-world/capabilities.md +130 -6
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
- package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
|
@@ -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
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 {};
|
package/dist/render/useFootIk.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
const current =
|
|
24
|
-
const
|
|
25
|
-
if (current.lengthSq() < 1e-
|
|
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 =
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
}
|