@jgengine/core 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.
- package/CHANGELOG.md +265 -2
- package/dist/ai/decisionGraph.d.ts +96 -0
- package/dist/ai/decisionGraph.js +212 -0
- package/dist/ai/groupAssist.d.ts +10 -0
- package/dist/ai/groupAssist.js +11 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/pursuit.d.ts +1 -1
- package/dist/ai/pursuit.js +1 -1
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/ai/threat.d.ts +8 -0
- package/dist/ai/threat.js +10 -0
- package/dist/anim/animGraph.d.ts +155 -0
- package/dist/anim/animGraph.js +414 -0
- package/dist/anim/boneTexture.d.ts +55 -0
- package/dist/anim/boneTexture.js +43 -0
- package/dist/anim/footPlacement.d.ts +57 -0
- package/dist/anim/footPlacement.js +67 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +35 -0
- package/dist/anim/locomotionGraph.js +62 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +12 -0
- package/dist/audio/doppler.d.ts +21 -0
- package/dist/audio/doppler.js +32 -0
- package/dist/audio/engineLayers.d.ts +83 -0
- package/dist/audio/engineLayers.js +115 -0
- package/dist/combat/abilityKit.d.ts +1 -0
- package/dist/combat/abilityKit.js +1 -0
- package/dist/combat/death.d.ts +7 -0
- package/dist/combat/shotOrigin.d.ts +6 -0
- package/dist/combat/shotOrigin.js +6 -0
- package/dist/combat/weaponFeedback.d.ts +36 -0
- package/dist/combat/weaponFeedback.js +42 -0
- package/dist/combat/weaponHandling.d.ts +161 -0
- package/dist/combat/weaponHandling.js +202 -0
- package/dist/combat/weaponPresentation.d.ts +115 -0
- package/dist/combat/weaponPresentation.js +96 -0
- package/dist/devtools/warnOnce.d.ts +8 -0
- package/dist/devtools/warnOnce.js +18 -0
- package/dist/economy/currency.d.ts +16 -2
- package/dist/economy/currency.js +55 -14
- package/dist/economy/wallet.d.ts +5 -4
- package/dist/economy/wallet.js +8 -4
- package/dist/editor/commandApply.js +1 -0
- package/dist/editor/commands.d.ts +4 -1
- package/dist/editor/document.d.ts +2 -1
- package/dist/editor/document.js +92 -1
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +24 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +20 -0
- package/dist/faction/reputation.d.ts +6 -0
- package/dist/faction/reputation.js +11 -0
- package/dist/game/cameraConfig.d.ts +59 -1
- package/dist/game/chat.d.ts +22 -1
- package/dist/game/chat.js +48 -19
- package/dist/game/controlGate.d.ts +6 -0
- package/dist/game/controlGate.js +30 -1
- package/dist/game/defineGame.d.ts +10 -1
- package/dist/game/events.d.ts +14 -1
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +16 -0
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +167 -6
- package/dist/game/systemRuntime.js +6 -1
- package/dist/game/toasts.d.ts +8 -0
- package/dist/game/toasts.js +7 -0
- package/dist/game/vfxInstance.d.ts +9 -0
- package/dist/game/vfxInstance.js +12 -1
- package/dist/game/viewports.d.ts +43 -0
- package/dist/game/viewports.js +46 -0
- package/dist/game/worldItem.d.ts +5 -1
- package/dist/game/worldItem.js +0 -1
- package/dist/input/actionBindings.d.ts +17 -2
- package/dist/input/actionBindings.js +24 -11
- package/dist/input/actionContexts.d.ts +45 -0
- package/dist/input/actionContexts.js +113 -0
- package/dist/input/axisInput.d.ts +4 -0
- package/dist/input/axisInput.js +4 -0
- package/dist/input/axisShaper.d.ts +73 -0
- package/dist/input/axisShaper.js +105 -0
- package/dist/input/gamepadModel.d.ts +79 -0
- package/dist/input/gamepadModel.js +108 -0
- package/dist/input/haptics.d.ts +50 -0
- package/dist/input/haptics.js +74 -0
- package/dist/input/inputBuffer.d.ts +40 -0
- package/dist/input/inputBuffer.js +59 -0
- package/dist/input/lookChannel.d.ts +10 -0
- package/dist/input/lookChannel.js +6 -0
- package/dist/input/pointer.d.ts +2 -0
- package/dist/input/pointer.js +4 -0
- package/dist/item/affix.d.ts +6 -0
- package/dist/item/affix.js +6 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +196 -1
- package/dist/movement/characterController.d.ts +76 -0
- package/dist/movement/characterController.js +216 -0
- package/dist/movement/freeFlight.d.ts +119 -0
- package/dist/movement/freeFlight.js +294 -0
- package/dist/movement/glideModel.d.ts +3 -0
- package/dist/movement/glideModel.js +6 -0
- package/dist/movement/grappleSwing.d.ts +2 -0
- package/dist/movement/grappleSwing.js +5 -1
- package/dist/movement/movementModel.d.ts +39 -0
- package/dist/movement/movementModel.js +62 -19
- package/dist/movement/movementProbe.d.ts +51 -0
- package/dist/movement/movementProbe.js +119 -0
- package/dist/movement/playerMovement.d.ts +30 -3
- package/dist/movement/playerMovement.js +304 -7
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +39 -8
- package/dist/movement/solidObstacles.js +71 -6
- package/dist/movement/voxelController.js +7 -7
- package/dist/multiplayer/poseSyncGate.d.ts +3 -1
- package/dist/multiplayer/poseSyncGate.js +9 -1
- package/dist/multiplayer/presenceContract.d.ts +6 -0
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +11 -0
- package/dist/nav/navFromEnvironment.d.ts +9 -3
- package/dist/nav/navFromEnvironment.js +18 -12
- package/dist/nav/navMesh.d.ts +74 -0
- package/dist/nav/navMesh.js +570 -0
- package/dist/physics/aircraftDynamics.d.ts +390 -0
- package/dist/physics/aircraftDynamics.js +704 -0
- package/dist/physics/boatDynamics.d.ts +116 -0
- package/dist/physics/boatDynamics.js +160 -0
- package/dist/physics/drivableVehicle.d.ts +22 -8
- package/dist/physics/drivableVehicle.js +4 -2
- package/dist/physics/flightDynamics.d.ts +29 -4
- package/dist/physics/flightDynamics.js +28 -5
- package/dist/physics/handlingProbe.d.ts +249 -0
- package/dist/physics/handlingProbe.js +606 -0
- package/dist/physics/kinematicVehicle.d.ts +37 -2
- package/dist/physics/kinematicVehicle.js +15 -0
- package/dist/physics/physicsBackend.d.ts +203 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +151 -0
- package/dist/physics/physicsWorldBackend.d.ts +45 -0
- package/dist/physics/physicsWorldBackend.js +518 -0
- package/dist/physics/vehicleBackendLink.d.ts +49 -0
- package/dist/physics/vehicleBackendLink.js +102 -0
- package/dist/physics/vehicleDynamics.d.ts +374 -0
- package/dist/physics/vehicleDynamics.js +737 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +128 -0
- package/dist/puzzle/cellGrid.d.ts +15 -9
- package/dist/puzzle/cellGrid.js +13 -9
- package/dist/random/rng.d.ts +12 -1
- package/dist/random/rng.js +20 -3
- package/dist/render/environment.d.ts +23 -0
- package/dist/render/environment.js +1 -0
- package/dist/render/lookPreset.d.ts +19 -6
- package/dist/render/lookPreset.js +56 -4
- package/dist/render/postProcessing.d.ts +40 -1
- package/dist/render/postProcessing.js +1 -1
- package/dist/render/sprite2d.d.ts +38 -0
- package/dist/render/sprite2d.js +40 -0
- package/dist/runtime/adapter.d.ts +20 -1
- package/dist/runtime/adapter.js +8 -1
- package/dist/runtime/cameraDirector.d.ts +17 -3
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/commandInput.d.ts +52 -0
- package/dist/runtime/commandInput.js +71 -0
- package/dist/runtime/commandRunner.d.ts +39 -7
- package/dist/runtime/commandRunner.js +35 -5
- package/dist/runtime/context/combat.d.ts +1 -0
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/deathLoot.d.ts +2 -0
- package/dist/runtime/context/deathLoot.js +1 -0
- package/dist/runtime/gameContext.js +18 -1
- package/dist/runtime/gameContextTypes.d.ts +33 -7
- package/dist/runtime/gameRuntime.d.ts +23 -5
- package/dist/runtime/gameRuntime.js +21 -4
- package/dist/runtime/headlessRunner.d.ts +10 -0
- package/dist/runtime/headlessRunner.js +26 -8
- package/dist/runtime/hostPolicy.d.ts +3 -2
- package/dist/runtime/hostPolicy.js +8 -10
- package/dist/runtime/hostedGameRunner.js +34 -6
- package/dist/runtime/hostedWorldSession.d.ts +12 -16
- package/dist/runtime/hostedWorldSession.js +29 -6
- package/dist/runtime/hostedWorldStore.d.ts +20 -0
- package/dist/runtime/hostedWorldStore.js +1 -0
- package/dist/runtime/inputRecorder.d.ts +30 -0
- package/dist/runtime/inputRecorder.js +59 -0
- package/dist/runtime/inputSnapshot.d.ts +14 -0
- package/dist/runtime/inputSnapshot.js +11 -0
- package/dist/runtime/localPlayers.d.ts +73 -0
- package/dist/runtime/localPlayers.js +119 -0
- package/dist/runtime/poseInterpolation.d.ts +36 -0
- package/dist/runtime/poseInterpolation.js +91 -0
- package/dist/runtime/prediction.d.ts +45 -0
- package/dist/runtime/prediction.js +62 -0
- package/dist/runtime/simContext.d.ts +45 -0
- package/dist/runtime/simContext.js +65 -0
- package/dist/runtime/simLoop.d.ts +52 -0
- package/dist/runtime/simLoop.js +72 -0
- package/dist/runtime/simSnapshot.d.ts +32 -0
- package/dist/runtime/simSnapshot.js +60 -0
- package/dist/runtime/snapshot.d.ts +8 -0
- package/dist/runtime/snapshotBuffer.d.ts +33 -0
- package/dist/runtime/snapshotBuffer.js +58 -0
- package/dist/runtime/transport.d.ts +41 -2
- package/dist/runtime/transport.js +7 -1
- package/dist/runtime/worldChunks.d.ts +2 -0
- package/dist/runtime/worldChunks.js +14 -0
- package/dist/runtime/worldProjection.d.ts +9 -0
- package/dist/runtime/worldProjection.js +17 -1
- package/dist/runtime/worldSnapshot.d.ts +6 -0
- package/dist/runtime/worldSnapshot.js +3 -0
- package/dist/scene/behaviorRuntime.d.ts +29 -1
- package/dist/scene/behaviorRuntime.js +71 -4
- package/dist/scene/behaviors.d.ts +12 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/scene/sceneKinds.d.ts +9 -0
- package/dist/scene/vehicleSeat.d.ts +5 -1
- package/dist/scene/vehicleSeat.js +5 -1
- package/dist/sensor/concealment.d.ts +6 -0
- package/dist/sensor/concealment.js +8 -0
- package/dist/sensor/freezeMonitor.d.ts +6 -0
- package/dist/sensor/freezeMonitor.js +8 -0
- package/dist/sensor/perception.d.ts +70 -0
- package/dist/sensor/perception.js +189 -0
- package/dist/sensor/recordingBuffer.d.ts +3 -0
- package/dist/sensor/recordingBuffer.js +8 -0
- package/dist/session/roundState.d.ts +4 -0
- package/dist/session/roundState.js +11 -0
- package/dist/settings/frameRateLimit.d.ts +16 -0
- package/dist/settings/frameRateLimit.js +37 -0
- package/dist/settings/graphicsProfile.d.ts +21 -0
- package/dist/settings/graphicsProfile.js +48 -0
- package/dist/settings/graphicsSettings.d.ts +40 -0
- package/dist/settings/graphicsSettings.js +81 -0
- package/dist/settings/settingsModel.d.ts +9 -1
- package/dist/settings/settingsModel.js +10 -0
- package/dist/stats/eventMeter.d.ts +1 -1
- package/dist/stats/eventMeter.js +1 -1
- package/dist/time/accrueSince.d.ts +10 -0
- package/dist/time/accrueSince.js +9 -0
- package/dist/time/rateWindow.d.ts +16 -0
- package/dist/time/rateWindow.js +14 -0
- package/dist/time/serverTick.d.ts +21 -3
- package/dist/time/serverTick.js +14 -13
- package/dist/vfx/feedbackMixer.d.ts +61 -0
- package/dist/vfx/feedbackMixer.js +106 -0
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/visibility/simulationCulling.d.ts +8 -1
- package/dist/visibility/simulationCulling.js +7 -0
- package/dist/visibility/spatialIndex.d.ts +21 -0
- package/dist/visibility/spatialIndex.js +16 -0
- package/dist/world/authoredSolids.d.ts +46 -0
- package/dist/world/authoredSolids.js +75 -0
- package/dist/world/buildingGenerator.js +2 -2
- package/dist/world/buildingKit.d.ts +3 -1
- package/dist/world/buildings.d.ts +46 -2
- package/dist/world/buildings.js +53 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +139 -0
- package/dist/world/cityKind.js +367 -16
- package/dist/world/features.d.ts +56 -3
- package/dist/world/features.js +13 -2
- package/dist/world/footprintGrid.d.ts +7 -0
- package/dist/world/footprintGrid.js +16 -0
- package/dist/world/interiors.d.ts +1 -0
- package/dist/world/interiors.js +5 -3
- package/dist/world/lod.d.ts +6 -0
- package/dist/world/lod.js +8 -0
- package/dist/world/placement.d.ts +7 -0
- package/dist/world/placement.js +2 -0
- package/dist/world/placementController.d.ts +1 -1
- package/dist/world/placementController.js +7 -0
- package/dist/world/raceCircuit.d.ts +168 -0
- package/dist/world/raceCircuit.js +797 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world/terraform.js +7 -0
- package/dist/world/terrain.d.ts +5 -1
- package/dist/world/terrain.js +5 -0
- package/dist/world/territory.d.ts +97 -0
- package/dist/world/territory.js +182 -0
- package/dist/world/walls.d.ts +7 -0
- package/dist/world/walls.js +7 -0
- package/dist/world/worldSolids.d.ts +84 -0
- package/dist/world/worldSolids.js +240 -0
- package/dist/world.d.ts +5 -3
- package/dist/world.js +4 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-combat/SKILL.md +5 -0
- package/skills/jgengine-combat/api.md +138 -103
- package/skills/jgengine-combat/capabilities.md +29 -1
- package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +501 -453
- package/skills/jgengine-gameplay/capabilities.md +36 -0
- package/skills/jgengine-gameplay/reference-systems.md +4 -0
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- 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
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
const DEFAULT_FADE = 0.2;
|
|
2
|
+
/** Entity blackboard key the shell reads extra graph parameters from: `ctx.scene.entity.blackboard.set(id, ANIM_PARAMS_KEY, { aiming: true })`. */
|
|
3
|
+
export const ANIM_PARAMS_KEY = "anim.params";
|
|
4
|
+
function compare(actual, op, value) {
|
|
5
|
+
const a = actual === undefined ? 0 : Number(actual);
|
|
6
|
+
const b = Number(value);
|
|
7
|
+
switch (op) {
|
|
8
|
+
case ">":
|
|
9
|
+
return a > b;
|
|
10
|
+
case "<":
|
|
11
|
+
return a < b;
|
|
12
|
+
case ">=":
|
|
13
|
+
return a >= b;
|
|
14
|
+
case "<=":
|
|
15
|
+
return a <= b;
|
|
16
|
+
case "==":
|
|
17
|
+
return a === b;
|
|
18
|
+
case "!=":
|
|
19
|
+
return a !== b;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Static clip weights of a state at `params`, before any crossfade. */
|
|
23
|
+
export function stateClipWeights(state, params) {
|
|
24
|
+
const out = {};
|
|
25
|
+
if (state.kind === "clip") {
|
|
26
|
+
out[state.clip] = 1;
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
if (state.kind === "blend1D") {
|
|
30
|
+
const points = [...state.points].sort((x, y) => x.at - y.at);
|
|
31
|
+
if (points.length === 0)
|
|
32
|
+
return out;
|
|
33
|
+
const value = Number(params[state.param] ?? 0);
|
|
34
|
+
if (value <= points[0].at) {
|
|
35
|
+
out[points[0].clip] = 1;
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
const last = points[points.length - 1];
|
|
39
|
+
if (value >= last.at) {
|
|
40
|
+
out[last.clip] = 1;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
44
|
+
const lo = points[i];
|
|
45
|
+
const hi = points[i + 1];
|
|
46
|
+
if (value >= lo.at && value <= hi.at) {
|
|
47
|
+
const t = hi.at === lo.at ? 0 : (value - lo.at) / (hi.at - lo.at);
|
|
48
|
+
out[lo.clip] = (out[lo.clip] ?? 0) + (1 - t);
|
|
49
|
+
out[hi.clip] = (out[hi.clip] ?? 0) + t;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const x = Number(params[state.params[0]] ?? 0);
|
|
56
|
+
const y = Number(params[state.params[1]] ?? 0);
|
|
57
|
+
let total = 0;
|
|
58
|
+
const raw = [];
|
|
59
|
+
for (const point of state.points) {
|
|
60
|
+
const dx = point.at[0] - x;
|
|
61
|
+
const dy = point.at[1] - y;
|
|
62
|
+
const d2 = dx * dx + dy * dy;
|
|
63
|
+
if (d2 < 1e-9) {
|
|
64
|
+
out[point.clip] = 1;
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
const w = 1 / d2;
|
|
68
|
+
raw.push({ clip: point.clip, w });
|
|
69
|
+
total += w;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of raw)
|
|
72
|
+
out[entry.clip] = (out[entry.clip] ?? 0) + entry.w / total;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function stateSpeed(state) {
|
|
76
|
+
return state.speed ?? 1;
|
|
77
|
+
}
|
|
78
|
+
function stateLoops(state) {
|
|
79
|
+
return state.loop !== false;
|
|
80
|
+
}
|
|
81
|
+
/** Longest clip in a state at these weights; the state's timeline length for exit times and events. */
|
|
82
|
+
function stateDuration(weights, clips) {
|
|
83
|
+
let duration = 0;
|
|
84
|
+
for (const clip of Object.keys(weights))
|
|
85
|
+
duration = Math.max(duration, clipDuration(clips[clip]));
|
|
86
|
+
return duration;
|
|
87
|
+
}
|
|
88
|
+
function clipDuration(info) {
|
|
89
|
+
return typeof info === "number" ? info : info?.duration ?? 0;
|
|
90
|
+
}
|
|
91
|
+
function rootPosition(track, time, duration, loop) {
|
|
92
|
+
const count = Math.min(track.times.length, Math.floor(track.values.length / 3));
|
|
93
|
+
if (count === 0)
|
|
94
|
+
return [0, 0, 0];
|
|
95
|
+
const first = track.times[0] ?? 0;
|
|
96
|
+
const last = track.times[count - 1] ?? first;
|
|
97
|
+
const cycle = loop && duration > 0 ? Math.floor(time / duration) : 0;
|
|
98
|
+
const local = loop && duration > 0 ? ((time % duration) + duration) % duration : Math.min(Math.max(time, first), last);
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i + 1 < count && (track.times[i + 1] ?? 0) <= local)
|
|
101
|
+
i += 1;
|
|
102
|
+
const next = Math.min(i + 1, count - 1);
|
|
103
|
+
const span = (track.times[next] ?? 0) - (track.times[i] ?? 0);
|
|
104
|
+
const t = next === i || span <= 0 ? 0 : (local - (track.times[i] ?? 0)) / span;
|
|
105
|
+
const out = [0, 0, 0];
|
|
106
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
107
|
+
const a = track.values[i * 3 + axis] ?? 0;
|
|
108
|
+
const b = track.values[next * 3 + axis] ?? a;
|
|
109
|
+
const cycleOffset = loop && duration > 0 ? ((track.values[(count - 1) * 3 + axis] ?? 0) - (track.values[axis] ?? 0)) * cycle : 0;
|
|
110
|
+
out[axis] = a + (b - a) * t + cycleOffset;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
function clipTimeFor(time, duration, loop) {
|
|
115
|
+
if (!(duration > 0))
|
|
116
|
+
return 0;
|
|
117
|
+
if (loop)
|
|
118
|
+
return time % duration;
|
|
119
|
+
return Math.min(time, duration);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Headless animation state machine and blend evaluator. It owns every clip's playback time and weight, so the
|
|
123
|
+
* renderer only seeks and weights actions on a mixer, and headless hosts, replays, and tests advance the same
|
|
124
|
+
* graph without three.js. Transitions are data (parameter comparisons and consumed triggers), layers can be
|
|
125
|
+
* masked or additive, and events fire by clip time, including across loop wraps.
|
|
126
|
+
*
|
|
127
|
+
* @capability anim-graph data-first animation state machine with blend trees, crossfades, layers, and clip events
|
|
128
|
+
*/
|
|
129
|
+
export function createAnimGraphRuntime(initial) {
|
|
130
|
+
let graph = initial;
|
|
131
|
+
let triggers = new Set();
|
|
132
|
+
let layers = {};
|
|
133
|
+
function reset() {
|
|
134
|
+
layers = {};
|
|
135
|
+
for (const layer of graph.layers)
|
|
136
|
+
layers[layer.id] = { current: layer.entry, time: 0, transition: null };
|
|
137
|
+
}
|
|
138
|
+
reset();
|
|
139
|
+
function pickTransition(layer, state, params, normalized) {
|
|
140
|
+
for (const transition of layer.transitions) {
|
|
141
|
+
if (transition.from !== "*" && transition.from !== state.current)
|
|
142
|
+
continue;
|
|
143
|
+
if (transition.to === state.current && transition.from === "*")
|
|
144
|
+
continue;
|
|
145
|
+
if (transition.trigger !== undefined && !triggers.has(transition.trigger))
|
|
146
|
+
continue;
|
|
147
|
+
if (transition.exitTime !== undefined && normalized < transition.exitTime)
|
|
148
|
+
continue;
|
|
149
|
+
if (transition.when !== undefined && !transition.when.every((c) => compare(params[c.param], c.op, c.value)))
|
|
150
|
+
continue;
|
|
151
|
+
if (transition.trigger !== undefined)
|
|
152
|
+
triggers.delete(transition.trigger);
|
|
153
|
+
return transition;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function collectEvents(out, clip, before, after, duration, loop) {
|
|
158
|
+
if (graph.events === undefined || !(duration > 0))
|
|
159
|
+
return;
|
|
160
|
+
for (const event of graph.events) {
|
|
161
|
+
if (event.clip !== clip)
|
|
162
|
+
continue;
|
|
163
|
+
const at = event.atSec;
|
|
164
|
+
if (loop) {
|
|
165
|
+
const wrapped = after < before;
|
|
166
|
+
const hit = wrapped ? at > before || at <= after : at > before && at <= after;
|
|
167
|
+
if (hit)
|
|
168
|
+
out.push({ name: event.name, clip });
|
|
169
|
+
}
|
|
170
|
+
else if (at > before && at <= after) {
|
|
171
|
+
out.push({ name: event.name, clip });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
graph: () => graph,
|
|
177
|
+
retune(next) {
|
|
178
|
+
graph = next;
|
|
179
|
+
const previous = layers;
|
|
180
|
+
reset();
|
|
181
|
+
for (const [id, state] of Object.entries(previous)) {
|
|
182
|
+
const layer = graph.layers.find((l) => l.id === id);
|
|
183
|
+
if (layer !== undefined && layer.states[state.current] !== undefined)
|
|
184
|
+
layers[id] = state;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
state: () => ({ layers, triggers: [...triggers] }),
|
|
188
|
+
snapshot: () => ({ layers: structuredClone(layers), triggers: [...triggers] }),
|
|
189
|
+
restore(next) {
|
|
190
|
+
layers = structuredClone(next.layers);
|
|
191
|
+
triggers = new Set(next.triggers);
|
|
192
|
+
},
|
|
193
|
+
trigger(name) {
|
|
194
|
+
triggers.add(name);
|
|
195
|
+
},
|
|
196
|
+
stateOf: (layerId) => layers[layerId]?.current ?? null,
|
|
197
|
+
advance(dt, params, clips) {
|
|
198
|
+
const output = { clips: [], events: [] };
|
|
199
|
+
const rootDelta = [0, 0, 0];
|
|
200
|
+
let rootMotion = false;
|
|
201
|
+
for (const layer of graph.layers) {
|
|
202
|
+
const state = layers[layer.id];
|
|
203
|
+
if (state === undefined)
|
|
204
|
+
continue;
|
|
205
|
+
const def = layer.states[state.current];
|
|
206
|
+
if (def === undefined)
|
|
207
|
+
continue;
|
|
208
|
+
const layerWeight = layer.weight ?? 1;
|
|
209
|
+
if (def.rootMotion === true && layerWeight > 0)
|
|
210
|
+
rootMotion = true;
|
|
211
|
+
const before = state.time;
|
|
212
|
+
state.time += dt * stateSpeed(def);
|
|
213
|
+
const weights = stateClipWeights(def, params);
|
|
214
|
+
const duration = stateDuration(weights, clips);
|
|
215
|
+
const loop = stateLoops(def);
|
|
216
|
+
const normalized = duration > 0 ? (loop ? (state.time % duration) / duration : Math.min(1, state.time / duration)) : 1;
|
|
217
|
+
for (const clip of Object.keys(weights)) {
|
|
218
|
+
const duration = clipDuration(clips[clip]);
|
|
219
|
+
collectEvents(output.events, clip, clipTimeFor(before, duration, loop), clipTimeFor(state.time, duration, loop), duration, loop);
|
|
220
|
+
if (def.rootMotion) {
|
|
221
|
+
const info = clips[clip];
|
|
222
|
+
if (typeof info !== "number" && info?.rootTrack !== undefined) {
|
|
223
|
+
const from = rootPosition(info.rootTrack, before, duration, loop);
|
|
224
|
+
const to = rootPosition(info.rootTrack, state.time, duration, loop);
|
|
225
|
+
for (let axis = 0; axis < 3; axis += 1)
|
|
226
|
+
rootDelta[axis] += (to[axis] - from[axis]) * weights[clip] * layerWeight;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const transition = state.transition;
|
|
231
|
+
if (transition !== null) {
|
|
232
|
+
transition.elapsed += dt;
|
|
233
|
+
const t = transition.duration > 0 ? Math.min(1, transition.elapsed / transition.duration) : 1;
|
|
234
|
+
const merged = {};
|
|
235
|
+
const times = {};
|
|
236
|
+
for (const [clip, w] of Object.entries(transition.fromWeights)) {
|
|
237
|
+
merged[clip] = (merged[clip] ?? 0) + w * (1 - t);
|
|
238
|
+
times[clip] = transition.fromTimes[clip] ?? 0;
|
|
239
|
+
}
|
|
240
|
+
for (const [clip, w] of Object.entries(weights)) {
|
|
241
|
+
merged[clip] = (merged[clip] ?? 0) + w * t;
|
|
242
|
+
times[clip] = clipTimeFor(state.time, clipDuration(clips[clip]), loop);
|
|
243
|
+
}
|
|
244
|
+
for (const [clip, w] of Object.entries(merged)) {
|
|
245
|
+
if (w <= 0)
|
|
246
|
+
continue;
|
|
247
|
+
output.clips.push({ clip, weight: w * layerWeight, time: times[clip] ?? 0, layer: layer.id });
|
|
248
|
+
}
|
|
249
|
+
if (t >= 1)
|
|
250
|
+
state.transition = null;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
for (const [clip, w] of Object.entries(weights)) {
|
|
254
|
+
if (w <= 0)
|
|
255
|
+
continue;
|
|
256
|
+
output.clips.push({ clip, weight: w * layerWeight, time: clipTimeFor(state.time, clipDuration(clips[clip]), loop), layer: layer.id });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const next = pickTransition(layer, state, params, normalized);
|
|
260
|
+
if (next !== null && next.to !== state.current) {
|
|
261
|
+
const fromWeights = {};
|
|
262
|
+
const fromTimes = {};
|
|
263
|
+
for (const entry of output.clips) {
|
|
264
|
+
if (entry.layer !== layer.id)
|
|
265
|
+
continue;
|
|
266
|
+
fromWeights[entry.clip] = (fromWeights[entry.clip] ?? 0) + entry.weight / (layerWeight || 1);
|
|
267
|
+
fromTimes[entry.clip] = entry.time;
|
|
268
|
+
}
|
|
269
|
+
state.current = next.to;
|
|
270
|
+
state.time = 0;
|
|
271
|
+
state.transition = {
|
|
272
|
+
to: next.to,
|
|
273
|
+
elapsed: 0,
|
|
274
|
+
duration: next.duration ?? DEFAULT_FADE,
|
|
275
|
+
fromWeights,
|
|
276
|
+
fromTimes,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
else if (next !== null && next.to === state.current) {
|
|
280
|
+
state.time = 0;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
triggers.clear();
|
|
284
|
+
if (rootDelta[0] !== 0 || rootDelta[1] !== 0 || rootDelta[2] !== 0)
|
|
285
|
+
output.rootDelta = rootDelta;
|
|
286
|
+
if (rootMotion)
|
|
287
|
+
output.rootMotion = true;
|
|
288
|
+
return output;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const COMPARES = [">", "<", ">=", "<=", "==", "!="];
|
|
293
|
+
function record(value) {
|
|
294
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
295
|
+
}
|
|
296
|
+
function optionalNumber(value) {
|
|
297
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
298
|
+
}
|
|
299
|
+
function parseState(value) {
|
|
300
|
+
const raw = record(value);
|
|
301
|
+
if (raw === null)
|
|
302
|
+
return null;
|
|
303
|
+
const extra = {
|
|
304
|
+
...(optionalNumber(raw.speed) === undefined ? {} : { speed: raw.speed }),
|
|
305
|
+
...(typeof raw.loop === "boolean" ? { loop: raw.loop } : {}),
|
|
306
|
+
...(typeof raw.rootMotion === "boolean" ? { rootMotion: raw.rootMotion } : {}),
|
|
307
|
+
};
|
|
308
|
+
if (raw.kind === "clip" && typeof raw.clip === "string")
|
|
309
|
+
return { kind: "clip", clip: raw.clip, ...extra };
|
|
310
|
+
if (!Array.isArray(raw.points))
|
|
311
|
+
return null;
|
|
312
|
+
if (raw.kind === "blend1D" && typeof raw.param === "string") {
|
|
313
|
+
const points = raw.points.flatMap((point) => {
|
|
314
|
+
const entry = record(point);
|
|
315
|
+
return entry !== null && typeof entry.clip === "string" && optionalNumber(entry.at) !== undefined ? [{ at: entry.at, clip: entry.clip }] : [];
|
|
316
|
+
});
|
|
317
|
+
return { kind: "blend1D", param: raw.param, points, ...extra };
|
|
318
|
+
}
|
|
319
|
+
if (raw.kind === "blend2D" && Array.isArray(raw.params) && raw.params.length === 2 && raw.params.every((param) => typeof param === "string")) {
|
|
320
|
+
const points = raw.points.flatMap((point) => {
|
|
321
|
+
const entry = record(point);
|
|
322
|
+
const at = entry?.at;
|
|
323
|
+
return entry !== null && typeof entry.clip === "string" && Array.isArray(at) && at.length === 2 && at.every((n) => optionalNumber(n) !== undefined)
|
|
324
|
+
? [{ at: [at[0], at[1]], clip: entry.clip }]
|
|
325
|
+
: [];
|
|
326
|
+
});
|
|
327
|
+
return { kind: "blend2D", params: [raw.params[0], raw.params[1]], points, ...extra };
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
function parseTransition(value, states) {
|
|
332
|
+
const raw = record(value);
|
|
333
|
+
if (raw === null || typeof raw.from !== "string" || typeof raw.to !== "string")
|
|
334
|
+
return null;
|
|
335
|
+
if (states[raw.to] === undefined || (raw.from !== "*" && states[raw.from] === undefined))
|
|
336
|
+
return null;
|
|
337
|
+
const when = Array.isArray(raw.when)
|
|
338
|
+
? raw.when.flatMap((condition) => {
|
|
339
|
+
const entry = record(condition);
|
|
340
|
+
return entry !== null &&
|
|
341
|
+
typeof entry.param === "string" &&
|
|
342
|
+
COMPARES.includes(entry.op) &&
|
|
343
|
+
(typeof entry.value === "number" || typeof entry.value === "boolean")
|
|
344
|
+
? [{ param: entry.param, op: entry.op, value: entry.value }]
|
|
345
|
+
: [];
|
|
346
|
+
})
|
|
347
|
+
: undefined;
|
|
348
|
+
return {
|
|
349
|
+
from: raw.from,
|
|
350
|
+
to: raw.to,
|
|
351
|
+
...(when === undefined || when.length === 0 ? {} : { when }),
|
|
352
|
+
...(typeof raw.trigger === "string" ? { trigger: raw.trigger } : {}),
|
|
353
|
+
...(optionalNumber(raw.duration) === undefined ? {} : { duration: Math.max(0, raw.duration) }),
|
|
354
|
+
...(optionalNumber(raw.exitTime) === undefined ? {} : { exitTime: raw.exitTime }),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Validates untrusted JSON (a saved scene document, a network payload) as an {@link AnimGraph}.
|
|
359
|
+
* Malformed states, transitions to unknown states, and bad conditions are dropped; a layer whose
|
|
360
|
+
* entry state is missing is dropped; the result is `undefined` when no layer survives.
|
|
361
|
+
*
|
|
362
|
+
* @capability anim-graph load an animation graph from saved JSON without trusting its shape
|
|
363
|
+
*/
|
|
364
|
+
export function parseAnimGraph(value) {
|
|
365
|
+
const raw = record(value);
|
|
366
|
+
if (raw === null || !Array.isArray(raw.layers))
|
|
367
|
+
return undefined;
|
|
368
|
+
const layers = [];
|
|
369
|
+
const seen = new Set();
|
|
370
|
+
for (const candidate of raw.layers) {
|
|
371
|
+
const layer = record(candidate);
|
|
372
|
+
if (layer === null || typeof layer.id !== "string" || seen.has(layer.id) || typeof layer.entry !== "string")
|
|
373
|
+
continue;
|
|
374
|
+
const rawStates = record(layer.states);
|
|
375
|
+
if (rawStates === null)
|
|
376
|
+
continue;
|
|
377
|
+
const states = {};
|
|
378
|
+
for (const [name, state] of Object.entries(rawStates)) {
|
|
379
|
+
const parsed = parseState(state);
|
|
380
|
+
if (parsed !== null)
|
|
381
|
+
states[name] = parsed;
|
|
382
|
+
}
|
|
383
|
+
if (states[layer.entry] === undefined)
|
|
384
|
+
continue;
|
|
385
|
+
const transitions = Array.isArray(layer.transitions)
|
|
386
|
+
? layer.transitions.flatMap((transition) => {
|
|
387
|
+
const parsed = parseTransition(transition, states);
|
|
388
|
+
return parsed === null ? [] : [parsed];
|
|
389
|
+
})
|
|
390
|
+
: [];
|
|
391
|
+
const mask = Array.isArray(layer.mask) ? layer.mask.filter((prefix) => typeof prefix === "string") : undefined;
|
|
392
|
+
seen.add(layer.id);
|
|
393
|
+
layers.push({
|
|
394
|
+
id: layer.id,
|
|
395
|
+
entry: layer.entry,
|
|
396
|
+
states,
|
|
397
|
+
transitions,
|
|
398
|
+
...(mask === undefined ? {} : { mask }),
|
|
399
|
+
...(typeof layer.additive === "boolean" ? { additive: layer.additive } : {}),
|
|
400
|
+
...(optionalNumber(layer.weight) === undefined ? {} : { weight: Math.min(1, Math.max(0, layer.weight)) }),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (layers.length === 0)
|
|
404
|
+
return undefined;
|
|
405
|
+
const events = Array.isArray(raw.events)
|
|
406
|
+
? raw.events.flatMap((event) => {
|
|
407
|
+
const entry = record(event);
|
|
408
|
+
return entry !== null && typeof entry.clip === "string" && typeof entry.name === "string" && optionalNumber(entry.atSec) !== undefined
|
|
409
|
+
? [{ clip: entry.clip, atSec: entry.atSec, name: entry.name }]
|
|
410
|
+
: [];
|
|
411
|
+
})
|
|
412
|
+
: [];
|
|
413
|
+
return { layers, ...(events.length === 0 ? {} : { events }) };
|
|
414
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** A clip to bake: its name and length in seconds. */
|
|
2
|
+
export interface BoneTextureClipInput {
|
|
3
|
+
name: string;
|
|
4
|
+
duration: number;
|
|
5
|
+
}
|
|
6
|
+
/** One baked clip's rows in a {@link BoneTextureLayout}. */
|
|
7
|
+
export interface BoneTextureClip {
|
|
8
|
+
name: string;
|
|
9
|
+
/** First texture row of the clip. */
|
|
10
|
+
startFrame: number;
|
|
11
|
+
/** Rows baked for the clip, first and last pose included. */
|
|
12
|
+
frameCount: number;
|
|
13
|
+
duration: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Where every bone matrix of every baked frame lives in a vertex-animation texture: one row per
|
|
17
|
+
* frame, four RGBA texels (matrix columns) per bone. Plain data, so a baked crowd can be cached,
|
|
18
|
+
* shipped or checked in a test without a renderer.
|
|
19
|
+
*/
|
|
20
|
+
export interface BoneTextureLayout {
|
|
21
|
+
boneCount: number;
|
|
22
|
+
fps: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
clips: BoneTextureClip[];
|
|
26
|
+
}
|
|
27
|
+
/** Inputs for {@link planBoneTexture}. */
|
|
28
|
+
export interface BoneTextureInput {
|
|
29
|
+
boneCount: number;
|
|
30
|
+
clips: readonly BoneTextureClipInput[];
|
|
31
|
+
/** Baked samples per second. Default 30. */
|
|
32
|
+
fps?: number;
|
|
33
|
+
/** Largest texture side the target GPU accepts. Default 4096. */
|
|
34
|
+
maxTextureSize?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Plans a bone-matrix texture for a rig's clips. Throws when the rig or the clip set does not fit
|
|
38
|
+
* `maxTextureSize`; lower `fps` or bake fewer clips.
|
|
39
|
+
*
|
|
40
|
+
* @capability animation bake many clips of a rig into one bone-matrix texture for instanced crowds
|
|
41
|
+
*/
|
|
42
|
+
export declare function planBoneTexture(input: BoneTextureInput): BoneTextureLayout;
|
|
43
|
+
/** Two texture rows around a playback time and the blend between them. */
|
|
44
|
+
export interface BoneTextureSample {
|
|
45
|
+
rowA: number;
|
|
46
|
+
rowB: number;
|
|
47
|
+
t: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Finds the baked rows around `time` in a clip, wrapping when `loop` and holding the last pose
|
|
51
|
+
* otherwise. This is the CPU mirror of the crowd shader's lookup.
|
|
52
|
+
*
|
|
53
|
+
* @capability animation look up the baked frames and blend for a crowd member's playback time
|
|
54
|
+
*/
|
|
55
|
+
export declare function sampleBoneTexture(clip: BoneTextureClip, fps: number, time: number, loop: boolean, out?: BoneTextureSample): BoneTextureSample;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plans a bone-matrix texture for a rig's clips. Throws when the rig or the clip set does not fit
|
|
3
|
+
* `maxTextureSize`; lower `fps` or bake fewer clips.
|
|
4
|
+
*
|
|
5
|
+
* @capability animation bake many clips of a rig into one bone-matrix texture for instanced crowds
|
|
6
|
+
*/
|
|
7
|
+
export function planBoneTexture(input) {
|
|
8
|
+
const fps = input.fps ?? 30;
|
|
9
|
+
const maxSize = input.maxTextureSize ?? 4096;
|
|
10
|
+
if (!(fps > 0))
|
|
11
|
+
throw new Error(`planBoneTexture: fps must be positive, got ${fps}`);
|
|
12
|
+
const width = Math.max(1, input.boneCount) * 4;
|
|
13
|
+
if (width > maxSize)
|
|
14
|
+
throw new Error(`planBoneTexture: ${input.boneCount} bones need ${width} texels per row, over the ${maxSize} limit`);
|
|
15
|
+
const clips = [];
|
|
16
|
+
let row = 0;
|
|
17
|
+
for (const clip of input.clips) {
|
|
18
|
+
const duration = Math.max(0, clip.duration);
|
|
19
|
+
const frameCount = Math.max(2, Math.ceil(duration * fps) + 1);
|
|
20
|
+
clips.push({ name: clip.name, startFrame: row, frameCount, duration });
|
|
21
|
+
row += frameCount;
|
|
22
|
+
}
|
|
23
|
+
if (row > maxSize)
|
|
24
|
+
throw new Error(`planBoneTexture: ${row} frames exceed the ${maxSize}-row limit; lower fps or bake fewer clips`);
|
|
25
|
+
return { boneCount: input.boneCount, fps, width, height: Math.max(1, row), clips };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Finds the baked rows around `time` in a clip, wrapping when `loop` and holding the last pose
|
|
29
|
+
* otherwise. This is the CPU mirror of the crowd shader's lookup.
|
|
30
|
+
*
|
|
31
|
+
* @capability animation look up the baked frames and blend for a crowd member's playback time
|
|
32
|
+
*/
|
|
33
|
+
export function sampleBoneTexture(clip, fps, time, loop, out) {
|
|
34
|
+
const result = out ?? { rowA: 0, rowB: 0, t: 0 };
|
|
35
|
+
const span = clip.frameCount - 1;
|
|
36
|
+
const raw = time * fps;
|
|
37
|
+
const frame = loop ? ((raw % span) + span) % span : Math.min(Math.max(raw, 0), span);
|
|
38
|
+
const a = Math.floor(frame);
|
|
39
|
+
result.rowA = clip.startFrame + a;
|
|
40
|
+
result.rowB = clip.startFrame + Math.min(a + 1, span);
|
|
41
|
+
result.t = frame - a;
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** One foot's animated ankle height and the ground found under it, both in world units. */
|
|
2
|
+
export interface FootGroundSample {
|
|
3
|
+
/** Animated ankle (foot bone) height this frame, before any correction. */
|
|
4
|
+
ankleY: number;
|
|
5
|
+
/** Ground height under the foot, or `null` when the probe found nothing. */
|
|
6
|
+
groundY: number | null;
|
|
7
|
+
}
|
|
8
|
+
/** Inputs for {@link placeFeet}. All heights are world units. */
|
|
9
|
+
export interface FootPlacementInput {
|
|
10
|
+
feet: readonly FootGroundSample[];
|
|
11
|
+
/** Height of the model origin, which the clips were authored to stand on. */
|
|
12
|
+
originY: number;
|
|
13
|
+
/** Ankle height above the sole in the rig's rest pose; the sole never ends below the ground. */
|
|
14
|
+
ankleHeight: number;
|
|
15
|
+
/** Largest correction applied to a foot or the pelvis, up or down. */
|
|
16
|
+
maxAdjust: number;
|
|
17
|
+
/** A lowest sole this far above its ground reads as airborne. */
|
|
18
|
+
airborneGap: number;
|
|
19
|
+
}
|
|
20
|
+
/** Result of {@link placeFeet}: where each ankle should go and how far the pelvis drops. */
|
|
21
|
+
export interface FootPlacement {
|
|
22
|
+
/** Vertical pelvis shift, `<= 0`, so the lowest planted foot can reach its ground. */
|
|
23
|
+
pelvisOffset: number;
|
|
24
|
+
/** Target ankle height per foot, or `null` when that foot has no ground or no correction. */
|
|
25
|
+
ankleTargets: (number | null)[];
|
|
26
|
+
/** `false` while the feet are clear of the ground, so callers fade the correction out. */
|
|
27
|
+
grounded: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolves foot placement for a rig whose clips were authored on flat ground at its origin.
|
|
31
|
+
* Each foot keeps its animated lift and moves by its ground's height above or below the origin,
|
|
32
|
+
* the sole is never left under the ground, and the pelvis drops by the deepest reach so both legs
|
|
33
|
+
* stay within length. Pure and allocation-light; the renderer applies the targets with
|
|
34
|
+
* {@link solveTwoBone}.
|
|
35
|
+
*
|
|
36
|
+
* @capability animation ground rigged feet on slopes and steps without hiding the swing leg
|
|
37
|
+
*/
|
|
38
|
+
export declare function placeFeet(input: FootPlacementInput, out?: FootPlacement): FootPlacement;
|
|
39
|
+
/** A bone as {@link inferLegChains} sees it: its name and its parent's name. */
|
|
40
|
+
export interface RigBone {
|
|
41
|
+
name: string;
|
|
42
|
+
parent: string | null;
|
|
43
|
+
}
|
|
44
|
+
/** Thigh → shin → foot bone names for one leg. */
|
|
45
|
+
export interface LegChain {
|
|
46
|
+
root: string;
|
|
47
|
+
mid: string;
|
|
48
|
+
tip: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Finds leg chains on a humanoid rig by bone name: a thigh-like bone (`UpLeg`, `UpperLeg`, `Thigh`)
|
|
52
|
+
* with its first child and grandchild as shin and foot. Control, IK-target and twist bones are
|
|
53
|
+
* skipped. Returns chains in rig order; an empty array means the rig has no recognizable legs.
|
|
54
|
+
*
|
|
55
|
+
* @capability animation find thigh, shin and foot bones on a humanoid rig for foot IK
|
|
56
|
+
*/
|
|
57
|
+
export declare function inferLegChains(bones: readonly RigBone[]): LegChain[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function clamp(value, limit) {
|
|
2
|
+
return Math.max(-limit, Math.min(limit, value));
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Resolves foot placement for a rig whose clips were authored on flat ground at its origin.
|
|
6
|
+
* Each foot keeps its animated lift and moves by its ground's height above or below the origin,
|
|
7
|
+
* the sole is never left under the ground, and the pelvis drops by the deepest reach so both legs
|
|
8
|
+
* stay within length. Pure and allocation-light; the renderer applies the targets with
|
|
9
|
+
* {@link solveTwoBone}.
|
|
10
|
+
*
|
|
11
|
+
* @capability animation ground rigged feet on slopes and steps without hiding the swing leg
|
|
12
|
+
*/
|
|
13
|
+
export function placeFeet(input, out) {
|
|
14
|
+
const result = out ?? { pelvisOffset: 0, ankleTargets: [], grounded: false };
|
|
15
|
+
result.ankleTargets.length = input.feet.length;
|
|
16
|
+
const limit = Math.max(0, input.maxAdjust);
|
|
17
|
+
let lowestGap = Infinity;
|
|
18
|
+
let pelvis = 0;
|
|
19
|
+
for (let i = 0; i < input.feet.length; i += 1) {
|
|
20
|
+
const foot = input.feet[i];
|
|
21
|
+
if (foot.groundY === null) {
|
|
22
|
+
result.ankleTargets[i] = null;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
lowestGap = Math.min(lowestGap, foot.ankleY - input.ankleHeight - foot.groundY);
|
|
26
|
+
const followed = foot.ankleY + clamp(foot.groundY - input.originY, limit);
|
|
27
|
+
const target = Math.min(foot.ankleY + limit, Math.max(followed, foot.groundY + input.ankleHeight));
|
|
28
|
+
result.ankleTargets[i] = target;
|
|
29
|
+
pelvis = Math.min(pelvis, target - foot.ankleY);
|
|
30
|
+
}
|
|
31
|
+
result.grounded = lowestGap <= input.airborneGap;
|
|
32
|
+
result.pelvisOffset = result.grounded ? Math.max(-limit, pelvis) : 0;
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
const THIGH = /(upleg|upperleg|thigh|femur)/;
|
|
36
|
+
const HELPER = /(ik|control|ctrl|twist|pole|target|roll)/;
|
|
37
|
+
/**
|
|
38
|
+
* Finds leg chains on a humanoid rig by bone name: a thigh-like bone (`UpLeg`, `UpperLeg`, `Thigh`)
|
|
39
|
+
* with its first child and grandchild as shin and foot. Control, IK-target and twist bones are
|
|
40
|
+
* skipped. Returns chains in rig order; an empty array means the rig has no recognizable legs.
|
|
41
|
+
*
|
|
42
|
+
* @capability animation find thigh, shin and foot bones on a humanoid rig for foot IK
|
|
43
|
+
*/
|
|
44
|
+
export function inferLegChains(bones) {
|
|
45
|
+
const children = new Map();
|
|
46
|
+
for (const bone of bones) {
|
|
47
|
+
if (bone.parent === null)
|
|
48
|
+
continue;
|
|
49
|
+
const list = children.get(bone.parent);
|
|
50
|
+
if (list === undefined)
|
|
51
|
+
children.set(bone.parent, [bone.name]);
|
|
52
|
+
else
|
|
53
|
+
list.push(bone.name);
|
|
54
|
+
}
|
|
55
|
+
const firstChild = (name) => children.get(name)?.find((child) => !HELPER.test(child.toLowerCase()));
|
|
56
|
+
const chains = [];
|
|
57
|
+
for (const bone of bones) {
|
|
58
|
+
const key = bone.name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
59
|
+
if (!THIGH.test(key) || HELPER.test(key))
|
|
60
|
+
continue;
|
|
61
|
+
const mid = firstChild(bone.name);
|
|
62
|
+
const tip = mid === undefined ? undefined : firstChild(mid);
|
|
63
|
+
if (mid !== undefined && tip !== undefined)
|
|
64
|
+
chains.push({ root: bone.name, mid, tip });
|
|
65
|
+
}
|
|
66
|
+
return chains;
|
|
67
|
+
}
|