@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/dist/index.js
ADDED
|
@@ -0,0 +1,1420 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
HairChainMotion: () => HairChainMotion,
|
|
24
|
+
IdleMotion: () => IdleMotion,
|
|
25
|
+
IkiPlayer: () => IkiPlayer,
|
|
26
|
+
ParameterStore: () => ParameterStore,
|
|
27
|
+
PhysicsMotion: () => PhysicsMotion,
|
|
28
|
+
multiply: () => multiply,
|
|
29
|
+
rotate: () => rotate,
|
|
30
|
+
scale: () => scale,
|
|
31
|
+
toMat3: () => toMat3,
|
|
32
|
+
translate: () => translate
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/math.ts
|
|
37
|
+
function clamp(value, min, max) {
|
|
38
|
+
return Math.max(min, Math.min(max, value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/parameter-store.ts
|
|
42
|
+
var ParameterStore = class {
|
|
43
|
+
params = /* @__PURE__ */ new Map();
|
|
44
|
+
values = /* @__PURE__ */ new Map();
|
|
45
|
+
/**
|
|
46
|
+
* Resting value per id: the declared default clamped into range, resolved
|
|
47
|
+
* ONCE here so `reset()` is a straight copy and a malformed descriptor is
|
|
48
|
+
* reported once rather than on every reset.
|
|
49
|
+
*/
|
|
50
|
+
defaults = /* @__PURE__ */ new Map();
|
|
51
|
+
constructor(parameters) {
|
|
52
|
+
for (const param of parameters) {
|
|
53
|
+
this.params.set(param.id, param);
|
|
54
|
+
if (!Number.isFinite(param.default)) {
|
|
55
|
+
console.error(
|
|
56
|
+
`Iki: parameter "${param.id}" has a non-finite default; resting at the neutral in-range value instead`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const base = Number.isFinite(param.default) ? param.default : 0;
|
|
60
|
+
this.defaults.set(param.id, clamp(base, param.min, param.max));
|
|
61
|
+
}
|
|
62
|
+
for (const [id, value] of this.defaults) this.values.set(id, value);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Set a parameter's value, clamped to its range. Unknown ids are ignored, as
|
|
66
|
+
* are non-finite values: this is the boundary a host drives with live signals,
|
|
67
|
+
* and `clamp` cannot filter NaN (`Math.max(min, Math.min(max, NaN))` is NaN),
|
|
68
|
+
* so one bad lip-sync/gaze frame would otherwise poison every binding that
|
|
69
|
+
* reads the parameter. A dropped write holds the last good pose.
|
|
70
|
+
*/
|
|
71
|
+
set(id, value) {
|
|
72
|
+
const param = this.params.get(id);
|
|
73
|
+
if (!param) return;
|
|
74
|
+
if (!Number.isFinite(value)) return;
|
|
75
|
+
this.values.set(id, clamp(value, param.min, param.max));
|
|
76
|
+
}
|
|
77
|
+
/** Current value, or 0 if the id is unknown. */
|
|
78
|
+
get(id) {
|
|
79
|
+
return this.values.get(id) ?? 0;
|
|
80
|
+
}
|
|
81
|
+
/** Position of a parameter within its range, 0..1. */
|
|
82
|
+
normalized(id) {
|
|
83
|
+
const param = this.params.get(id);
|
|
84
|
+
if (!param || param.max === param.min) return 0;
|
|
85
|
+
return (this.get(id) - param.min) / (param.max - param.min);
|
|
86
|
+
}
|
|
87
|
+
/** Reset every parameter to its resting value (see `defaults`). */
|
|
88
|
+
reset() {
|
|
89
|
+
for (const [id, value] of this.defaults) this.values.set(id, value);
|
|
90
|
+
}
|
|
91
|
+
list() {
|
|
92
|
+
return [...this.params.values()];
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/affine.ts
|
|
97
|
+
function translate(tx, ty) {
|
|
98
|
+
return [1, 0, 0, 1, tx, ty];
|
|
99
|
+
}
|
|
100
|
+
function scale(sx, sy) {
|
|
101
|
+
return [sx, 0, 0, sy, 0, 0];
|
|
102
|
+
}
|
|
103
|
+
function rotate(degrees) {
|
|
104
|
+
const r = degrees * Math.PI / 180;
|
|
105
|
+
const c = Math.cos(r);
|
|
106
|
+
const s = Math.sin(r);
|
|
107
|
+
return [c, s, -s, c, 0, 0];
|
|
108
|
+
}
|
|
109
|
+
function multiply(a, b) {
|
|
110
|
+
return [
|
|
111
|
+
a[0] * b[0] + a[2] * b[1],
|
|
112
|
+
a[1] * b[0] + a[3] * b[1],
|
|
113
|
+
a[0] * b[2] + a[2] * b[3],
|
|
114
|
+
a[1] * b[2] + a[3] * b[3],
|
|
115
|
+
a[0] * b[4] + a[2] * b[5] + a[4],
|
|
116
|
+
a[1] * b[4] + a[3] * b[5] + a[5]
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
function toMat3(a) {
|
|
120
|
+
return new Float32Array([a[0], a[1], 0, a[2], a[3], 0, a[4], a[5], 1]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/deform.ts
|
|
124
|
+
var IDENTITY_TRANSFORM = {
|
|
125
|
+
x: 0,
|
|
126
|
+
y: 0,
|
|
127
|
+
rotation: 0,
|
|
128
|
+
scaleX: 1,
|
|
129
|
+
scaleY: 1,
|
|
130
|
+
opacity: 1
|
|
131
|
+
};
|
|
132
|
+
function evaluateTransform(transform, bindings, params) {
|
|
133
|
+
const base = transform ?? IDENTITY_TRANSFORM;
|
|
134
|
+
const result = {
|
|
135
|
+
x: base.x,
|
|
136
|
+
y: base.y,
|
|
137
|
+
rotation: base.rotation ?? 0,
|
|
138
|
+
scaleX: base.scaleX ?? 1,
|
|
139
|
+
scaleY: base.scaleY ?? 1,
|
|
140
|
+
opacity: base.opacity ?? 1
|
|
141
|
+
};
|
|
142
|
+
for (const binding of bindings ?? []) {
|
|
143
|
+
const t = params.normalized(binding.parameter);
|
|
144
|
+
const value = binding.from + (binding.to - binding.from) * t;
|
|
145
|
+
switch (binding.channel) {
|
|
146
|
+
case "translateX":
|
|
147
|
+
result.x += value;
|
|
148
|
+
break;
|
|
149
|
+
case "translateY":
|
|
150
|
+
result.y += value;
|
|
151
|
+
break;
|
|
152
|
+
case "rotate":
|
|
153
|
+
result.rotation += value;
|
|
154
|
+
break;
|
|
155
|
+
case "scaleX":
|
|
156
|
+
result.scaleX += value;
|
|
157
|
+
break;
|
|
158
|
+
case "scaleY":
|
|
159
|
+
result.scaleY += value;
|
|
160
|
+
break;
|
|
161
|
+
case "opacity":
|
|
162
|
+
result.opacity *= value;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
function deformerLocalMatrix(d, params) {
|
|
169
|
+
const t = evaluateTransform(d.transform, d.bindings, params);
|
|
170
|
+
const trs = multiply(
|
|
171
|
+
multiply(translate(t.x, t.y), rotate(t.rotation)),
|
|
172
|
+
scale(t.scaleX, t.scaleY)
|
|
173
|
+
);
|
|
174
|
+
return multiply(
|
|
175
|
+
multiply(translate(d.pivot.x, d.pivot.y), trs),
|
|
176
|
+
translate(-d.pivot.x, -d.pivot.y)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
function resolveDeformerWorlds(deformers, params) {
|
|
180
|
+
const matrixDeformers = deformers.filter(
|
|
181
|
+
(d) => d.kind === "matrix" || d.kind === void 0
|
|
182
|
+
);
|
|
183
|
+
const byId = new Map(
|
|
184
|
+
matrixDeformers.map((d) => [d.id, d])
|
|
185
|
+
);
|
|
186
|
+
const worldById = /* @__PURE__ */ new Map();
|
|
187
|
+
function resolve(d) {
|
|
188
|
+
const cached = worldById.get(d.id);
|
|
189
|
+
if (cached) return cached;
|
|
190
|
+
const local = deformerLocalMatrix(d, params);
|
|
191
|
+
let world;
|
|
192
|
+
if (d.parent === void 0) {
|
|
193
|
+
world = local;
|
|
194
|
+
} else {
|
|
195
|
+
const parentDef = byId.get(d.parent);
|
|
196
|
+
if (!parentDef) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`unresolved deformer parent "${d.parent}" \u2014 model not validated?`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
world = multiply(resolve(parentDef), local);
|
|
202
|
+
}
|
|
203
|
+
worldById.set(d.id, world);
|
|
204
|
+
return world;
|
|
205
|
+
}
|
|
206
|
+
for (const d of matrixDeformers) {
|
|
207
|
+
resolve(d);
|
|
208
|
+
}
|
|
209
|
+
return worldById;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/warp.ts
|
|
213
|
+
function accumulateKeyformOffsets(keyforms, value, out) {
|
|
214
|
+
const ks = keyforms;
|
|
215
|
+
if (value <= ks[0].value) {
|
|
216
|
+
const { offsets } = ks[0];
|
|
217
|
+
for (let i = 0; i < offsets.length; i++) {
|
|
218
|
+
out[i] += offsets[i];
|
|
219
|
+
}
|
|
220
|
+
} else if (value >= ks[ks.length - 1].value) {
|
|
221
|
+
const { offsets } = ks[ks.length - 1];
|
|
222
|
+
for (let i = 0; i < offsets.length; i++) {
|
|
223
|
+
out[i] += offsets[i];
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
let lo = ks[0];
|
|
227
|
+
let hi = ks[1];
|
|
228
|
+
for (let k = 1; k < ks.length - 1; k++) {
|
|
229
|
+
if (ks[k].value <= value) {
|
|
230
|
+
lo = ks[k];
|
|
231
|
+
hi = ks[k + 1];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const t = (value - lo.value) / (hi.value - lo.value);
|
|
235
|
+
const loOff = lo.offsets;
|
|
236
|
+
const hiOff = hi.offsets;
|
|
237
|
+
for (let i = 0; i < loOff.length; i++) {
|
|
238
|
+
out[i] += loOff[i] + (hiOff[i] - loOff[i]) * t;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function accumulate2DKeyformOffsets(valuesX, valuesY, keyforms2d, vx, vy, out) {
|
|
243
|
+
const lastX = valuesX.length - 1;
|
|
244
|
+
const lastY = valuesY.length - 1;
|
|
245
|
+
let ix;
|
|
246
|
+
let tx;
|
|
247
|
+
if (vx <= valuesX[0]) {
|
|
248
|
+
ix = 0;
|
|
249
|
+
tx = 0;
|
|
250
|
+
} else if (vx >= valuesX[lastX]) {
|
|
251
|
+
ix = lastX - 1;
|
|
252
|
+
tx = 1;
|
|
253
|
+
} else {
|
|
254
|
+
ix = 0;
|
|
255
|
+
for (let k = 0; k < lastX - 1; k++) {
|
|
256
|
+
if (valuesX[k + 1] <= vx) ix = k + 1;
|
|
257
|
+
}
|
|
258
|
+
tx = (vx - valuesX[ix]) / (valuesX[ix + 1] - valuesX[ix]);
|
|
259
|
+
}
|
|
260
|
+
let iy;
|
|
261
|
+
let ty;
|
|
262
|
+
if (vy <= valuesY[0]) {
|
|
263
|
+
iy = 0;
|
|
264
|
+
ty = 0;
|
|
265
|
+
} else if (vy >= valuesY[lastY]) {
|
|
266
|
+
iy = lastY - 1;
|
|
267
|
+
ty = 1;
|
|
268
|
+
} else {
|
|
269
|
+
iy = 0;
|
|
270
|
+
for (let k = 0; k < lastY - 1; k++) {
|
|
271
|
+
if (valuesY[k + 1] <= vy) iy = k + 1;
|
|
272
|
+
}
|
|
273
|
+
ty = (vy - valuesY[iy]) / (valuesY[iy + 1] - valuesY[iy]);
|
|
274
|
+
}
|
|
275
|
+
const W = valuesX.length;
|
|
276
|
+
const c00 = keyforms2d[iy * W + ix];
|
|
277
|
+
const c10 = keyforms2d[iy * W + ix + 1];
|
|
278
|
+
const c01 = keyforms2d[(iy + 1) * W + ix];
|
|
279
|
+
const c11 = keyforms2d[(iy + 1) * W + ix + 1];
|
|
280
|
+
const o00 = c00.offsets;
|
|
281
|
+
const o10 = c10.offsets;
|
|
282
|
+
const o01 = c01.offsets;
|
|
283
|
+
const o11 = c11.offsets;
|
|
284
|
+
for (let n = 0; n < o00.length; n++) {
|
|
285
|
+
const top = o00[n] + (o10[n] - o00[n]) * tx;
|
|
286
|
+
const bot = o01[n] + (o11[n] - o01[n]) * tx;
|
|
287
|
+
out[n] += top + (bot - top) * ty;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function applyWarps(rest, warps, params, out) {
|
|
291
|
+
out.set(rest);
|
|
292
|
+
if (!warps || warps.length === 0) return;
|
|
293
|
+
for (const warp of warps) {
|
|
294
|
+
accumulateKeyformOffsets(warp.keyforms, params.get(warp.parameter), out);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/warp-grid.ts
|
|
299
|
+
function resolveWarpGrids(deformers, params, matrixWorlds) {
|
|
300
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
301
|
+
for (const d of deformers) {
|
|
302
|
+
if (d.kind !== "warp") continue;
|
|
303
|
+
const { cols, rows, points: restPoints } = d.grid;
|
|
304
|
+
const points = Float32Array.from(restPoints);
|
|
305
|
+
for (const warp of d.warps ?? []) {
|
|
306
|
+
accumulateKeyformOffsets(
|
|
307
|
+
warp.keyforms,
|
|
308
|
+
params.get(warp.parameter),
|
|
309
|
+
points
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (d.warp2d !== void 0) {
|
|
313
|
+
accumulate2DKeyformOffsets(
|
|
314
|
+
d.warp2d.valuesX,
|
|
315
|
+
d.warp2d.valuesY,
|
|
316
|
+
d.warp2d.keyforms2d,
|
|
317
|
+
params.get(d.warp2d.parameter),
|
|
318
|
+
params.get(d.warp2d.parameterY),
|
|
319
|
+
points
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (d.parent !== void 0) {
|
|
323
|
+
const parentAffine = matrixWorlds.get(d.parent);
|
|
324
|
+
if (parentAffine) {
|
|
325
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
326
|
+
const x = points[i];
|
|
327
|
+
const y = points[i + 1];
|
|
328
|
+
points[i] = parentAffine[0] * x + parentAffine[2] * y + parentAffine[4];
|
|
329
|
+
points[i + 1] = parentAffine[1] * x + parentAffine[3] * y + parentAffine[5];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
resolved.set(d.id, { cols, rows, points });
|
|
334
|
+
}
|
|
335
|
+
return resolved;
|
|
336
|
+
}
|
|
337
|
+
function bindPointToRestGrid(x, y, restGrid) {
|
|
338
|
+
const { cols, rows, points } = restGrid;
|
|
339
|
+
const stride = cols + 1;
|
|
340
|
+
let col = cols - 1;
|
|
341
|
+
for (let c = 0; c < cols; c++) {
|
|
342
|
+
const xRight2 = points[(c + 1) * 2];
|
|
343
|
+
if (x < xRight2) {
|
|
344
|
+
col = c;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const xLeft = points[col * 2];
|
|
349
|
+
const xRight = points[(col + 1) * 2];
|
|
350
|
+
const s = clamp((x - xLeft) / (xRight - xLeft), 0, 1);
|
|
351
|
+
let row = rows - 1;
|
|
352
|
+
for (let r = 0; r < rows; r++) {
|
|
353
|
+
const yBottom2 = points[(r + 1) * stride * 2 + 1];
|
|
354
|
+
if (y > yBottom2) {
|
|
355
|
+
row = r;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const yTop = points[row * stride * 2 + 1];
|
|
360
|
+
const yBottom = points[(row + 1) * stride * 2 + 1];
|
|
361
|
+
const t = clamp((yTop - y) / (yTop - yBottom), 0, 1);
|
|
362
|
+
return { cell: row * cols + col, s, t };
|
|
363
|
+
}
|
|
364
|
+
function applyWarpToChild(localVerts, partAffine, restGrid, deformedGrid, out) {
|
|
365
|
+
const n = localVerts.length / 2;
|
|
366
|
+
for (let v = 0; v < n; v++) {
|
|
367
|
+
const lx = localVerts[v * 2];
|
|
368
|
+
const ly = localVerts[v * 2 + 1];
|
|
369
|
+
const mx = partAffine[0] * lx + partAffine[2] * ly + partAffine[4];
|
|
370
|
+
const my = partAffine[1] * lx + partAffine[3] * ly + partAffine[5];
|
|
371
|
+
const binding = bindPointToRestGrid(mx, my, restGrid);
|
|
372
|
+
const [sx, sy] = sampleWarpGrid(deformedGrid, binding);
|
|
373
|
+
out[v * 2] = sx;
|
|
374
|
+
out[v * 2 + 1] = sy;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function sampleWarpGrid(grid, binding) {
|
|
378
|
+
const { cols, points } = grid;
|
|
379
|
+
const stride = cols + 1;
|
|
380
|
+
const row = Math.floor(binding.cell / cols);
|
|
381
|
+
const col = binding.cell % cols;
|
|
382
|
+
const { s, t } = binding;
|
|
383
|
+
const i00 = (row * stride + col) * 2;
|
|
384
|
+
const i10 = (row * stride + col + 1) * 2;
|
|
385
|
+
const i01 = ((row + 1) * stride + col) * 2;
|
|
386
|
+
const i11 = ((row + 1) * stride + col + 1) * 2;
|
|
387
|
+
const topX = points[i00] + (points[i10] - points[i00]) * s;
|
|
388
|
+
const topY = points[i00 + 1] + (points[i10 + 1] - points[i00 + 1]) * s;
|
|
389
|
+
const botX = points[i01] + (points[i11] - points[i01]) * s;
|
|
390
|
+
const botY = points[i01 + 1] + (points[i11 + 1] - points[i01 + 1]) * s;
|
|
391
|
+
return [topX + (botX - topX) * t, topY + (botY - topY) * t];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/player.ts
|
|
395
|
+
var MASK_ALPHA_CUTOFF = 0.5;
|
|
396
|
+
var IkiPlayer = class {
|
|
397
|
+
constructor(canvas) {
|
|
398
|
+
this.canvas = canvas;
|
|
399
|
+
const gl = canvas.getContext("webgl2", {
|
|
400
|
+
alpha: true,
|
|
401
|
+
// The whole pipeline is premultiplied-alpha: the fragment shader
|
|
402
|
+
// multiplies rgb by alpha, the blend function is ONE /
|
|
403
|
+
// ONE_MINUS_SRC_ALPHA, and the page compositor reads the framebuffer
|
|
404
|
+
// as premultiplied. Straight-alpha (`premultipliedAlpha: false`) cannot
|
|
405
|
+
// be made consistent with SRC_ALPHA-style blending: semi-transparent
|
|
406
|
+
// pixels over a transparent background come out premultiplied anyway
|
|
407
|
+
// and composite too dark.
|
|
408
|
+
premultipliedAlpha: true,
|
|
409
|
+
// Stencil buffer backs clip masks (a part rendered only inside its masks'
|
|
410
|
+
// coverage). Granted by every modern browser; the load() guard reports if not.
|
|
411
|
+
stencil: true
|
|
412
|
+
});
|
|
413
|
+
if (!gl) throw new Error("WebGL2 is not available in this browser");
|
|
414
|
+
this.gl = gl;
|
|
415
|
+
this.stencilAvailable = gl.getContextAttributes()?.stencil ?? false;
|
|
416
|
+
this.program = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
|
|
417
|
+
this.uMatrix = getUniform(gl, this.program, "u_matrix");
|
|
418
|
+
this.uColor = getUniform(gl, this.program, "u_color");
|
|
419
|
+
this.uUseTexture = getUniform(gl, this.program, "u_useTexture");
|
|
420
|
+
this.uTex = getUniform(gl, this.program, "u_tex");
|
|
421
|
+
this.uUvOffset = getUniform(gl, this.program, "u_uvOffset");
|
|
422
|
+
this.uUvScale = getUniform(gl, this.program, "u_uvScale");
|
|
423
|
+
this.uUseMeshUv = getUniform(gl, this.program, "u_useMeshUv");
|
|
424
|
+
this.uAlphaCutoff = getUniform(gl, this.program, "u_alphaCutoff");
|
|
425
|
+
this.aPos = gl.getAttribLocation(this.program, "a_pos");
|
|
426
|
+
this.aUv = gl.getAttribLocation(this.program, "a_uv");
|
|
427
|
+
this.quad = createUnitQuad(gl);
|
|
428
|
+
gl.enable(gl.BLEND);
|
|
429
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
430
|
+
}
|
|
431
|
+
canvas;
|
|
432
|
+
gl;
|
|
433
|
+
program;
|
|
434
|
+
quad;
|
|
435
|
+
uMatrix;
|
|
436
|
+
uColor;
|
|
437
|
+
uUseTexture;
|
|
438
|
+
uTex;
|
|
439
|
+
uUvOffset;
|
|
440
|
+
uUvScale;
|
|
441
|
+
uUseMeshUv;
|
|
442
|
+
uAlphaCutoff;
|
|
443
|
+
aPos;
|
|
444
|
+
aUv;
|
|
445
|
+
/** True when the context granted a stencil buffer; clipping needs it. */
|
|
446
|
+
stencilAvailable;
|
|
447
|
+
model;
|
|
448
|
+
parts = [];
|
|
449
|
+
params = new ParameterStore([]);
|
|
450
|
+
rafId;
|
|
451
|
+
/** Uploaded textures, index-aligned with `model.textures`; `null` = unusable. */
|
|
452
|
+
textures = [];
|
|
453
|
+
/** Bumped by every `load` and by `destroy`; lets a stale async load bail. */
|
|
454
|
+
loadGeneration = 0;
|
|
455
|
+
destroyed = false;
|
|
456
|
+
/**
|
|
457
|
+
* Engine-internal mesh buffers, keyed by the part's INDEX in `this.parts`
|
|
458
|
+
* (NOT by part id — duplicate ids must not swap buffers).
|
|
459
|
+
*/
|
|
460
|
+
partMeshes = /* @__PURE__ */ new Map();
|
|
461
|
+
/**
|
|
462
|
+
* Clip groups resolved once per `load()`: consumer part index (into `this.parts`)
|
|
463
|
+
* → its mask part indices. A part absent from this map is unclipped.
|
|
464
|
+
*/
|
|
465
|
+
partClipGroups = /* @__PURE__ */ new Map();
|
|
466
|
+
/**
|
|
467
|
+
* Load a model and reset parameters to their defaults. All textures are
|
|
468
|
+
* decoded and uploaded before the model is swapped in — the swap is atomic,
|
|
469
|
+
* so you never see a partially-textured frame. `start()` may be called any
|
|
470
|
+
* time, but nothing renders until the first `load()` resolves. For an
|
|
471
|
+
* embedded `data:` atlas this is near-instant.
|
|
472
|
+
*
|
|
473
|
+
* Individual texture decode/upload failures are non-fatal: they are logged
|
|
474
|
+
* via `console.error`, the affected parts are skipped, and `load()` still
|
|
475
|
+
* resolves — the returned {@link IkiLoadResult} lists the indices of any
|
|
476
|
+
* textures that failed, so a host can detect and report a partial load. The
|
|
477
|
+
* model is assumed already validated by `@ikijs/format`.
|
|
478
|
+
*
|
|
479
|
+
* Mesh buffer allocation failure IS fatal (unlike per-texture skip) because
|
|
480
|
+
* textures have an `IkiLoadResult.failedTextures` reporting surface and mesh
|
|
481
|
+
* buffers have none — there is no partial-mesh concept in the format.
|
|
482
|
+
*/
|
|
483
|
+
async load(model) {
|
|
484
|
+
const { gl } = this;
|
|
485
|
+
const generation = ++this.loadGeneration;
|
|
486
|
+
const sources = model.textures ?? [];
|
|
487
|
+
const decoded = await Promise.allSettled(
|
|
488
|
+
sources.map((tex) => decodeTexture(tex.source))
|
|
489
|
+
);
|
|
490
|
+
if (generation !== this.loadGeneration || this.destroyed) {
|
|
491
|
+
for (const result of decoded) {
|
|
492
|
+
if (result.status === "fulfilled" && result.value) result.value.close();
|
|
493
|
+
}
|
|
494
|
+
return { failedTextures: [], superseded: true };
|
|
495
|
+
}
|
|
496
|
+
const maxTextureSizeRaw = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
497
|
+
const maxTextureSize = typeof maxTextureSizeRaw === "number" ? maxTextureSizeRaw : void 0;
|
|
498
|
+
if (maxTextureSize === void 0) {
|
|
499
|
+
console.error("Iki: WebGL context lost during load()");
|
|
500
|
+
}
|
|
501
|
+
drainGlErrors(gl);
|
|
502
|
+
const uploaded = decoded.map((result, i) => {
|
|
503
|
+
if (result.status === "rejected") {
|
|
504
|
+
console.error(`Iki: failed to decode textures[${i}]`, result.reason);
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const bitmap = result.value;
|
|
508
|
+
if (!bitmap) return null;
|
|
509
|
+
if (maxTextureSize !== void 0 && (bitmap.width > maxTextureSize || bitmap.height > maxTextureSize)) {
|
|
510
|
+
console.error(
|
|
511
|
+
`Iki: textures[${i}] is ${bitmap.width}x${bitmap.height}, over this device's ${maxTextureSize}px limit`
|
|
512
|
+
);
|
|
513
|
+
bitmap.close();
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
const texture = gl.createTexture();
|
|
517
|
+
if (!texture) {
|
|
518
|
+
bitmap.close();
|
|
519
|
+
console.error(`Iki: failed to allocate GL texture for textures[${i}]`);
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
523
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
524
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
525
|
+
drainGlErrors(gl);
|
|
526
|
+
gl.texImage2D(
|
|
527
|
+
gl.TEXTURE_2D,
|
|
528
|
+
0,
|
|
529
|
+
gl.RGBA,
|
|
530
|
+
gl.RGBA,
|
|
531
|
+
gl.UNSIGNED_BYTE,
|
|
532
|
+
bitmap
|
|
533
|
+
);
|
|
534
|
+
bitmap.close();
|
|
535
|
+
const uploadError = gl.getError();
|
|
536
|
+
if (uploadError !== gl.NO_ERROR) {
|
|
537
|
+
gl.deleteTexture(texture);
|
|
538
|
+
console.error(
|
|
539
|
+
`Iki: failed to upload textures[${i}] (GL error 0x${uploadError.toString(16)})`
|
|
540
|
+
);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
544
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
545
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
546
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
547
|
+
return texture;
|
|
548
|
+
});
|
|
549
|
+
const nextParts = [...model.parts].sort((a, b) => a.order - b.order);
|
|
550
|
+
const nextPartMeshes = /* @__PURE__ */ new Map();
|
|
551
|
+
for (let i = 0; i < nextParts.length; i++) {
|
|
552
|
+
const part = nextParts[i];
|
|
553
|
+
if (!part.mesh) continue;
|
|
554
|
+
const { mesh } = part;
|
|
555
|
+
const currentPartBuffers = [];
|
|
556
|
+
const positionBuf = gl.createBuffer();
|
|
557
|
+
if (!positionBuf) {
|
|
558
|
+
deletePartMeshBuffers(gl, nextPartMeshes);
|
|
559
|
+
deleteUploadedTextures(gl, uploaded);
|
|
560
|
+
throw new Error("Iki: failed to allocate mesh buffer");
|
|
561
|
+
}
|
|
562
|
+
currentPartBuffers.push(positionBuf);
|
|
563
|
+
const uvBuf = gl.createBuffer();
|
|
564
|
+
if (!uvBuf) {
|
|
565
|
+
for (const b of currentPartBuffers) gl.deleteBuffer(b);
|
|
566
|
+
deletePartMeshBuffers(gl, nextPartMeshes);
|
|
567
|
+
deleteUploadedTextures(gl, uploaded);
|
|
568
|
+
throw new Error("Iki: failed to allocate mesh buffer");
|
|
569
|
+
}
|
|
570
|
+
currentPartBuffers.push(uvBuf);
|
|
571
|
+
const indexBuf = gl.createBuffer();
|
|
572
|
+
if (!indexBuf) {
|
|
573
|
+
for (const b of currentPartBuffers) gl.deleteBuffer(b);
|
|
574
|
+
deletePartMeshBuffers(gl, nextPartMeshes);
|
|
575
|
+
deleteUploadedTextures(gl, uploaded);
|
|
576
|
+
throw new Error("Iki: failed to allocate mesh buffer");
|
|
577
|
+
}
|
|
578
|
+
const rest = new Float32Array(mesh.vertices);
|
|
579
|
+
const warpDeformer = part.deformer !== void 0 ? model.deformers?.find(
|
|
580
|
+
(d) => d.kind === "warp" && d.id === part.deformer
|
|
581
|
+
) : void 0;
|
|
582
|
+
const isWarpChild = warpDeformer !== void 0;
|
|
583
|
+
const hasWarps = (part.warps?.length ?? 0) > 0;
|
|
584
|
+
const dynamic = hasWarps || isWarpChild;
|
|
585
|
+
const scratch = dynamic ? new Float32Array(mesh.vertices.length) : void 0;
|
|
586
|
+
const local = isWarpChild ? new Float32Array(mesh.vertices.length) : void 0;
|
|
587
|
+
drainGlErrors(gl);
|
|
588
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuf);
|
|
589
|
+
gl.bufferData(
|
|
590
|
+
gl.ARRAY_BUFFER,
|
|
591
|
+
rest,
|
|
592
|
+
dynamic ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW
|
|
593
|
+
);
|
|
594
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
|
|
595
|
+
gl.bufferData(
|
|
596
|
+
gl.ARRAY_BUFFER,
|
|
597
|
+
new Float32Array(mesh.uvs),
|
|
598
|
+
gl.STATIC_DRAW
|
|
599
|
+
);
|
|
600
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf);
|
|
601
|
+
gl.bufferData(
|
|
602
|
+
gl.ELEMENT_ARRAY_BUFFER,
|
|
603
|
+
new Uint16Array(mesh.indices),
|
|
604
|
+
gl.STATIC_DRAW
|
|
605
|
+
);
|
|
606
|
+
const meshUploadError = gl.getError();
|
|
607
|
+
if (meshUploadError !== gl.NO_ERROR) {
|
|
608
|
+
for (const b of currentPartBuffers) gl.deleteBuffer(b);
|
|
609
|
+
gl.deleteBuffer(indexBuf);
|
|
610
|
+
deletePartMeshBuffers(gl, nextPartMeshes);
|
|
611
|
+
deleteUploadedTextures(gl, uploaded);
|
|
612
|
+
throw new Error(
|
|
613
|
+
`Iki: failed to upload mesh buffers for part "${part.id}" (GL error 0x${meshUploadError.toString(16)})`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
nextPartMeshes.set(i, {
|
|
617
|
+
position: positionBuf,
|
|
618
|
+
uv: uvBuf,
|
|
619
|
+
index: indexBuf,
|
|
620
|
+
indexCount: mesh.indices.length,
|
|
621
|
+
rest,
|
|
622
|
+
scratch,
|
|
623
|
+
warps: part.warps,
|
|
624
|
+
warpDeformer,
|
|
625
|
+
local
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const nextClipGroups = /* @__PURE__ */ new Map();
|
|
629
|
+
const indexById = /* @__PURE__ */ new Map();
|
|
630
|
+
for (let i = 0; i < nextParts.length; i++) {
|
|
631
|
+
indexById.set(nextParts[i].id, i);
|
|
632
|
+
}
|
|
633
|
+
for (let i = 0; i < nextParts.length; i++) {
|
|
634
|
+
const clip = nextParts[i].clip;
|
|
635
|
+
if (!clip) continue;
|
|
636
|
+
const maskIndices = clip.masks.map((maskId) => indexById.get(maskId)).filter((mi) => mi !== void 0);
|
|
637
|
+
if (maskIndices.length > 0) nextClipGroups.set(i, maskIndices);
|
|
638
|
+
}
|
|
639
|
+
if (nextClipGroups.size > 0 && !this.stencilAvailable) {
|
|
640
|
+
console.error(
|
|
641
|
+
"Iki: model uses clip masks but this WebGL2 context has no stencil buffer; rendering unclipped"
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
deletePartMeshBuffers(gl, this.partMeshes);
|
|
645
|
+
for (const texture of this.textures) {
|
|
646
|
+
if (texture) gl.deleteTexture(texture);
|
|
647
|
+
}
|
|
648
|
+
this.model = model;
|
|
649
|
+
this.params = new ParameterStore(model.parameters);
|
|
650
|
+
this.parts = nextParts;
|
|
651
|
+
this.partMeshes = nextPartMeshes;
|
|
652
|
+
this.partClipGroups = nextClipGroups;
|
|
653
|
+
this.textures = uploaded;
|
|
654
|
+
return {
|
|
655
|
+
failedTextures: uploaded.flatMap((t, i) => t === null ? [i] : []),
|
|
656
|
+
superseded: false
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Start the render loop. Safe to call more than once, and a no-op after
|
|
661
|
+
* {@link destroy} — the program and buffers the loop draws with are gone, so
|
|
662
|
+
* restarting would only spray GL errors.
|
|
663
|
+
*/
|
|
664
|
+
start() {
|
|
665
|
+
if (this.rafId !== void 0 || this.destroyed) return;
|
|
666
|
+
const loop = () => {
|
|
667
|
+
this.renderFrame();
|
|
668
|
+
this.rafId = requestAnimationFrame(loop);
|
|
669
|
+
};
|
|
670
|
+
this.rafId = requestAnimationFrame(loop);
|
|
671
|
+
}
|
|
672
|
+
stop() {
|
|
673
|
+
if (this.rafId === void 0) return;
|
|
674
|
+
cancelAnimationFrame(this.rafId);
|
|
675
|
+
this.rafId = void 0;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Set a parameter value (clamped to its range). Unknown ids and non-finite
|
|
679
|
+
* values are ignored — see {@link ParameterStore.set}.
|
|
680
|
+
*/
|
|
681
|
+
setParameter(id, value) {
|
|
682
|
+
this.params.set(id, value);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Current value of a parameter, or 0 for an unknown id.
|
|
686
|
+
*
|
|
687
|
+
* Hosts need this to avoid shadowing the engine's state: the motion drivers
|
|
688
|
+
* read the live pose to compute the next one, and without a read accessor
|
|
689
|
+
* every host has to keep its own mirror of what it last wrote — and keep that
|
|
690
|
+
* mirror's clamping in step with {@link ParameterStore} by hand.
|
|
691
|
+
*/
|
|
692
|
+
getParameter(id) {
|
|
693
|
+
return this.params.get(id);
|
|
694
|
+
}
|
|
695
|
+
/** The model's parameter descriptors, for building UI or host wiring. */
|
|
696
|
+
getParameters() {
|
|
697
|
+
return this.params.list();
|
|
698
|
+
}
|
|
699
|
+
destroy() {
|
|
700
|
+
this.stop();
|
|
701
|
+
this.destroyed = true;
|
|
702
|
+
++this.loadGeneration;
|
|
703
|
+
const { gl } = this;
|
|
704
|
+
for (const texture of this.textures) {
|
|
705
|
+
if (texture) gl.deleteTexture(texture);
|
|
706
|
+
}
|
|
707
|
+
this.textures = [];
|
|
708
|
+
deletePartMeshBuffers(gl, this.partMeshes);
|
|
709
|
+
this.partMeshes = /* @__PURE__ */ new Map();
|
|
710
|
+
gl.deleteBuffer(this.quad);
|
|
711
|
+
gl.deleteProgram(this.program);
|
|
712
|
+
}
|
|
713
|
+
renderFrame() {
|
|
714
|
+
const { gl, canvas } = this;
|
|
715
|
+
const dpr = window.devicePixelRatio || 1;
|
|
716
|
+
const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
|
|
717
|
+
const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
|
|
718
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
719
|
+
canvas.width = width;
|
|
720
|
+
canvas.height = height;
|
|
721
|
+
}
|
|
722
|
+
gl.viewport(0, 0, width, height);
|
|
723
|
+
gl.clearColor(0, 0, 0, 0);
|
|
724
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
|
|
725
|
+
if (!this.model) return;
|
|
726
|
+
const { width: modelW, height: modelH } = this.model.canvas;
|
|
727
|
+
const fit = Math.min(width / modelW, height / modelH);
|
|
728
|
+
const clipX = fit * 2 / width;
|
|
729
|
+
const clipY = fit * 2 / height;
|
|
730
|
+
gl.useProgram(this.program);
|
|
731
|
+
const deformerWorlds = this.model.deformers && this.model.deformers.length > 0 ? resolveDeformerWorlds(this.model.deformers, this.params) : void 0;
|
|
732
|
+
const warpGrids = this.model.deformers?.some((d) => d.kind === "warp") ? resolveWarpGrids(
|
|
733
|
+
this.model.deformers,
|
|
734
|
+
this.params,
|
|
735
|
+
deformerWorlds ?? /* @__PURE__ */ new Map()
|
|
736
|
+
) : void 0;
|
|
737
|
+
gl.uniform1f(this.uAlphaCutoff, 0);
|
|
738
|
+
for (let index = 0; index < this.parts.length; index++) {
|
|
739
|
+
const maskIndices = this.partClipGroups.get(index);
|
|
740
|
+
if (maskIndices && this.stencilAvailable) {
|
|
741
|
+
this.drawClipped(
|
|
742
|
+
index,
|
|
743
|
+
maskIndices,
|
|
744
|
+
clipX,
|
|
745
|
+
clipY,
|
|
746
|
+
deformerWorlds,
|
|
747
|
+
warpGrids
|
|
748
|
+
);
|
|
749
|
+
} else {
|
|
750
|
+
this.drawPart(index, clipX, clipY, deformerWorlds, warpGrids);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Draw a clipped part: stencil the union of its masks' alpha coverage, then
|
|
756
|
+
* draw the part only where the stencil was written. The mask parts also draw
|
|
757
|
+
* normally in their own `order` slot — this is an EXTRA, color-free pass over
|
|
758
|
+
* the same per-frame deformed geometry. All stencil/colorMask state the pass
|
|
759
|
+
* touches is restored before returning so later parts are unaffected.
|
|
760
|
+
*/
|
|
761
|
+
drawClipped(index, maskIndices, clipX, clipY, deformerWorlds, warpGrids) {
|
|
762
|
+
const { gl } = this;
|
|
763
|
+
gl.clear(gl.STENCIL_BUFFER_BIT);
|
|
764
|
+
gl.enable(gl.STENCIL_TEST);
|
|
765
|
+
gl.colorMask(false, false, false, false);
|
|
766
|
+
gl.stencilMask(255);
|
|
767
|
+
gl.stencilFunc(gl.ALWAYS, 1, 255);
|
|
768
|
+
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
|
|
769
|
+
gl.uniform1f(this.uAlphaCutoff, MASK_ALPHA_CUTOFF);
|
|
770
|
+
for (const maskIndex of maskIndices) {
|
|
771
|
+
this.drawPart(maskIndex, clipX, clipY, deformerWorlds, warpGrids);
|
|
772
|
+
}
|
|
773
|
+
gl.uniform1f(this.uAlphaCutoff, 0);
|
|
774
|
+
gl.colorMask(true, true, true, true);
|
|
775
|
+
gl.stencilMask(0);
|
|
776
|
+
gl.stencilFunc(gl.EQUAL, 1, 255);
|
|
777
|
+
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
|
|
778
|
+
this.drawPart(index, clipX, clipY, deformerWorlds, warpGrids);
|
|
779
|
+
gl.disable(gl.STENCIL_TEST);
|
|
780
|
+
gl.stencilMask(255);
|
|
781
|
+
gl.stencilFunc(gl.ALWAYS, 0, 255);
|
|
782
|
+
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Draw a single part with its full per-part material + geometry state. Shared
|
|
786
|
+
* by the normal pass, the stencil mask-write pass, and the masked consumer
|
|
787
|
+
* draw — so every path prepares the SAME complete uniform/texture/VBO state
|
|
788
|
+
* (the caller only sets stencil/colorMask/u_alphaCutoff around it).
|
|
789
|
+
*/
|
|
790
|
+
drawPart(index, clipX, clipY, deformerWorlds, warpGrids) {
|
|
791
|
+
const { gl } = this;
|
|
792
|
+
const part = this.parts[index];
|
|
793
|
+
const texture = part.texture ? this.textures[part.texture.index] : void 0;
|
|
794
|
+
if (part.texture && !texture) return;
|
|
795
|
+
const t = this.evaluate(part);
|
|
796
|
+
const warpChild = this.partMeshes.get(index)?.warpDeformer;
|
|
797
|
+
let m;
|
|
798
|
+
if (warpChild) {
|
|
799
|
+
m = scale(clipX, clipY);
|
|
800
|
+
} else if (part.deformer !== void 0) {
|
|
801
|
+
const dWorld = deformerWorlds.get(part.deformer);
|
|
802
|
+
if (!dWorld) {
|
|
803
|
+
throw new Error(
|
|
804
|
+
`part "${part.id}" references unknown deformer "${part.deformer}"`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
m = multiply(multiply(scale(clipX, clipY), dWorld), translate(t.x, t.y));
|
|
808
|
+
m = multiply(m, rotate(t.rotation));
|
|
809
|
+
m = multiply(m, scale(part.width * t.scaleX, part.height * t.scaleY));
|
|
810
|
+
} else {
|
|
811
|
+
m = multiply(scale(clipX, clipY), translate(t.x, t.y));
|
|
812
|
+
m = multiply(m, rotate(t.rotation));
|
|
813
|
+
m = multiply(m, scale(part.width * t.scaleX, part.height * t.scaleY));
|
|
814
|
+
}
|
|
815
|
+
const [r, g, b, a] = part.color;
|
|
816
|
+
gl.uniformMatrix3fv(this.uMatrix, false, toMat3(m));
|
|
817
|
+
gl.uniform4f(this.uColor, r, g, b, a * t.opacity);
|
|
818
|
+
if (part.texture && texture) {
|
|
819
|
+
const { uv } = part.texture;
|
|
820
|
+
gl.uniform1i(this.uUseTexture, 1);
|
|
821
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
822
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
823
|
+
gl.uniform1i(this.uTex, 0);
|
|
824
|
+
gl.uniform2f(this.uUvOffset, uv.x, uv.y);
|
|
825
|
+
gl.uniform2f(this.uUvScale, uv.width, uv.height);
|
|
826
|
+
} else {
|
|
827
|
+
gl.uniform1i(this.uUseTexture, 0);
|
|
828
|
+
}
|
|
829
|
+
if (part.mesh) {
|
|
830
|
+
const pm = this.partMeshes.get(index);
|
|
831
|
+
if (!pm) {
|
|
832
|
+
throw new Error(`Iki: mesh buffers missing for part "${part.id}"`);
|
|
833
|
+
}
|
|
834
|
+
gl.uniform1i(this.uUseMeshUv, 1);
|
|
835
|
+
if (pm.warpDeformer) {
|
|
836
|
+
const warpDef = pm.warpDeformer;
|
|
837
|
+
const grid = warpGrids.get(warpDef.id);
|
|
838
|
+
applyWarps(pm.rest, pm.warps, this.params, pm.local);
|
|
839
|
+
const trs = evaluateTransform(
|
|
840
|
+
part.transform,
|
|
841
|
+
part.bindings,
|
|
842
|
+
this.params
|
|
843
|
+
);
|
|
844
|
+
const partAffine = multiply(
|
|
845
|
+
multiply(translate(trs.x, trs.y), rotate(trs.rotation)),
|
|
846
|
+
scale(part.width * trs.scaleX, part.height * trs.scaleY)
|
|
847
|
+
);
|
|
848
|
+
applyWarpToChild(
|
|
849
|
+
pm.local,
|
|
850
|
+
partAffine,
|
|
851
|
+
warpDef.grid,
|
|
852
|
+
grid,
|
|
853
|
+
pm.scratch
|
|
854
|
+
);
|
|
855
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pm.position);
|
|
856
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, pm.scratch);
|
|
857
|
+
} else if (pm.warps && pm.warps.length > 0) {
|
|
858
|
+
applyWarps(pm.rest, pm.warps, this.params, pm.scratch);
|
|
859
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pm.position);
|
|
860
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, pm.scratch);
|
|
861
|
+
}
|
|
862
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pm.position);
|
|
863
|
+
gl.enableVertexAttribArray(this.aPos);
|
|
864
|
+
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
|
|
865
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pm.uv);
|
|
866
|
+
gl.enableVertexAttribArray(this.aUv);
|
|
867
|
+
gl.vertexAttribPointer(this.aUv, 2, gl.FLOAT, false, 0, 0);
|
|
868
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, pm.index);
|
|
869
|
+
gl.drawElements(gl.TRIANGLES, pm.indexCount, gl.UNSIGNED_SHORT, 0);
|
|
870
|
+
} else {
|
|
871
|
+
gl.uniform1i(this.uUseMeshUv, 0);
|
|
872
|
+
gl.disableVertexAttribArray(this.aUv);
|
|
873
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
|
|
874
|
+
gl.enableVertexAttribArray(this.aPos);
|
|
875
|
+
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
|
|
876
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/** Resolve a part's effective transform from its base plus active bindings. */
|
|
880
|
+
evaluate(part) {
|
|
881
|
+
return evaluateTransform(part.transform, part.bindings, this.params);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
var VERTEX_SHADER = `#version 300 es
|
|
885
|
+
in vec2 a_pos;
|
|
886
|
+
in vec2 a_uv;
|
|
887
|
+
uniform mat3 u_matrix;
|
|
888
|
+
uniform vec2 u_uvOffset;
|
|
889
|
+
uniform vec2 u_uvScale;
|
|
890
|
+
uniform bool u_useMeshUv;
|
|
891
|
+
out vec2 v_uv;
|
|
892
|
+
void main() {
|
|
893
|
+
if (u_useMeshUv) {
|
|
894
|
+
// Mesh path: UVs are already top-left atlas-space; pass straight through,
|
|
895
|
+
// no flip (the only flip in the pipeline lives in the quad branch below).
|
|
896
|
+
v_uv = a_uv;
|
|
897
|
+
} else {
|
|
898
|
+
// Quad path: a_pos corners are +/-0.5; lift to 0..1 (y-up), then map into
|
|
899
|
+
// the atlas sub-rect with a single V flip so the result is top-left UVs.
|
|
900
|
+
vec2 uvLocal = a_pos + 0.5;
|
|
901
|
+
v_uv = vec2(
|
|
902
|
+
u_uvOffset.x + uvLocal.x * u_uvScale.x,
|
|
903
|
+
u_uvOffset.y + (1.0 - uvLocal.y) * u_uvScale.y
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
vec3 p = u_matrix * vec3(a_pos, 1.0);
|
|
907
|
+
gl_Position = vec4(p.xy, 0.0, 1.0);
|
|
908
|
+
}`;
|
|
909
|
+
var FRAGMENT_SHADER = `#version 300 es
|
|
910
|
+
precision mediump float;
|
|
911
|
+
uniform vec4 u_color;
|
|
912
|
+
uniform bool u_useTexture;
|
|
913
|
+
uniform sampler2D u_tex;
|
|
914
|
+
uniform float u_alphaCutoff;
|
|
915
|
+
in vec2 v_uv;
|
|
916
|
+
out vec4 outColor;
|
|
917
|
+
void main() {
|
|
918
|
+
vec4 base = u_useTexture ? texture(u_tex, v_uv) : vec4(1.0);
|
|
919
|
+
vec4 tinted = base * u_color;
|
|
920
|
+
// 0 for normal draws (no-op); raised during the stencil mask-write pass so
|
|
921
|
+
// only opaque mask coverage marks the stencil (the transparent fringe is cut).
|
|
922
|
+
if (tinted.a < u_alphaCutoff) discard;
|
|
923
|
+
// Premultiply: the blend function and the canvas compositing contract
|
|
924
|
+
// (premultipliedAlpha: true) both expect rgb already scaled by alpha.
|
|
925
|
+
outColor = vec4(tinted.rgb * tinted.a, tinted.a);
|
|
926
|
+
}`;
|
|
927
|
+
async function decodeTexture(source) {
|
|
928
|
+
if (!source.startsWith("data:")) {
|
|
929
|
+
console.warn(
|
|
930
|
+
"Iki: external texture sources are unsupported in v1; skipping",
|
|
931
|
+
source.slice(0, 32)
|
|
932
|
+
);
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
const blob = await (await fetch(source)).blob();
|
|
936
|
+
return createImageBitmap(blob, {
|
|
937
|
+
imageOrientation: "none",
|
|
938
|
+
premultiplyAlpha: "none"
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
function createUnitQuad(gl) {
|
|
942
|
+
const buffer = gl.createBuffer();
|
|
943
|
+
if (!buffer) throw new Error("failed to allocate quad buffer");
|
|
944
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
945
|
+
gl.bufferData(
|
|
946
|
+
gl.ARRAY_BUFFER,
|
|
947
|
+
new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]),
|
|
948
|
+
gl.STATIC_DRAW
|
|
949
|
+
);
|
|
950
|
+
return buffer;
|
|
951
|
+
}
|
|
952
|
+
function drainGlErrors(gl) {
|
|
953
|
+
for (let i = 0; i < 32 && gl.getError() !== gl.NO_ERROR; i++) {
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function deletePartMeshBuffers(gl, meshes) {
|
|
957
|
+
for (const pm of meshes.values()) {
|
|
958
|
+
gl.deleteBuffer(pm.position);
|
|
959
|
+
gl.deleteBuffer(pm.uv);
|
|
960
|
+
gl.deleteBuffer(pm.index);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function deleteUploadedTextures(gl, textures) {
|
|
964
|
+
for (const texture of textures) {
|
|
965
|
+
if (texture) gl.deleteTexture(texture);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function createProgram(gl, vertexSrc, fragmentSrc) {
|
|
969
|
+
const program = gl.createProgram();
|
|
970
|
+
if (!program) throw new Error("failed to allocate WebGL program");
|
|
971
|
+
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, vertexSrc));
|
|
972
|
+
gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc));
|
|
973
|
+
gl.linkProgram(program);
|
|
974
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
975
|
+
const log = gl.getProgramInfoLog(program);
|
|
976
|
+
gl.deleteProgram(program);
|
|
977
|
+
throw new Error(`program link failed: ${log}`);
|
|
978
|
+
}
|
|
979
|
+
return program;
|
|
980
|
+
}
|
|
981
|
+
function compileShader(gl, type, source) {
|
|
982
|
+
const shader = gl.createShader(type);
|
|
983
|
+
if (!shader) throw new Error("failed to allocate shader");
|
|
984
|
+
gl.shaderSource(shader, source);
|
|
985
|
+
gl.compileShader(shader);
|
|
986
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
987
|
+
const log = gl.getShaderInfoLog(shader);
|
|
988
|
+
gl.deleteShader(shader);
|
|
989
|
+
throw new Error(`shader compile failed: ${log}`);
|
|
990
|
+
}
|
|
991
|
+
return shader;
|
|
992
|
+
}
|
|
993
|
+
function getUniform(gl, program, name) {
|
|
994
|
+
const loc = gl.getUniformLocation(program, name);
|
|
995
|
+
if (!loc) throw new Error(`uniform not found: ${name}`);
|
|
996
|
+
return loc;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/idle-motion.ts
|
|
1000
|
+
var import_format = require("@ikijs/format");
|
|
1001
|
+
|
|
1002
|
+
// src/frame-clock.ts
|
|
1003
|
+
var MAX_DT_MS = 100;
|
|
1004
|
+
var FIXED_DT_S = 1 / 60;
|
|
1005
|
+
var MAX_SUBSTEPS = 6;
|
|
1006
|
+
var FixedStepClock = class {
|
|
1007
|
+
prevNowMs = void 0;
|
|
1008
|
+
accumulatorS = 0;
|
|
1009
|
+
/**
|
|
1010
|
+
* True until the first {@link advance}. Drivers seed their rest pose on that
|
|
1011
|
+
* frame and integrate nothing, so a model loaded mid-motion does not kick.
|
|
1012
|
+
*/
|
|
1013
|
+
get isSeedFrame() {
|
|
1014
|
+
return this.prevNowMs === void 0;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Fold `nowMs` into the accumulator and return how many {@link FIXED_DT_S}
|
|
1018
|
+
* sub-steps to run this frame. On the seed frame it only records the
|
|
1019
|
+
* timestamp and returns 0. A non-monotonic `nowMs` floors to 0 — no rewind.
|
|
1020
|
+
*/
|
|
1021
|
+
advance(nowMs) {
|
|
1022
|
+
if (!Number.isFinite(nowMs)) return 0;
|
|
1023
|
+
if (this.prevNowMs === void 0) {
|
|
1024
|
+
this.prevNowMs = nowMs;
|
|
1025
|
+
return 0;
|
|
1026
|
+
}
|
|
1027
|
+
const dtMs = clamp(nowMs - this.prevNowMs, 0, MAX_DT_MS);
|
|
1028
|
+
this.prevNowMs = nowMs;
|
|
1029
|
+
this.accumulatorS += dtMs / 1e3;
|
|
1030
|
+
let steps = 0;
|
|
1031
|
+
while (this.accumulatorS >= FIXED_DT_S && steps < MAX_SUBSTEPS) {
|
|
1032
|
+
this.accumulatorS -= FIXED_DT_S;
|
|
1033
|
+
steps++;
|
|
1034
|
+
}
|
|
1035
|
+
return steps;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
// src/idle-motion.ts
|
|
1040
|
+
var BLINK_INTERVAL_MIN_MS = 1500;
|
|
1041
|
+
var BLINK_INTERVAL_MAX_MS = 6e3;
|
|
1042
|
+
var BLINK_DURATION_MS = 120;
|
|
1043
|
+
var BREATH_PERIOD_MS = 3500;
|
|
1044
|
+
var GAZE_RADIUS = 0.3;
|
|
1045
|
+
var GAZE_RETARGET_MIN_MS = 1200;
|
|
1046
|
+
var GAZE_RETARGET_MAX_MS = 3e3;
|
|
1047
|
+
var GAZE_EASE_RATE = 1e-3;
|
|
1048
|
+
var SWAY_X_AMP_A_DEG = 2.2;
|
|
1049
|
+
var SWAY_X_PERIOD_A_MS = 6100;
|
|
1050
|
+
var SWAY_X_AMP_B_DEG = 1.3;
|
|
1051
|
+
var SWAY_X_PERIOD_B_MS = 9700;
|
|
1052
|
+
var SWAY_Y_AMP_DEG = 1.6;
|
|
1053
|
+
var SWAY_Y_PERIOD_MS = 7300;
|
|
1054
|
+
function lerp(a, b, t) {
|
|
1055
|
+
return a + (b - a) * t;
|
|
1056
|
+
}
|
|
1057
|
+
function blinkEnvelope(phase) {
|
|
1058
|
+
return 2 * Math.abs(phase - 0.5);
|
|
1059
|
+
}
|
|
1060
|
+
function randomInDisk(rng, radius) {
|
|
1061
|
+
const r = Math.sqrt(rng()) * radius;
|
|
1062
|
+
const theta = rng() * 2 * Math.PI;
|
|
1063
|
+
return [r * Math.cos(theta), r * Math.sin(theta)];
|
|
1064
|
+
}
|
|
1065
|
+
var IdleMotion = class {
|
|
1066
|
+
sink;
|
|
1067
|
+
rng;
|
|
1068
|
+
// Internal clock: advances by clamped dt, NOT by raw wall-clock jumps.
|
|
1069
|
+
// This is the only clock blink/breath/gaze scheduling reads.
|
|
1070
|
+
clockMs = 0;
|
|
1071
|
+
prevNowMs = void 0;
|
|
1072
|
+
// Blink state
|
|
1073
|
+
nextBlinkAtMs;
|
|
1074
|
+
blinkStartMs = -1;
|
|
1075
|
+
// -1 means not currently blinking
|
|
1076
|
+
// Gaze state
|
|
1077
|
+
gazeCurrentX = 0;
|
|
1078
|
+
gazeCurrentY = 0;
|
|
1079
|
+
gazeTargetX = 0;
|
|
1080
|
+
gazeTargetY = 0;
|
|
1081
|
+
nextGazeRetargetMs;
|
|
1082
|
+
constructor(sink, options) {
|
|
1083
|
+
this.sink = sink;
|
|
1084
|
+
this.rng = options?.rng ?? Math.random;
|
|
1085
|
+
this.nextBlinkAtMs = lerp(
|
|
1086
|
+
BLINK_INTERVAL_MIN_MS,
|
|
1087
|
+
BLINK_INTERVAL_MAX_MS,
|
|
1088
|
+
this.rng()
|
|
1089
|
+
);
|
|
1090
|
+
this.nextGazeRetargetMs = lerp(
|
|
1091
|
+
GAZE_RETARGET_MIN_MS,
|
|
1092
|
+
GAZE_RETARGET_MAX_MS,
|
|
1093
|
+
this.rng()
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Advance the idle animation to the given wall-clock timestamp (milliseconds).
|
|
1098
|
+
*
|
|
1099
|
+
* On the first call: record prevNowMs, emit the resting pose, and return —
|
|
1100
|
+
* no animation advance happens so there is no jump from time 0.
|
|
1101
|
+
*
|
|
1102
|
+
* On subsequent calls: compute dt = clamp(nowMs - prevNowMs, 0, MAX_DT_MS)
|
|
1103
|
+
* and advance the internal clock by dt. A non-monotonic nowMs produces a
|
|
1104
|
+
* negative raw delta that the clamp floors to 0 — no rewind.
|
|
1105
|
+
*/
|
|
1106
|
+
update(nowMs) {
|
|
1107
|
+
if (!Number.isFinite(nowMs)) return;
|
|
1108
|
+
if (this.prevNowMs === void 0) {
|
|
1109
|
+
this.prevNowMs = nowMs;
|
|
1110
|
+
this.emitRestingPose();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const rawDt = nowMs - this.prevNowMs;
|
|
1114
|
+
const dt = clamp(rawDt, 0, MAX_DT_MS);
|
|
1115
|
+
this.prevNowMs = nowMs;
|
|
1116
|
+
this.clockMs += dt;
|
|
1117
|
+
const eyeVal = this.advanceBlink();
|
|
1118
|
+
this.sink(import_format.StandardParameter.EyeOpenLeft, eyeVal);
|
|
1119
|
+
this.sink(import_format.StandardParameter.EyeOpenRight, eyeVal);
|
|
1120
|
+
const breath = this.advanceBreath();
|
|
1121
|
+
this.advanceGaze(dt);
|
|
1122
|
+
this.sink(import_format.StandardParameter.Breath, breath);
|
|
1123
|
+
this.sink(import_format.StandardParameter.EyeballX, this.gazeCurrentX);
|
|
1124
|
+
this.sink(import_format.StandardParameter.EyeballY, this.gazeCurrentY);
|
|
1125
|
+
this.sink(import_format.StandardParameter.AngleX, this.swayX());
|
|
1126
|
+
this.sink(import_format.StandardParameter.AngleY, this.swayY());
|
|
1127
|
+
}
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
// Private helpers
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
emitRestingPose() {
|
|
1132
|
+
this.sink(import_format.StandardParameter.EyeOpenLeft, 1);
|
|
1133
|
+
this.sink(import_format.StandardParameter.EyeOpenRight, 1);
|
|
1134
|
+
this.sink(import_format.StandardParameter.Breath, 0.5);
|
|
1135
|
+
this.sink(import_format.StandardParameter.EyeballX, 0);
|
|
1136
|
+
this.sink(import_format.StandardParameter.EyeballY, 0);
|
|
1137
|
+
this.sink(import_format.StandardParameter.AngleX, 0);
|
|
1138
|
+
this.sink(import_format.StandardParameter.AngleY, 0);
|
|
1139
|
+
}
|
|
1140
|
+
/** Returns the current eye-open value (0..1) and advances blink state. */
|
|
1141
|
+
advanceBlink() {
|
|
1142
|
+
const { clockMs } = this;
|
|
1143
|
+
if (this.blinkStartMs >= 0) {
|
|
1144
|
+
const phase = (clockMs - this.blinkStartMs) / BLINK_DURATION_MS;
|
|
1145
|
+
if (phase >= 1) {
|
|
1146
|
+
this.blinkStartMs = -1;
|
|
1147
|
+
this.nextBlinkAtMs = clockMs + lerp(BLINK_INTERVAL_MIN_MS, BLINK_INTERVAL_MAX_MS, this.rng());
|
|
1148
|
+
return 1;
|
|
1149
|
+
}
|
|
1150
|
+
return blinkEnvelope(phase);
|
|
1151
|
+
}
|
|
1152
|
+
if (clockMs >= this.nextBlinkAtMs) {
|
|
1153
|
+
this.blinkStartMs = clockMs;
|
|
1154
|
+
return blinkEnvelope(0);
|
|
1155
|
+
}
|
|
1156
|
+
return 1;
|
|
1157
|
+
}
|
|
1158
|
+
advanceBreath() {
|
|
1159
|
+
return 0.5 + 0.5 * Math.sin(2 * Math.PI * this.clockMs / BREATH_PERIOD_MS);
|
|
1160
|
+
}
|
|
1161
|
+
/** Horizontal head sway in degrees, pure function of the internal clock. */
|
|
1162
|
+
swayX() {
|
|
1163
|
+
const t = 2 * Math.PI * this.clockMs;
|
|
1164
|
+
return SWAY_X_AMP_A_DEG * Math.sin(t / SWAY_X_PERIOD_A_MS) + SWAY_X_AMP_B_DEG * Math.sin(t / SWAY_X_PERIOD_B_MS);
|
|
1165
|
+
}
|
|
1166
|
+
/** Vertical head sway in degrees, pure function of the internal clock. */
|
|
1167
|
+
swayY() {
|
|
1168
|
+
return SWAY_Y_AMP_DEG * Math.sin(2 * Math.PI * this.clockMs / SWAY_Y_PERIOD_MS);
|
|
1169
|
+
}
|
|
1170
|
+
/** Ease gaze current toward target; pick a new target on the internal clock. */
|
|
1171
|
+
advanceGaze(dt) {
|
|
1172
|
+
if (this.clockMs >= this.nextGazeRetargetMs) {
|
|
1173
|
+
const [tx, ty] = randomInDisk(this.rng, GAZE_RADIUS);
|
|
1174
|
+
this.gazeTargetX = tx;
|
|
1175
|
+
this.gazeTargetY = ty;
|
|
1176
|
+
this.nextGazeRetargetMs = this.clockMs + lerp(GAZE_RETARGET_MIN_MS, GAZE_RETARGET_MAX_MS, this.rng());
|
|
1177
|
+
}
|
|
1178
|
+
const factor = 1 - Math.pow(1 - GAZE_EASE_RATE, dt);
|
|
1179
|
+
this.gazeCurrentX = lerp(this.gazeCurrentX, this.gazeTargetX, factor);
|
|
1180
|
+
this.gazeCurrentY = lerp(this.gazeCurrentY, this.gazeTargetY, factor);
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
// src/physics-motion.ts
|
|
1185
|
+
function signedNormalized(value, param) {
|
|
1186
|
+
const rest = clamp(param.default, param.min, param.max);
|
|
1187
|
+
const den = Math.max(Math.abs(param.max - rest), Math.abs(rest - param.min));
|
|
1188
|
+
if (den === 0) return 0;
|
|
1189
|
+
return clamp((value - rest) / den, -1, 1);
|
|
1190
|
+
}
|
|
1191
|
+
var PhysicsMotion = class {
|
|
1192
|
+
rigs;
|
|
1193
|
+
read;
|
|
1194
|
+
sink;
|
|
1195
|
+
params;
|
|
1196
|
+
// Integrator state, owned here — never stored in a ParameterStore.
|
|
1197
|
+
state;
|
|
1198
|
+
clock = new FixedStepClock();
|
|
1199
|
+
constructor(rigs, params, read, sink) {
|
|
1200
|
+
this.rigs = rigs;
|
|
1201
|
+
this.read = read;
|
|
1202
|
+
this.sink = sink;
|
|
1203
|
+
this.params = new Map(params.map((p) => [p.id, p]));
|
|
1204
|
+
this.state = rigs.map(() => ({ x: 0, v: 0 }));
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Advance every rig to the given wall-clock timestamp (milliseconds).
|
|
1208
|
+
*
|
|
1209
|
+
* First call: seed each spring to rest AT its current target (so a model
|
|
1210
|
+
* loaded with a nonzero input does not kick), emit the resting output, and
|
|
1211
|
+
* return without integrating — mirroring IdleMotion's first-frame behavior.
|
|
1212
|
+
*
|
|
1213
|
+
* Subsequent calls: {@link FixedStepClock} folds the clamped frame delta into
|
|
1214
|
+
* its accumulator and returns how many {@link FIXED_DT_S} sub-steps are due;
|
|
1215
|
+
* the spring advances that many semi-implicit Euler steps, then each rig emits
|
|
1216
|
+
* its output once. The clock's dt clamp and sub-step cap plus the symplectic
|
|
1217
|
+
* integrator are what keep it stable across hitches.
|
|
1218
|
+
*/
|
|
1219
|
+
update(nowMs) {
|
|
1220
|
+
if (this.clock.isSeedFrame) {
|
|
1221
|
+
this.clock.advance(nowMs);
|
|
1222
|
+
for (let i = 0; i < this.rigs.length; i++) {
|
|
1223
|
+
const st = this.state[i];
|
|
1224
|
+
st.x = this.targetFor(this.rigs[i]);
|
|
1225
|
+
st.v = 0;
|
|
1226
|
+
this.emit(this.rigs[i], st);
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const steps = this.clock.advance(nowMs);
|
|
1231
|
+
const targets = this.rigs.map((rig) => this.targetFor(rig));
|
|
1232
|
+
for (let s = 0; s < steps; s++) {
|
|
1233
|
+
for (let i = 0; i < this.rigs.length; i++) {
|
|
1234
|
+
this.step(this.rigs[i], this.state[i], targets[i]);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
for (let i = 0; i < this.rigs.length; i++) {
|
|
1238
|
+
const st = this.state[i];
|
|
1239
|
+
if (!Number.isFinite(st.x) || !Number.isFinite(st.v)) {
|
|
1240
|
+
st.x = Number.isFinite(targets[i]) ? targets[i] : 0;
|
|
1241
|
+
st.v = 0;
|
|
1242
|
+
}
|
|
1243
|
+
this.emit(this.rigs[i], st);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/** Spring target = signed-normalized input value × weight. */
|
|
1247
|
+
targetFor(rig) {
|
|
1248
|
+
const param = this.params.get(rig.input.parameter);
|
|
1249
|
+
const value = this.read(rig.input.parameter);
|
|
1250
|
+
const norm = param ? signedNormalized(value, param) : 0;
|
|
1251
|
+
return norm * rig.input.weight;
|
|
1252
|
+
}
|
|
1253
|
+
/** One semi-implicit (symplectic) Euler sub-step of FIXED_DT_S seconds. */
|
|
1254
|
+
step(rig, st, target) {
|
|
1255
|
+
const accel = (rig.stiffness * (target - st.x) - rig.damping * st.v) / rig.mass;
|
|
1256
|
+
st.v += accel * FIXED_DT_S;
|
|
1257
|
+
st.x += st.v * FIXED_DT_S;
|
|
1258
|
+
}
|
|
1259
|
+
/** Write outputDefault + x * scale onto the output param via the sink. */
|
|
1260
|
+
emit(rig, st) {
|
|
1261
|
+
const outParam = this.params.get(rig.output.parameter);
|
|
1262
|
+
const outDefault = outParam ? clamp(outParam.default, outParam.min, outParam.max) : 0;
|
|
1263
|
+
const value = outDefault + st.x * rig.output.scale;
|
|
1264
|
+
this.sink(
|
|
1265
|
+
rig.output.parameter,
|
|
1266
|
+
Number.isFinite(value) ? value : outDefault
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// src/hair-chain-motion.ts
|
|
1272
|
+
var DEG2RAD = Math.PI / 180;
|
|
1273
|
+
var RAD2DEG = 180 / Math.PI;
|
|
1274
|
+
var HairChainMotion = class {
|
|
1275
|
+
chainData;
|
|
1276
|
+
params;
|
|
1277
|
+
deformers;
|
|
1278
|
+
store;
|
|
1279
|
+
read;
|
|
1280
|
+
sink;
|
|
1281
|
+
clock = new FixedStepClock();
|
|
1282
|
+
constructor(chains, params, deformers, read, sink) {
|
|
1283
|
+
this.params = new Map(params.map((p) => [p.id, p]));
|
|
1284
|
+
this.deformers = deformers;
|
|
1285
|
+
this.store = new ParameterStore(params);
|
|
1286
|
+
this.read = read;
|
|
1287
|
+
this.sink = sink;
|
|
1288
|
+
this.chainData = chains.map((chain) => ({
|
|
1289
|
+
chain,
|
|
1290
|
+
restAnglesRad: chain.segments.map(
|
|
1291
|
+
(seg) => seg.restAngle !== void 0 ? seg.restAngle * DEG2RAD : 0
|
|
1292
|
+
),
|
|
1293
|
+
state: chain.segments.map(() => ({ angle: 0, angularVelocity: 0 }))
|
|
1294
|
+
}));
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Advance every chain to the given wall-clock timestamp (milliseconds).
|
|
1298
|
+
*
|
|
1299
|
+
* First call: seed every segment to θ=0/ω=0 (rest), emit the rest output
|
|
1300
|
+
* (outDefault + 0), and return without integrating — mirrors PhysicsMotion's
|
|
1301
|
+
* first-frame behavior so a model loaded in motion does not kick.
|
|
1302
|
+
*
|
|
1303
|
+
* Subsequent calls: dt = clamp(nowMs - prevNowMs, 0, MAX_DT_MS) → seconds into
|
|
1304
|
+
* accumulator. The per-frame world snapshot (anchor world angles) is taken ONCE
|
|
1305
|
+
* per update() — NOT per chain — so all chains share a consistent frame snapshot.
|
|
1306
|
+
* Fixed FIXED_DT_S sub-steps are run root→tip, capped at MAX_SUBSTEPS; leftover
|
|
1307
|
+
* time is carried to the next frame. Segments emit after substeps (even on zero
|
|
1308
|
+
* substeps) with a non-finite guard.
|
|
1309
|
+
*/
|
|
1310
|
+
update(nowMs) {
|
|
1311
|
+
if (this.clock.isSeedFrame) {
|
|
1312
|
+
this.clock.advance(nowMs);
|
|
1313
|
+
for (const cd of this.chainData) {
|
|
1314
|
+
for (let i = 0; i < cd.chain.segments.length; i++) {
|
|
1315
|
+
cd.state[i].angle = 0;
|
|
1316
|
+
cd.state[i].angularVelocity = 0;
|
|
1317
|
+
this.emitSegment(cd.chain.segments[i], cd.state[i]);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const steps = this.clock.advance(nowMs);
|
|
1323
|
+
for (const param of this.params.values()) {
|
|
1324
|
+
this.store.set(param.id, this.read(param.id));
|
|
1325
|
+
}
|
|
1326
|
+
const worldMap = resolveDeformerWorlds(this.deformers, this.store);
|
|
1327
|
+
const anchorAnglesRad = this.chainData.map(
|
|
1328
|
+
(cd) => this.anchorWorldAngleRad(worldMap, cd.chain.anchorDeformer)
|
|
1329
|
+
);
|
|
1330
|
+
for (let s = 0; s < steps; s++) {
|
|
1331
|
+
for (let c = 0; c < this.chainData.length; c++) {
|
|
1332
|
+
this.stepChain(this.chainData[c], anchorAnglesRad[c]);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
for (const cd of this.chainData) {
|
|
1336
|
+
for (let i = 0; i < cd.chain.segments.length; i++) {
|
|
1337
|
+
const st = cd.state[i];
|
|
1338
|
+
if (!Number.isFinite(st.angle) || !Number.isFinite(st.angularVelocity)) {
|
|
1339
|
+
st.angle = 0;
|
|
1340
|
+
st.angularVelocity = 0;
|
|
1341
|
+
}
|
|
1342
|
+
this.emitSegment(cd.chain.segments[i], st);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Extract world rotation (radians) from the anchor's Affine tuple.
|
|
1348
|
+
* Affine = [a,b,c,d,e,f]; rotation column = (a,b) → atan2(b,a).
|
|
1349
|
+
*
|
|
1350
|
+
* If the anchor id is absent from the map, THROWS an internal Error — the
|
|
1351
|
+
* format validator guarantees the anchor exists, so absence is an invariant
|
|
1352
|
+
* break (mirrors resolveDeformerWorlds' throw on an unresolved parent,
|
|
1353
|
+
* deform.ts:141).
|
|
1354
|
+
*/
|
|
1355
|
+
anchorWorldAngleRad(worldMap, anchorId) {
|
|
1356
|
+
const world = worldMap.get(anchorId);
|
|
1357
|
+
if (!world) {
|
|
1358
|
+
throw new Error(
|
|
1359
|
+
`HairChainMotion: anchor deformer "${anchorId}" not found in resolved world map \u2014 model not validated?`
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
return Math.atan2(world[1], world[0]);
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* One fixed sub-step of FIXED_DT_S seconds for all segments in a chain.
|
|
1366
|
+
*
|
|
1367
|
+
* Segments are integrated ROOT→TIP so each segment can read its upstream
|
|
1368
|
+
* neighbor's current-substep state when computing the world angle Φ_i.
|
|
1369
|
+
* (The chain is causal root-to-tip; reversing the order would use stale θ
|
|
1370
|
+
* values from the previous substep for Φ_i computation.)
|
|
1371
|
+
*
|
|
1372
|
+
* Per-segment semi-implicit (symplectic) Euler:
|
|
1373
|
+
* Φ_i = anchorWorldAngleRad + Σ_{j≤i}(restAngle_j + θ_j)
|
|
1374
|
+
* α_i = (−stiffness_i·θ_i − strength·sin(Φ_i − gravityAngle_rad) − damping_i·ω_i) / mass_i
|
|
1375
|
+
* ω_i += α_i · FIXED_DT_S (velocity updated FIRST = semi-implicit)
|
|
1376
|
+
* θ_i += ω_i · FIXED_DT_S (position updated from NEW velocity)
|
|
1377
|
+
*
|
|
1378
|
+
* The spring term is −stiffness·θ (restoring θ→0); restAngle does NOT appear
|
|
1379
|
+
* in the spring term, only in Φ_i for the gravity torque.
|
|
1380
|
+
*/
|
|
1381
|
+
stepChain(cd, anchorAngleRad) {
|
|
1382
|
+
const { chain, restAnglesRad, state } = cd;
|
|
1383
|
+
const gravityAngleRad = chain.gravity.angle * DEG2RAD;
|
|
1384
|
+
const strength = chain.gravity.strength;
|
|
1385
|
+
let worldAngleAccumRad = anchorAngleRad;
|
|
1386
|
+
for (let i = 0; i < chain.segments.length; i++) {
|
|
1387
|
+
const seg = chain.segments[i];
|
|
1388
|
+
const st = state[i];
|
|
1389
|
+
worldAngleAccumRad += restAnglesRad[i] + st.angle;
|
|
1390
|
+
const phi = worldAngleAccumRad;
|
|
1391
|
+
const alpha = (-seg.stiffness * st.angle - strength * Math.sin(phi - gravityAngleRad) - seg.damping * st.angularVelocity) / seg.mass;
|
|
1392
|
+
st.angularVelocity += alpha * FIXED_DT_S;
|
|
1393
|
+
st.angle += st.angularVelocity * FIXED_DT_S;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/** Emit outDefault + (θ_i · RAD2DEG) · scale for one segment. */
|
|
1397
|
+
emitSegment(seg, st) {
|
|
1398
|
+
const outParam = this.params.get(seg.output.parameter);
|
|
1399
|
+
const outDefault = outParam ? clamp(outParam.default, outParam.min, outParam.max) : 0;
|
|
1400
|
+
const value = outDefault + st.angle * RAD2DEG * seg.output.scale;
|
|
1401
|
+
this.sink(
|
|
1402
|
+
seg.output.parameter,
|
|
1403
|
+
Number.isFinite(value) ? value : outDefault
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1408
|
+
0 && (module.exports = {
|
|
1409
|
+
HairChainMotion,
|
|
1410
|
+
IdleMotion,
|
|
1411
|
+
IkiPlayer,
|
|
1412
|
+
ParameterStore,
|
|
1413
|
+
PhysicsMotion,
|
|
1414
|
+
multiply,
|
|
1415
|
+
rotate,
|
|
1416
|
+
scale,
|
|
1417
|
+
toMat3,
|
|
1418
|
+
translate
|
|
1419
|
+
});
|
|
1420
|
+
//# sourceMappingURL=index.js.map
|