@ikijs/engine 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/index.d.mts +353 -0
- package/dist/index.d.ts +353 -0
- package/dist/index.js +1420 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1384 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zeikar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @ikijs/engine
|
|
2
|
+
|
|
3
|
+
WebGL2 runtime that plays a [`.iki`](../format) puppet model in the browser.
|
|
4
|
+
|
|
5
|
+
The engine is **host-agnostic**: it depends only on
|
|
6
|
+
[`@ikijs/format`](../format) and knows nothing about any particular app. A host
|
|
7
|
+
drives it by setting parameters (from lip-sync, gaze, blink, expressions); the
|
|
8
|
+
engine renders the result each frame.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @ikijs/engine @ikijs/format
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { IkiPlayer } from "@ikijs/engine";
|
|
20
|
+
import { loadIkiModel, StandardParameter } from "@ikijs/format";
|
|
21
|
+
|
|
22
|
+
const player = new IkiPlayer(canvas); // HTMLCanvasElement
|
|
23
|
+
const result = await player.load(loadIkiModel(json));
|
|
24
|
+
if (result.failedTextures.length > 0) {
|
|
25
|
+
console.warn("some textures failed", result.failedTextures);
|
|
26
|
+
}
|
|
27
|
+
player.start();
|
|
28
|
+
|
|
29
|
+
player.setParameter(StandardParameter.MouthOpen, 0.7);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`load()` decodes and uploads every texture before swapping the model in, so a
|
|
33
|
+
frame is never half-textured. Parameter writes are clamped to the declared
|
|
34
|
+
range; unknown ids and non-finite values are ignored.
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
| Export | What it is |
|
|
39
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `IkiPlayer` | The renderer: `load` / `start` / `stop` / `setParameter` / `getParameter` / `getParameters` / `destroy` |
|
|
41
|
+
| `IkiLoadResult` | `{ failedTextures, superseded }` returned by `load()` |
|
|
42
|
+
| `ParameterStore` | The clamped parameter map the player drives |
|
|
43
|
+
| `IdleMotion` | Auto-blink / breath / gaze-drift driver |
|
|
44
|
+
| `PhysicsMotion` | Spring-mass-damper secondary motion (`model.physics`) |
|
|
45
|
+
| `HairChainMotion` | Multi-segment angular chain with gravity (`model.physicsChains`) |
|
|
46
|
+
| `translate` `rotate` `scale` `multiply` `toMat3` | The 2D affine helpers the engine itself uses |
|
|
47
|
+
|
|
48
|
+
## Motion drivers
|
|
49
|
+
|
|
50
|
+
`IdleMotion`, `PhysicsMotion`, and `HairChainMotion` are **peer drivers**, not
|
|
51
|
+
part of the render loop: each is a pure-logic object you `update(nowMs)` once
|
|
52
|
+
per frame, and each writes through a sink you supply. That keeps them testable
|
|
53
|
+
and lets a host override or omit any of them.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { HairChainMotion, IdleMotion, PhysicsMotion } from "@ikijs/engine";
|
|
57
|
+
|
|
58
|
+
// The drivers read the live pose and write the next one, both through the
|
|
59
|
+
// player — no host-side copy of the parameter state to keep in sync.
|
|
60
|
+
const drive = (id: string, value: number) => player.setParameter(id, value);
|
|
61
|
+
const read = (id: string) => player.getParameter(id);
|
|
62
|
+
|
|
63
|
+
const idle = new IdleMotion(drive);
|
|
64
|
+
const physics = new PhysicsMotion(
|
|
65
|
+
model.physics ?? [],
|
|
66
|
+
model.parameters,
|
|
67
|
+
read,
|
|
68
|
+
drive,
|
|
69
|
+
);
|
|
70
|
+
const chains = new HairChainMotion(
|
|
71
|
+
model.physicsChains ?? [],
|
|
72
|
+
model.parameters,
|
|
73
|
+
model.deformers ?? [],
|
|
74
|
+
read,
|
|
75
|
+
drive,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const tick = (now: number) => {
|
|
79
|
+
idle.update(now);
|
|
80
|
+
physics.update(now); // reads what idle just wrote
|
|
81
|
+
chains.update(now);
|
|
82
|
+
requestAnimationFrame(tick);
|
|
83
|
+
};
|
|
84
|
+
requestAnimationFrame(tick);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Both physics drivers integrate on a fixed 1/60 s sub-step with a clamped frame
|
|
88
|
+
delta, so a backgrounded tab or a long hitch cannot snap the rig.
|
|
89
|
+
|
|
90
|
+
## Rendering notes
|
|
91
|
+
|
|
92
|
+
- The whole pipeline is **premultiplied alpha** — the canvas is created with
|
|
93
|
+
`premultipliedAlpha: true` and the shader premultiplies before blending.
|
|
94
|
+
- Clipping masks use the stencil buffer. If the context grants no stencil, the
|
|
95
|
+
affected parts render unclipped and `load()` logs it.
|
|
96
|
+
- Textures are decoded from `data:` URIs only; external URLs are skipped with a
|
|
97
|
+
warning (a resolver is not part of v1).
|
|
98
|
+
- Atlas authors should pad / extrude sub-rect borders to avoid LINEAR-filter
|
|
99
|
+
bleeding.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT © Zeikar
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { IkiModel, IkiParameter, IkiPhysics, IkiPhysicsChain, IkiDeformer } from '@ikijs/format';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Outcome of {@link IkiPlayer.load}: the indices into `model.textures` that
|
|
5
|
+
* failed to decode or upload (empty = every declared texture loaded). The model
|
|
6
|
+
* is still swapped in and rendered; parts using a failed texture are skipped.
|
|
7
|
+
* A host can inspect this to detect and report a partial load.
|
|
8
|
+
*/
|
|
9
|
+
interface IkiLoadResult {
|
|
10
|
+
failedTextures: number[];
|
|
11
|
+
/**
|
|
12
|
+
* True when a newer `load()` (or `destroy()`) superseded this call before it
|
|
13
|
+
* adopted anything — the model was NOT loaded and `failedTextures` is empty
|
|
14
|
+
* because nothing was attempted, not because everything succeeded. Without
|
|
15
|
+
* this flag a caller awaiting the losing promise cannot tell the two apart.
|
|
16
|
+
*/
|
|
17
|
+
superseded: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Drives a single `.iki` model on a WebGL2 canvas.
|
|
21
|
+
*
|
|
22
|
+
* v1 scope: parts are solid-color or atlas-sampled textured quads or meshes,
|
|
23
|
+
* transformed each frame by their base transform plus the sum of their parameter
|
|
24
|
+
* bindings. `load()` is async — it decodes and uploads textures before swapping
|
|
25
|
+
* the model in. Mesh parts additionally carry per-vertex UV and optional warp
|
|
26
|
+
* keyforms, interpolated each frame on the CPU into a dynamic vertex buffer.
|
|
27
|
+
*/
|
|
28
|
+
declare class IkiPlayer {
|
|
29
|
+
private readonly canvas;
|
|
30
|
+
private readonly gl;
|
|
31
|
+
private readonly program;
|
|
32
|
+
private readonly quad;
|
|
33
|
+
private readonly uMatrix;
|
|
34
|
+
private readonly uColor;
|
|
35
|
+
private readonly uUseTexture;
|
|
36
|
+
private readonly uTex;
|
|
37
|
+
private readonly uUvOffset;
|
|
38
|
+
private readonly uUvScale;
|
|
39
|
+
private readonly uUseMeshUv;
|
|
40
|
+
private readonly uAlphaCutoff;
|
|
41
|
+
private readonly aPos;
|
|
42
|
+
private readonly aUv;
|
|
43
|
+
/** True when the context granted a stencil buffer; clipping needs it. */
|
|
44
|
+
private readonly stencilAvailable;
|
|
45
|
+
private model?;
|
|
46
|
+
private parts;
|
|
47
|
+
private params;
|
|
48
|
+
private rafId?;
|
|
49
|
+
/** Uploaded textures, index-aligned with `model.textures`; `null` = unusable. */
|
|
50
|
+
private textures;
|
|
51
|
+
/** Bumped by every `load` and by `destroy`; lets a stale async load bail. */
|
|
52
|
+
private loadGeneration;
|
|
53
|
+
private destroyed;
|
|
54
|
+
/**
|
|
55
|
+
* Engine-internal mesh buffers, keyed by the part's INDEX in `this.parts`
|
|
56
|
+
* (NOT by part id — duplicate ids must not swap buffers).
|
|
57
|
+
*/
|
|
58
|
+
private partMeshes;
|
|
59
|
+
/**
|
|
60
|
+
* Clip groups resolved once per `load()`: consumer part index (into `this.parts`)
|
|
61
|
+
* → its mask part indices. A part absent from this map is unclipped.
|
|
62
|
+
*/
|
|
63
|
+
private partClipGroups;
|
|
64
|
+
constructor(canvas: HTMLCanvasElement);
|
|
65
|
+
/**
|
|
66
|
+
* Load a model and reset parameters to their defaults. All textures are
|
|
67
|
+
* decoded and uploaded before the model is swapped in — the swap is atomic,
|
|
68
|
+
* so you never see a partially-textured frame. `start()` may be called any
|
|
69
|
+
* time, but nothing renders until the first `load()` resolves. For an
|
|
70
|
+
* embedded `data:` atlas this is near-instant.
|
|
71
|
+
*
|
|
72
|
+
* Individual texture decode/upload failures are non-fatal: they are logged
|
|
73
|
+
* via `console.error`, the affected parts are skipped, and `load()` still
|
|
74
|
+
* resolves — the returned {@link IkiLoadResult} lists the indices of any
|
|
75
|
+
* textures that failed, so a host can detect and report a partial load. The
|
|
76
|
+
* model is assumed already validated by `@ikijs/format`.
|
|
77
|
+
*
|
|
78
|
+
* Mesh buffer allocation failure IS fatal (unlike per-texture skip) because
|
|
79
|
+
* textures have an `IkiLoadResult.failedTextures` reporting surface and mesh
|
|
80
|
+
* buffers have none — there is no partial-mesh concept in the format.
|
|
81
|
+
*/
|
|
82
|
+
load(model: IkiModel): Promise<IkiLoadResult>;
|
|
83
|
+
/**
|
|
84
|
+
* Start the render loop. Safe to call more than once, and a no-op after
|
|
85
|
+
* {@link destroy} — the program and buffers the loop draws with are gone, so
|
|
86
|
+
* restarting would only spray GL errors.
|
|
87
|
+
*/
|
|
88
|
+
start(): void;
|
|
89
|
+
stop(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Set a parameter value (clamped to its range). Unknown ids and non-finite
|
|
92
|
+
* values are ignored — see {@link ParameterStore.set}.
|
|
93
|
+
*/
|
|
94
|
+
setParameter(id: string, value: number): void;
|
|
95
|
+
/**
|
|
96
|
+
* Current value of a parameter, or 0 for an unknown id.
|
|
97
|
+
*
|
|
98
|
+
* Hosts need this to avoid shadowing the engine's state: the motion drivers
|
|
99
|
+
* read the live pose to compute the next one, and without a read accessor
|
|
100
|
+
* every host has to keep its own mirror of what it last wrote — and keep that
|
|
101
|
+
* mirror's clamping in step with {@link ParameterStore} by hand.
|
|
102
|
+
*/
|
|
103
|
+
getParameter(id: string): number;
|
|
104
|
+
/** The model's parameter descriptors, for building UI or host wiring. */
|
|
105
|
+
getParameters(): IkiParameter[];
|
|
106
|
+
destroy(): void;
|
|
107
|
+
private renderFrame;
|
|
108
|
+
/**
|
|
109
|
+
* Draw a clipped part: stencil the union of its masks' alpha coverage, then
|
|
110
|
+
* draw the part only where the stencil was written. The mask parts also draw
|
|
111
|
+
* normally in their own `order` slot — this is an EXTRA, color-free pass over
|
|
112
|
+
* the same per-frame deformed geometry. All stencil/colorMask state the pass
|
|
113
|
+
* touches is restored before returning so later parts are unaffected.
|
|
114
|
+
*/
|
|
115
|
+
private drawClipped;
|
|
116
|
+
/**
|
|
117
|
+
* Draw a single part with its full per-part material + geometry state. Shared
|
|
118
|
+
* by the normal pass, the stencil mask-write pass, and the masked consumer
|
|
119
|
+
* draw — so every path prepares the SAME complete uniform/texture/VBO state
|
|
120
|
+
* (the caller only sets stencil/colorMask/u_alphaCutoff around it).
|
|
121
|
+
*/
|
|
122
|
+
private drawPart;
|
|
123
|
+
/** Resolve a part's effective transform from its base plus active bindings. */
|
|
124
|
+
private evaluate;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Holds the live value of every model parameter, clamped to its declared
|
|
129
|
+
* range. This is the single surface a host drives (lip-sync, gaze, blink) and
|
|
130
|
+
* the engine reads each frame to evaluate bindings.
|
|
131
|
+
*/
|
|
132
|
+
declare class ParameterStore {
|
|
133
|
+
private readonly params;
|
|
134
|
+
private readonly values;
|
|
135
|
+
/**
|
|
136
|
+
* Resting value per id: the declared default clamped into range, resolved
|
|
137
|
+
* ONCE here so `reset()` is a straight copy and a malformed descriptor is
|
|
138
|
+
* reported once rather than on every reset.
|
|
139
|
+
*/
|
|
140
|
+
private readonly defaults;
|
|
141
|
+
constructor(parameters: IkiParameter[]);
|
|
142
|
+
/**
|
|
143
|
+
* Set a parameter's value, clamped to its range. Unknown ids are ignored, as
|
|
144
|
+
* are non-finite values: this is the boundary a host drives with live signals,
|
|
145
|
+
* and `clamp` cannot filter NaN (`Math.max(min, Math.min(max, NaN))` is NaN),
|
|
146
|
+
* so one bad lip-sync/gaze frame would otherwise poison every binding that
|
|
147
|
+
* reads the parameter. A dropped write holds the last good pose.
|
|
148
|
+
*/
|
|
149
|
+
set(id: string, value: number): void;
|
|
150
|
+
/** Current value, or 0 if the id is unknown. */
|
|
151
|
+
get(id: string): number;
|
|
152
|
+
/** Position of a parameter within its range, 0..1. */
|
|
153
|
+
normalized(id: string): number;
|
|
154
|
+
/** Reset every parameter to its resting value (see `defaults`). */
|
|
155
|
+
reset(): void;
|
|
156
|
+
list(): IkiParameter[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type Affine = [number, number, number, number, number, number];
|
|
160
|
+
declare function translate(tx: number, ty: number): Affine;
|
|
161
|
+
declare function scale(sx: number, sy: number): Affine;
|
|
162
|
+
declare function rotate(degrees: number): Affine;
|
|
163
|
+
declare function multiply(a: Affine, b: Affine): Affine;
|
|
164
|
+
/** Expand a 2D affine into a column-major mat3 for `uniformMatrix3fv`. */
|
|
165
|
+
declare function toMat3(a: Affine): Float32Array;
|
|
166
|
+
|
|
167
|
+
interface IdleMotionOptions {
|
|
168
|
+
/** Inject a deterministic rng for testing. Defaults to Math.random. */
|
|
169
|
+
rng?: () => number;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Pure-logic idle-animation driver. Animates the seven "life" parameters
|
|
173
|
+
* (eyes, breath, gaze, head sway) on an internal clock so tab-backgrounding
|
|
174
|
+
* or irregular frame delivery can't produce teleports or snap-close blinks.
|
|
175
|
+
*
|
|
176
|
+
* Usage:
|
|
177
|
+
* const idle = new IdleMotion(player.setParameter.bind(player));
|
|
178
|
+
* // inside your rAF loop:
|
|
179
|
+
* idle.update(performance.now());
|
|
180
|
+
*
|
|
181
|
+
* The host is responsible for scheduling; this class has no timers or rAF.
|
|
182
|
+
*/
|
|
183
|
+
declare class IdleMotion {
|
|
184
|
+
private readonly sink;
|
|
185
|
+
private readonly rng;
|
|
186
|
+
private clockMs;
|
|
187
|
+
private prevNowMs;
|
|
188
|
+
private nextBlinkAtMs;
|
|
189
|
+
private blinkStartMs;
|
|
190
|
+
private gazeCurrentX;
|
|
191
|
+
private gazeCurrentY;
|
|
192
|
+
private gazeTargetX;
|
|
193
|
+
private gazeTargetY;
|
|
194
|
+
private nextGazeRetargetMs;
|
|
195
|
+
constructor(sink: (id: string, value: number) => void, options?: IdleMotionOptions);
|
|
196
|
+
/**
|
|
197
|
+
* Advance the idle animation to the given wall-clock timestamp (milliseconds).
|
|
198
|
+
*
|
|
199
|
+
* On the first call: record prevNowMs, emit the resting pose, and return —
|
|
200
|
+
* no animation advance happens so there is no jump from time 0.
|
|
201
|
+
*
|
|
202
|
+
* On subsequent calls: compute dt = clamp(nowMs - prevNowMs, 0, MAX_DT_MS)
|
|
203
|
+
* and advance the internal clock by dt. A non-monotonic nowMs produces a
|
|
204
|
+
* negative raw delta that the clamp floors to 0 — no rewind.
|
|
205
|
+
*/
|
|
206
|
+
update(nowMs: number): void;
|
|
207
|
+
private emitRestingPose;
|
|
208
|
+
/** Returns the current eye-open value (0..1) and advances blink state. */
|
|
209
|
+
private advanceBlink;
|
|
210
|
+
private advanceBreath;
|
|
211
|
+
/** Horizontal head sway in degrees, pure function of the internal clock. */
|
|
212
|
+
private swayX;
|
|
213
|
+
/** Vertical head sway in degrees, pure function of the internal clock. */
|
|
214
|
+
private swayY;
|
|
215
|
+
/** Ease gaze current toward target; pick a new target on the internal clock. */
|
|
216
|
+
private advanceGaze;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Host-agnostic 1D spring-mass-damper secondary-motion driver — the physics
|
|
221
|
+
* peer of {@link IdleMotion}. For each rig it reads the input parameter,
|
|
222
|
+
* signed-normalizes it around the input's default × `weight` to form a spring
|
|
223
|
+
* target, integrates a lagging spring position with semi-implicit (symplectic)
|
|
224
|
+
* Euler on a fixed 1/60s sub-step accumulator, and writes
|
|
225
|
+
* `outputDefault + x * scale` onto the output parameter — so the output lags
|
|
226
|
+
* and overshoots the input (hair/accessory sway).
|
|
227
|
+
*
|
|
228
|
+
* Usage:
|
|
229
|
+
* const physics = new PhysicsMotion(
|
|
230
|
+
* model.physics ?? [],
|
|
231
|
+
* model.parameters,
|
|
232
|
+
* (id) => currentValue(id),
|
|
233
|
+
* player.setParameter.bind(player),
|
|
234
|
+
* );
|
|
235
|
+
* // inside your rAF loop, right AFTER idle.update(now):
|
|
236
|
+
* physics.update(performance.now());
|
|
237
|
+
*
|
|
238
|
+
* The host schedules updates; this class has no timers, rAF, DOM, or Date.now.
|
|
239
|
+
* Writes go through the sink, exactly like IdleMotion; the player renders the
|
|
240
|
+
* updated params on its own render loop (drivers and rendering are decoupled).
|
|
241
|
+
*/
|
|
242
|
+
declare class PhysicsMotion {
|
|
243
|
+
private readonly rigs;
|
|
244
|
+
private readonly read;
|
|
245
|
+
private readonly sink;
|
|
246
|
+
private readonly params;
|
|
247
|
+
private readonly state;
|
|
248
|
+
private readonly clock;
|
|
249
|
+
constructor(rigs: IkiPhysics[], params: IkiParameter[], read: (id: string) => number, sink: (id: string, value: number) => void);
|
|
250
|
+
/**
|
|
251
|
+
* Advance every rig to the given wall-clock timestamp (milliseconds).
|
|
252
|
+
*
|
|
253
|
+
* First call: seed each spring to rest AT its current target (so a model
|
|
254
|
+
* loaded with a nonzero input does not kick), emit the resting output, and
|
|
255
|
+
* return without integrating — mirroring IdleMotion's first-frame behavior.
|
|
256
|
+
*
|
|
257
|
+
* Subsequent calls: {@link FixedStepClock} folds the clamped frame delta into
|
|
258
|
+
* its accumulator and returns how many {@link FIXED_DT_S} sub-steps are due;
|
|
259
|
+
* the spring advances that many semi-implicit Euler steps, then each rig emits
|
|
260
|
+
* its output once. The clock's dt clamp and sub-step cap plus the symplectic
|
|
261
|
+
* integrator are what keep it stable across hitches.
|
|
262
|
+
*/
|
|
263
|
+
update(nowMs: number): void;
|
|
264
|
+
/** Spring target = signed-normalized input value × weight. */
|
|
265
|
+
private targetFor;
|
|
266
|
+
/** One semi-implicit (symplectic) Euler sub-step of FIXED_DT_S seconds. */
|
|
267
|
+
private step;
|
|
268
|
+
/** Write outputDefault + x * scale onto the output param via the sink. */
|
|
269
|
+
private emit;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Host-agnostic multi-segment angular-pendulum-chain secondary-motion driver.
|
|
274
|
+
* Peer of {@link PhysicsMotion} and {@link IdleMotion}.
|
|
275
|
+
*
|
|
276
|
+
* Each chain anchors to a matrix deformer in the model hierarchy. The driver
|
|
277
|
+
* self-computes the anchor's world rotation via `resolveDeformerWorlds` (a
|
|
278
|
+
* private `ParameterStore` is filled from `read` ONCE per frame) and integrates
|
|
279
|
+
* a per-segment angular pendulum with semi-implicit Euler on a fixed 1/60s
|
|
280
|
+
* sub-step accumulator. Each segment's angular displacement θ (in radians
|
|
281
|
+
* internally) is emitted in DEGREES on its output parameter, so `rotate = 0`
|
|
282
|
+
* when the chain is at its authored rest pose.
|
|
283
|
+
*
|
|
284
|
+
* Usage:
|
|
285
|
+
* const chains = new HairChainMotion(
|
|
286
|
+
* model.physicsChains ?? [],
|
|
287
|
+
* model.parameters,
|
|
288
|
+
* model.deformers ?? [],
|
|
289
|
+
* (id) => currentValue(id),
|
|
290
|
+
* player.setParameter.bind(player),
|
|
291
|
+
* );
|
|
292
|
+
* // inside your rAF loop, right AFTER physics.update(now):
|
|
293
|
+
* chains.update(performance.now());
|
|
294
|
+
*
|
|
295
|
+
* The host schedules updates; this class has no timers, rAF, DOM, or Date.now.
|
|
296
|
+
*/
|
|
297
|
+
declare class HairChainMotion {
|
|
298
|
+
private readonly chainData;
|
|
299
|
+
private readonly params;
|
|
300
|
+
private readonly deformers;
|
|
301
|
+
private readonly store;
|
|
302
|
+
private readonly read;
|
|
303
|
+
private readonly sink;
|
|
304
|
+
private readonly clock;
|
|
305
|
+
constructor(chains: IkiPhysicsChain[], params: IkiParameter[], deformers: IkiDeformer[], read: (id: string) => number, sink: (id: string, value: number) => void);
|
|
306
|
+
/**
|
|
307
|
+
* Advance every chain to the given wall-clock timestamp (milliseconds).
|
|
308
|
+
*
|
|
309
|
+
* First call: seed every segment to θ=0/ω=0 (rest), emit the rest output
|
|
310
|
+
* (outDefault + 0), and return without integrating — mirrors PhysicsMotion's
|
|
311
|
+
* first-frame behavior so a model loaded in motion does not kick.
|
|
312
|
+
*
|
|
313
|
+
* Subsequent calls: dt = clamp(nowMs - prevNowMs, 0, MAX_DT_MS) → seconds into
|
|
314
|
+
* accumulator. The per-frame world snapshot (anchor world angles) is taken ONCE
|
|
315
|
+
* per update() — NOT per chain — so all chains share a consistent frame snapshot.
|
|
316
|
+
* Fixed FIXED_DT_S sub-steps are run root→tip, capped at MAX_SUBSTEPS; leftover
|
|
317
|
+
* time is carried to the next frame. Segments emit after substeps (even on zero
|
|
318
|
+
* substeps) with a non-finite guard.
|
|
319
|
+
*/
|
|
320
|
+
update(nowMs: number): void;
|
|
321
|
+
/**
|
|
322
|
+
* Extract world rotation (radians) from the anchor's Affine tuple.
|
|
323
|
+
* Affine = [a,b,c,d,e,f]; rotation column = (a,b) → atan2(b,a).
|
|
324
|
+
*
|
|
325
|
+
* If the anchor id is absent from the map, THROWS an internal Error — the
|
|
326
|
+
* format validator guarantees the anchor exists, so absence is an invariant
|
|
327
|
+
* break (mirrors resolveDeformerWorlds' throw on an unresolved parent,
|
|
328
|
+
* deform.ts:141).
|
|
329
|
+
*/
|
|
330
|
+
private anchorWorldAngleRad;
|
|
331
|
+
/**
|
|
332
|
+
* One fixed sub-step of FIXED_DT_S seconds for all segments in a chain.
|
|
333
|
+
*
|
|
334
|
+
* Segments are integrated ROOT→TIP so each segment can read its upstream
|
|
335
|
+
* neighbor's current-substep state when computing the world angle Φ_i.
|
|
336
|
+
* (The chain is causal root-to-tip; reversing the order would use stale θ
|
|
337
|
+
* values from the previous substep for Φ_i computation.)
|
|
338
|
+
*
|
|
339
|
+
* Per-segment semi-implicit (symplectic) Euler:
|
|
340
|
+
* Φ_i = anchorWorldAngleRad + Σ_{j≤i}(restAngle_j + θ_j)
|
|
341
|
+
* α_i = (−stiffness_i·θ_i − strength·sin(Φ_i − gravityAngle_rad) − damping_i·ω_i) / mass_i
|
|
342
|
+
* ω_i += α_i · FIXED_DT_S (velocity updated FIRST = semi-implicit)
|
|
343
|
+
* θ_i += ω_i · FIXED_DT_S (position updated from NEW velocity)
|
|
344
|
+
*
|
|
345
|
+
* The spring term is −stiffness·θ (restoring θ→0); restAngle does NOT appear
|
|
346
|
+
* in the spring term, only in Φ_i for the gravity torque.
|
|
347
|
+
*/
|
|
348
|
+
private stepChain;
|
|
349
|
+
/** Emit outDefault + (θ_i · RAD2DEG) · scale for one segment. */
|
|
350
|
+
private emitSegment;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export { type Affine, HairChainMotion, IdleMotion, type IdleMotionOptions, type IkiLoadResult, IkiPlayer, ParameterStore, PhysicsMotion, multiply, rotate, scale, toMat3, translate };
|