reze-engine 0.68.5 → 0.68.7

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.
@@ -3,36 +3,106 @@
3
3
  // What every working version of the effect has in common, whether it is keyed
4
4
  // by hand in MMD, a pendulum in Live2D, or a jiggle bone in Blender: an INPUT
5
5
  // (a blink, a turn), something that REMEMBERS the last frame (a spring), and a
6
- // cheap OUTPUT. Here the output is the iris's SCALE, in place: it shrinks and
7
- // springs back as a lid moves (opening too), swells as her head turns or moves, and
8
- // wobbles back. It never slides — tried, it read as the iris coming loose. The
9
- // inputs are her blinks and her head, never her eyes' own turn: eye tracking
10
- // turns the eyes after the camera, and the camera must not shake the jelly.
6
+ // cheap OUTPUT. Here the output is the iris's shape, in place.
7
+ //
8
+ // The blink is the MME 果冻眼's (JellyEye V1.2.2, by 茶叶味香皂): a spring
9
+ // driven by how FAST the lids move. The iris widens while they close and rings
10
+ // out once they stop, kicked again at each change of pace; the lid presses it
11
+ // flatter as it closes; a twist and a drift of a hair ride the spring. On top,
12
+ // her head's motion and an idle tap now and then knock it, as before. A
13
+ // highlight drawn as its own layer over the iris (a separate material, as MME
14
+ // rigs have) follows the iris, then trembles on its own: a new random spot ten
15
+ // times a second, no easing — the jiggle the MME is known for. The inputs are
16
+ // her blinks and her head, never her eyes' own turn: eye tracking turns the
17
+ // eyes after the camera, and the camera must not shake the jelly.
11
18
  //
12
19
  // The springs run on the CPU, stepped by the engine's frame delta — the same
13
20
  // exact delta renderFrame() hands an export, so a blink bounces identically on
14
- // screen and in the file. Their state rides in the model's ObjectLight
15
- // (jelly[0..5], see materials/common.ts): the vertex shader scales the iris —
16
- // the vertices the eye bones carry — about its centre, and the eye render class
17
- // adds the gem look after its graph (glints, light through the iris, deeper
18
- // colour), so every eye graph becomes jelly without knowing it.
21
+ // screen and in the file. Their result rides in the model's ObjectLight
22
+ // (jelly[0..11], see materials/common.ts): the vertex shader moves the iris —
23
+ // the vertices the eye bones carry — about its centre, so every eye graph
24
+ // becomes jelly without knowing it. Nothing changes its colour.
19
25
  // The closing morphs, by the names MMD, VRoid and the usual converters give
20
26
  // them. Group morphs (a smile that closes the eyes) resolve into these through
21
27
  // the effective weights, so only the leaves need naming.
22
28
  const BOTH = ["まばたき", "笑い", "はぅ", "blink", "Blink", "eye_close", "Fcl_EYE_Close"];
23
29
  const LEFT = ["ウィンク", "ウィンク2", "ウィンク", "ウィンク2", "wink", "Wink", "blink_L", "Fcl_EYE_Close_L"];
24
30
  const RIGHT = ["ウィンク右", "ウィンク2右", "ウィンク右", "ウィンク2右", "wink_R", "Wink_R", "blink_R", "Fcl_EYE_Close_R"];
25
- // The squash: how far a closed eye presses the picture, and the spring that
26
- // follows it — lightly damped, so one knock ripples: the squash, a rebound past
27
- // rest, and several smaller wobbles before it settles. One dip and done read as
28
- // a scale, not a jelly; the ripple is the "Duang" the effect is named for.
29
- // Only a CHANGE of closure squashes: the eye's closure less a slow follower of
30
- // it, so a blink squeezes then bounces, while a held half-close (a smile)
31
- // leaves the iris at rest instead of squashed for as long as she smiles.
32
- const SQUASH_CLOSED = 0.35;
31
+ // The blink, as the MME runs it (its JellyEye_Settings.fxsub defaults). The
32
+ // eye's closure (0..1) is differentiated twice. Its speed drives a target the
33
+ // spring chases: CLOSE_DRIVE while closing, OPEN_DRIVE while opening, eased by
34
+ // how fast. Every change between gaining and losing pace kicks the spring's
35
+ // velocity. The spring is an exact under-damped step at SPRING_HZ whose
36
+ // envelope halves every HALF_LIFE.
37
+ const ACCEL_MIN = 0.5;
38
+ const ACCEL_FULL = 2;
39
+ const ACCEL_KICK = 0.32;
40
+ const RELEASE_KICK = 0.2;
41
+ const CLOSE_DRIVE = 0.85;
42
+ const OPEN_DRIVE = 0.2;
43
+ const DRIVE_SPEED_MIN = 0.04;
44
+ const DRIVE_SPEED_FULL = 2.5;
45
+ const DRIVE_CURVE = 0.85;
46
+ const MORPH_SPEED_LIMIT = 30;
47
+ const ACCEL_LIMIT = 240;
48
+ const MAX_STEP = 0.12;
49
+ const SPRING_HZ = 3;
50
+ const HALF_LIFE = 0.28;
51
+ const ELASTIC_S = 2;
52
+ const STOP_EPS = 0.0025;
53
+ const MIN_RELEASE = 0.12;
54
+ const SPRING_LIMIT = 1.25;
55
+ // What the spring does to the iris, per unit of it: wider far more than
56
+ // taller, a little less on the rebound side; the lid's press, flatter by
57
+ // SQUEEZE_Y at full closure; a twist (degrees); and a drift (iris radii: the
58
+ // MME's model units over a typical iris of 0.1). Twist and drift take both the
59
+ // spring's place (_POS) and its velocity (_VEL). The twist is ten and five
60
+ // times the MME's defaults (0.2°, 0.8°): at those it stays under a degree and
61
+ // the iris never visibly turns as it rings.
62
+ const SCALE_GAIN = [0.2, 0.05];
63
+ const REBOUND = [0.7, 0.8];
64
+ const SQUEEZE_Y = 0.18;
65
+ const TWIST_POS = 2;
66
+ const TWIST_VEL = 4;
67
+ const DRIFT_POS = [0.008, 0.015];
68
+ const DRIFT_VEL = [0.012, 0.006];
69
+ // The highlight layer's own motion: a held random spot, re-drawn JITTER_HZ
70
+ // times a second per axis, up to JITTER of an iris radius; and, on the spring,
71
+ // a further sway (iris radii) and turn (degrees) of its own.
72
+ const JITTER_HZ = [9, 11];
73
+ const JITTER = [0.03, 0.02];
74
+ const HL_SWAY = [0.03, 0.045];
75
+ const HL_TURN = 10;
76
+ const HL_SCALE = [0.055, 0.045];
77
+ const HL_SCALE_MIN = 0.85;
78
+ const HL_SCALE_MAX = 1.2;
79
+ // The roll: while a closing expression holds, the iris turns about its eye
80
+ // bone's X axis (左目/右目, the eyeball's centre), rolling down with the lid —
81
+ // the angle at weight 1, and simultaneous expressions add. And it comes
82
+ // forward a little (iris radii) with the expression and with the spring, so
83
+ // the roll does not sink it into the white. The MME rolls with the lid
84
+ // exactly, which the lid hides; here the roll follows on a spring of the same
85
+ // pace and decay as the blink's, so as the eye opens the iris swings up past
86
+ // rest and wobbles back, where it can be seen.
87
+ const ROLL = {
88
+ まばたき: -15, blink: -15, Blink: -15, eye_close: -15, Fcl_EYE_Close: -15, はぅ: -15, 笑い: -5,
89
+ ウィンク: -5, ウィンク: -5, wink: -5, Wink: -5, ウィンク2: -15, ウィンク2: -15, blink_L: -15, Fcl_EYE_Close_L: -15,
90
+ ウィンク右: -5, ウィンク右: -5, wink_R: -5, Wink_R: -5, ウィンク2右: -15, ウィンク2右: -15, blink_R: -15, Fcl_EYE_Close_R: -15,
91
+ };
92
+ const ROLL_HZ = SPRING_HZ;
93
+ const ROLL_ZETA = Math.LN2 / HALF_LIFE / (2 * Math.PI * SPRING_HZ);
94
+ // How much of the spring's swing about the expression angle shows: the whole
95
+ // of it carried the iris up most of its radius after a blink.
96
+ const ROLL_SWING = 0.4;
97
+ const PUSH_EXPRESSION = 0.15;
98
+ const PUSH_ELASTIC = 0.04;
99
+ // A highlight sheet by name, for rigs that keep it on its own material.
100
+ const HIGHLIGHT = /ハイライト|ハイライト|高光|highlight|hilight|(^|[^a-z])hl([^a-z]|$)/i;
101
+ const DEG = Math.PI / 180;
102
+ // The knock's squash (her motion, the idle taps): nearly round, lightly damped
103
+ // so one knock ripples.
33
104
  const SQUASH_HZ = 1.8;
34
105
  const SQUASH_ZETA = 0.08;
35
- const CLOSURE_FOLLOW_S = 0.35;
36
106
  // The lag: the picture trails the eye's turn and catches up past it. Radians of
37
107
  // lag to eye radii of offset; the head's own turn counts for a third, so a
38
108
  // sudden stop of the head wobbles the eyes without a dance keeping them
@@ -70,16 +140,21 @@ const PULSE_REF = 0.006;
70
140
  const MOTION_SQUASH = 0.18;
71
141
  const PULSE_HZ = 2.0;
72
142
  const PULSE_ZETA = 0.08;
73
- // The idle taps: every few seconds, at uneven times, a small knock into the
143
+ // The idle taps: every 4–8 s, at uneven times, a knock into the
74
144
  // same springs her motion drives — the iris dips, bounces and settles, then
75
145
  // rests — the way a jelly on a table is never quite still. A steady sine was
76
146
  // tried, as size (a zoom) and as wider-then-taller (queasy); a settling tap is
77
- // what reads as jelly. Velocity kicks, so the bounce keeps the springs' own
78
- // timing; the gaps come from a hash of the tap's count, so the scene clock
79
- // alone decides them and an export taps where the screen did.
80
- const IDLE_SQUASH_KICK = 0.45;
81
- const IDLE_PULSE_KICK = 0.25;
82
- const IDLE_GAP = [1.8, 3.8];
147
+ // what reads as jelly. A tap is a full KNOCK, exactly what her motion gives at
148
+ // its strongest — a gentler nudge was tried and went unnoticed — held for a
149
+ // moment and let go, so it rings through the same springs. The gaps come from
150
+ // a hash of the tap's count, so the scene clock alone decides them and an
151
+ // export taps where the screen did.
152
+ const IDLE_TAP_S = 0.12;
153
+ // How hard a tap knocks (motion's strongest is 1) and how far apart they fall:
154
+ // a full knock every 2–4 s read as the iris zooming constantly; rarer, a touch
155
+ // softer, each one reads as a ripple and the eye rests between.
156
+ const IDLE_TAP = 0.6;
157
+ const IDLE_GAP = [4, 8];
83
158
  const idleGap = (n) => {
84
159
  const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
85
160
  return IDLE_GAP[0] + (x - Math.floor(x)) * (IDLE_GAP[1] - IDLE_GAP[0]);
@@ -89,14 +164,20 @@ const SUBSTEP = 1 / 240;
89
164
  const SEEK = 0.25;
90
165
  const newState = () => ({
91
166
  closure: 0,
92
- settled: 0,
167
+ prev: 0,
168
+ speed: 0,
169
+ trend: 0,
170
+ x: 0,
171
+ v: 0,
172
+ age: ELASTIC_S,
93
173
  squash: 0,
94
174
  squashV: 0,
95
175
  lag: [0, 0],
96
176
  lagV: [0, 0],
97
177
  look: [0, 0],
98
- shine: 0,
99
178
  pulse: 0,
179
+ roll: 0,
180
+ rollV: 0,
100
181
  pulseV: 0,
101
182
  });
102
183
  export class JellyEyes {
@@ -105,9 +186,12 @@ export class JellyEyes {
105
186
  this.clock = 0;
106
187
  this.taps = 0;
107
188
  this.nextTap = idleGap(0);
189
+ // The current tap's knock, 1 at the tap and fading over IDLE_TAP_S.
190
+ this.tapKnock = 0;
108
191
  this.state = [newState(), newState()];
109
192
  this.fresh = true;
110
193
  this.inertia = { p: [0, 0, 0], v: [0, 0, 0], head: [0, 0, 0], offset: [0, 0] };
194
+ this.roll = [0, 0];
111
195
  this.strength = clampStrength(options.strength);
112
196
  this.idle = options.idle ?? true;
113
197
  const bones = model.getSkeleton().bones;
@@ -115,26 +199,52 @@ export class JellyEyes {
115
199
  const both = index("両目");
116
200
  const l = index("左目");
117
201
  const r = index("右目");
118
- this.eyeBones = [l >= 0 ? l : both, r >= 0 ? r : both];
119
202
  this.headBone = index("頭");
120
203
  const names = model.getMorphing().morphs.map((m) => m.name);
121
204
  const find = (list) => list.map((n) => names.indexOf(n)).filter((i) => i >= 0);
122
205
  this.morphs = [find(BOTH), find(LEFT), find(RIGHT)];
206
+ const rolls = (list) => list.map((n) => ({ morph: names.indexOf(n), deg: ROLL[n] ?? 0 })).filter((r) => r.morph >= 0 && r.deg !== 0);
207
+ this.rolls = [rolls([...BOTH, ...LEFT]), rolls([...BOTH, ...RIGHT])];
123
208
  this.rest = findIrises(model, l, r, both);
209
+ // The bone that carries each iris, which may be under the eye bone.
210
+ this.eyeBones = [this.rest[0].bone, this.rest[1].bone];
211
+ this.shifts = [irisShifts(model, this.rest[0].verts), irisShifts(model, this.rest[1].verts)];
212
+ this.centre = [[...this.rest[0].centre], [...this.rest[1].centre]];
213
+ this.highlights = findHighlights(model, this.rest);
124
214
  }
125
215
  /** Advance both springs by dt seconds of scene time. */
126
216
  step(dt, model) {
127
217
  const weights = model.getEffectiveMorphWeights();
218
+ for (let e = 0; e < 2; e++) {
219
+ const c = this.centre[e];
220
+ const r = this.rest[e].centre;
221
+ c[0] = r[0];
222
+ c[1] = r[1];
223
+ c[2] = r[2];
224
+ for (const { morph, d } of this.shifts[e]) {
225
+ const w = weights[morph] ?? 0;
226
+ if (w === 0)
227
+ continue;
228
+ c[0] += d[0] * w;
229
+ c[1] += d[1] * w;
230
+ c[2] += d[2] * w;
231
+ }
232
+ }
128
233
  const sum = (list) => list.reduce((s, i) => s + (weights[i] ?? 0), 0);
129
234
  const both = sum(this.morphs[0]);
130
235
  const closure = [Math.min(1, both + sum(this.morphs[1])), Math.min(1, both + sum(this.morphs[2]))];
236
+ for (let e = 0; e < 2; e++)
237
+ this.roll[e] = this.rolls[e].reduce((a, r) => a + r.deg * Math.max(0, Math.min(1, weights[r.morph] ?? 0)), 0);
131
238
  const head = this.headBone >= 0 ? model.getBoneWorldMatrixAt(this.headBone) : null;
132
239
  const seek = this.fresh || dt > SEEK || dt < 0;
133
240
  if (dt > 0)
134
241
  this.clock += dt;
135
- let tap = false;
136
- if (!seek && this.idle && this.clock >= this.nextTap) {
137
- tap = true;
242
+ if (dt > 0)
243
+ this.tapKnock *= Math.exp(-dt / IDLE_TAP_S);
244
+ if (seek)
245
+ this.tapKnock = 0;
246
+ else if (this.idle && this.clock >= this.nextTap) {
247
+ this.tapKnock = IDLE_TAP;
138
248
  this.taps++;
139
249
  this.nextTap = this.clock + idleGap(this.taps);
140
250
  }
@@ -147,28 +257,24 @@ export class JellyEyes {
147
257
  // Only her motion — the head turning and moving (the inertia) — and her
148
258
  // blinks do.
149
259
  look(head, null, s.look);
150
- if (seek)
151
- s.settled = s.closure;
152
- else
153
- s.settled += (s.closure - s.settled) * Math.min(1, dt / CLOSURE_FOLLOW_S);
154
- // Either way a lid moves squashes — opening too, which is the half of a
155
- // blink you can see (closing, the lid hides the iris anyway).
156
- const knock = seek ? 0 : Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF);
157
- const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled) + MOTION_SQUASH * knock;
260
+ const knock = seek ? 0 : Math.max(this.tapKnock, Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF));
261
+ const squashTarget = MOTION_SQUASH * knock;
158
262
  if (seek) {
263
+ // Landing on a pose is no blink: the spring rests where it lands.
264
+ s.prev = s.closure;
265
+ s.speed = s.trend = s.x = s.v = 0;
266
+ s.age = ELASTIC_S;
159
267
  s.squash = squashTarget;
160
268
  s.squashV = 0;
161
269
  s.lag[0] = s.look[0];
162
270
  s.lag[1] = s.look[1];
163
271
  s.lagV[0] = s.lagV[1] = 0;
164
- s.shine = 0;
165
272
  s.pulse = s.pulseV = 0;
273
+ s.roll = this.roll[e];
274
+ s.rollV = 0;
166
275
  continue;
167
276
  }
168
- if (tap) {
169
- s.squashV += IDLE_SQUASH_KICK;
170
- s.pulseV -= IDLE_PULSE_KICK;
171
- }
277
+ blink(s, dt);
172
278
  const steps = Math.ceil(dt / SUBSTEP);
173
279
  const h = steps > 0 ? dt / steps : 0;
174
280
  const pulseTarget = PULSE_MAX * knock;
@@ -177,31 +283,59 @@ export class JellyEyes {
177
283
  s.pulse += h * s.pulseV;
178
284
  s.squashV += h * spring(s.squash - squashTarget, s.squashV, SQUASH_HZ, SQUASH_ZETA);
179
285
  s.squash += h * s.squashV;
286
+ s.rollV += h * spring(s.roll - this.roll[e], s.rollV, ROLL_HZ, ROLL_ZETA);
287
+ s.roll += h * s.rollV;
180
288
  for (let k = 0; k < 2; k++) {
181
289
  s.lagV[k] += h * spring(s.lag[k] - s.look[k], s.lagV[k], LAG_HZ, LAG_ZETA);
182
290
  s.lag[k] += h * s.lagV[k];
183
291
  }
184
292
  }
185
- // Shine follows the motion: bright while the jelly moves, fading as it
186
- // settles, eased so a single wobble does not strobe.
187
- const motion = Math.min(1, 2.5 * Math.abs(s.squash - squashTarget) + 4 * Math.hypot(...this.offset(s, e)));
188
- s.shine += (motion - s.shine) * Math.min(1, dt * 12);
189
293
  }
190
294
  this.fresh = false;
191
295
  }
192
- /** Write jelly[0..5] into an ObjectLight copy (floats 48..71). */
296
+ /** Write jelly[0..11] into an ObjectLight copy (floats 48..95). */
193
297
  write(o) {
194
- const [L, R] = this.rest;
195
- o.set([L.centre[0], L.centre[1], L.centre[2], this.strength], 48);
196
- // One scale bounce for both: the mean of the two eyes', so they swell together.
197
- const pulse = ((this.state[0].pulse + this.state[1].pulse) / 2) * this.strength;
198
- o.set([R.centre[0], R.centre[1], R.centre[2], pulse], 52);
298
+ const [L, R] = this.centre;
299
+ const g = this.strength;
300
+ o.set([L[0], L[1], L[2], g], 48);
301
+ // The highlight layers' draw ids, two packed in one float (exact to 4095).
302
+ const [a = 0, b = 0] = this.highlights;
303
+ o.set([R[0], R[1], R[2], a + b * 4096], 52);
304
+ // One knock swell for both: the mean of the two eyes', so they swell together.
305
+ const pulse = (this.state[0].pulse + this.state[1].pulse) / 2;
306
+ const omega = 2 * Math.PI * SPRING_HZ;
307
+ // Strength scales every departure from rest, then the bounds hold: the
308
+ // swell under 1.08 tall so it cannot rise past the upper lid.
309
+ const by = (k, lo, hi) => Math.max(lo, Math.min(hi, 1 + (k - 1) * g));
199
310
  for (let e = 0; e < 2; e++) {
200
311
  const s = this.state[e];
201
312
  const rest = this.rest[e];
313
+ const r = (rest.radius[0] + rest.radius[1]) / 2;
314
+ // The MME spring's place, and its velocity in the same units.
315
+ const x = s.x;
316
+ const vn = s.v / omega;
317
+ const gain = (k) => SCALE_GAIN[k] * (x >= 0 ? 1 : REBOUND[k]);
318
+ const kx = (1 + x * gain(0)) * (1 - 0.8 * s.squash + pulse);
319
+ const ky = (1 + x * gain(1)) * (1 - SQUEEZE_Y * s.closure) * (1 - s.squash + pulse);
202
320
  o.set([rest.radius[0], rest.radius[1], s.closure, this.eyeBones[e]], 56 + e * 4);
203
- // Offset words (65, 66) stay 0: the iris holds its place; only scale.
204
- o.set([s.squash * this.strength, 0, 0, s.shine], 64 + e * 4);
321
+ // [4]/[5]: scale across and down, the iris's twist, the highlight's turn.
322
+ o.set([by(kx, 0.78, 1.25), by(ky, 0.7, 1.08), (x * TWIST_POS + vn * TWIST_VEL) * DEG * g, vn * HL_TURN * DEG * g], 64 + e * 4);
323
+ // [6]/[7]: the iris's drift, then the highlight's on top (bind units).
324
+ // The tremble is held random spots on the scene clock, so an export
325
+ // trembles where the screen did, and a pause holds it.
326
+ const tick = (hz, seed) => (this.idle ? hashSigned(Math.floor(this.clock * hz) + seed + e * 101) : 0);
327
+ o.set([
328
+ (x * DRIFT_POS[0] + vn * DRIFT_VEL[0]) * r * g,
329
+ (x * DRIFT_POS[1] + vn * DRIFT_VEL[1]) * r * g,
330
+ (vn * HL_SWAY[0] + tick(JITTER_HZ[0], 17) * JITTER[0]) * r * g,
331
+ (vn * HL_SWAY[1] + tick(JITTER_HZ[1], 53) * JITTER[1]) * r * g,
332
+ ], 72 + e * 4);
333
+ // [8]/[9]: the roll's pivot and angle; [10]/[11]: the highlight's own
334
+ // scale, and how far the iris comes forward (bind units).
335
+ const pivot = rest.pivot;
336
+ o.set(pivot ? [pivot[0], pivot[1], pivot[2], (this.roll[e] + (s.roll - this.roll[e]) * ROLL_SWING) * DEG * g] : [0, 0, 0, 0], 80 + e * 4);
337
+ const hs = (k) => Math.max(HL_SCALE_MIN, Math.min(HL_SCALE_MAX, 1 + x * HL_SCALE[k] * g));
338
+ o.set([hs(0), hs(1), (s.closure * PUSH_EXPRESSION + Math.min(Math.abs(x), SPRING_LIMIT) * PUSH_ELASTIC) * r * g, 0], 88 + e * 4);
205
339
  }
206
340
  }
207
341
  /** Where the iris sits off rest, in iris radii (right, up): the lag through
@@ -255,9 +389,115 @@ export class JellyEyes {
255
389
  m.offset[1] = cap((dot(u, up) / Math.hypot(...up) / size) * INERTIA_GAIN);
256
390
  }
257
391
  }
258
- /** Zero jelly[0..5] — strength 0 is what the shader reads as off. */
392
+ /** Zero jelly[0..11] — strength 0 is what the shader reads as off. */
259
393
  export function clearJellyEyes(o) {
260
- o.fill(0, 48, 72);
394
+ o.fill(0, 48, 96);
395
+ }
396
+ /** A held random value in −1..1 for an integer tick, as the MME hashes it. */
397
+ function hashSigned(n) {
398
+ const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
399
+ return (x - Math.floor(x)) * 2 - 1;
400
+ }
401
+ /**
402
+ * One frame of the MME's blink spring for one eye (JellyEye_StateUpdate.fxsub,
403
+ * JE_ComputeNextState), from its closure this frame.
404
+ */
405
+ function blink(s, dt) {
406
+ if (!(dt > 0))
407
+ return;
408
+ dt = Math.min(dt, MAX_STEP);
409
+ const omega = 2 * Math.PI * SPRING_HZ;
410
+ const clamp = (x, m) => Math.max(-m, Math.min(m, x));
411
+ const speed = clamp((s.closure - s.prev) / dt, MORPH_SPEED_LIMIT);
412
+ // The change of SPEED, not of signed velocity: gaining pace or losing it.
413
+ const accel = clamp((Math.abs(speed) - Math.abs(s.speed)) / dt, ACCEL_LIMIT);
414
+ const trend = accel > ACCEL_MIN ? 1 : accel < -ACCEL_MIN ? -1 : 0;
415
+ // One run of gaining (or losing) pace is one kick.
416
+ const kicked = trend !== 0 && trend !== s.trend;
417
+ let latched = trend !== 0 ? trend : s.trend;
418
+ if (trend === 0 && Math.abs(speed) < DRIVE_SPEED_MIN && Math.abs(s.speed) < DRIVE_SPEED_MIN)
419
+ latched = 0;
420
+ // While the lids move, a target the spring chases, eased by how fast.
421
+ const amount = Math.max(0, Math.min(1, (Math.abs(speed) - DRIVE_SPEED_MIN) / (DRIVE_SPEED_FULL - DRIVE_SPEED_MIN))) ** DRIVE_CURVE;
422
+ const target = amount * (speed > DRIVE_SPEED_MIN ? CLOSE_DRIVE : speed < -DRIVE_SPEED_MIN ? OPEN_DRIVE : 0);
423
+ const driven = Math.abs(target) > 1e-5;
424
+ const x0 = s.x;
425
+ let v0 = s.v;
426
+ if (kicked)
427
+ v0 += (trend > 0 ? ACCEL_KICK : -RELEASE_KICK) * Math.min(1, Math.abs(accel) / ACCEL_FULL) * omega;
428
+ const age = driven || kicked ? 0 : Math.min(s.age + dt, ELASTIC_S);
429
+ let x = 0;
430
+ let v = 0;
431
+ if (driven || kicked || s.age < ELASTIC_S || Math.abs(x0) > STOP_EPS || Math.abs(v0) / omega > STOP_EPS) {
432
+ // The exact under-damped step about the target.
433
+ const decay = Math.LN2 / HALF_LIFE;
434
+ const envelope = Math.exp(-decay * dt);
435
+ const c = Math.cos(omega * dt);
436
+ const sn = Math.sin(omega * dt);
437
+ const rel = x0 - target;
438
+ x = target + envelope * (rel * c + ((v0 + decay * rel) * sn) / omega);
439
+ v = envelope * (v0 * c - ((decay * v0 + (decay * decay + omega * omega) * rel) * sn) / omega);
440
+ }
441
+ x = clamp(x, SPRING_LIMIT);
442
+ v = clamp(v, omega * SPRING_LIMIT * 2);
443
+ s.prev = s.closure;
444
+ s.speed = speed;
445
+ s.trend = latched;
446
+ const settled = !driven && age >= MIN_RELEASE && Math.abs(x) <= STOP_EPS && Math.abs(v) / omega <= STOP_EPS;
447
+ if (settled || (!driven && age >= ELASTIC_S)) {
448
+ s.x = s.v = 0;
449
+ s.age = ELASTIC_S;
450
+ }
451
+ else {
452
+ s.x = x;
453
+ s.v = v;
454
+ s.age = age;
455
+ }
456
+ }
457
+ /**
458
+ * The highlight layers: a material drawn over an iris — after it, on the same
459
+ * disc, made of vertices the eye bones carry — or one named as a highlight.
460
+ * As draw ids (1-based over the materials that draw, as the engine numbers
461
+ * MaterialUniforms.materialId), at most two. A rig whose highlight is painted
462
+ * into the iris has none, and only the iris moves.
463
+ */
464
+ function findHighlights(model, rest) {
465
+ const materials = model.getMaterials?.() ?? [];
466
+ const { positions, indices } = model.getGeometry();
467
+ const iris = new Set([...rest[0].verts, ...rest[1].verts]);
468
+ if (!materials.length || !indices.length || !iris.size)
469
+ return [];
470
+ const layers = [];
471
+ let start = 0;
472
+ let id = 0;
473
+ for (const m of materials) {
474
+ const count = m.vertexCount;
475
+ if (count === 0)
476
+ continue;
477
+ id++;
478
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
479
+ for (let k = start; k < start + count; k++) {
480
+ const v = indices[k];
481
+ if (!iris.has(v))
482
+ continue;
483
+ minX = Math.min(minX, positions[v * 3]);
484
+ maxX = Math.max(maxX, positions[v * 3]);
485
+ minY = Math.min(minY, positions[v * 3 + 1]);
486
+ maxY = Math.max(maxY, positions[v * 3 + 1]);
487
+ }
488
+ start += count;
489
+ if (minX <= maxX)
490
+ layers.push({ id, name: m.name, minX, maxX, minY, maxY });
491
+ }
492
+ const out = [];
493
+ layers.forEach((l, i) => {
494
+ const cx = (l.minX + l.maxX) / 2;
495
+ const cy = (l.minY + l.maxY) / 2;
496
+ const over = layers.slice(0, i).some((u) => cx >= u.minX && cx <= u.maxX && cy >= u.minY && cy <= u.maxY);
497
+ if ((over || HIGHLIGHT.test(l.name)) && out.length < 2)
498
+ out.push(l.id);
499
+ });
500
+ return out;
261
501
  }
262
502
  export function clampStrength(s) {
263
503
  return Math.max(0, Math.min(2, s ?? 1));
@@ -295,6 +535,71 @@ function look(eye, head, out) {
295
535
  }
296
536
  }
297
537
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
538
+ /**
539
+ * The iris out of everything one eye carries: grouped by material, the group
540
+ * whose centre is nearest the eye bone, and any other group centred on it
541
+ * (within its own size) — layers of the same eye. Without materials (or a
542
+ * bone), the set as it came.
543
+ */
544
+ function irisOnly(model, set, bone) {
545
+ const materials = model.getMaterials?.() ?? [];
546
+ const { positions, indices } = model.getGeometry();
547
+ if (set.length < 3 || !bone || !materials.length || !indices.length)
548
+ return set;
549
+ const material = new Int32Array(positions.length / 3).fill(-1);
550
+ let start = 0;
551
+ materials.forEach((m, i) => {
552
+ for (let k = start; k < start + m.vertexCount; k++)
553
+ if (material[indices[k]] < 0)
554
+ material[indices[k]] = i;
555
+ start += m.vertexCount;
556
+ });
557
+ const groups = new Map();
558
+ for (const v of set) {
559
+ const g = groups.get(material[v]);
560
+ if (g)
561
+ g.push(v);
562
+ else
563
+ groups.set(material[v], [v]);
564
+ }
565
+ if (groups.size < 2)
566
+ return set;
567
+ const boxes = [...groups.values()].map((vs) => {
568
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
569
+ for (const v of vs) {
570
+ minX = Math.min(minX, positions[v * 3]);
571
+ maxX = Math.max(maxX, positions[v * 3]);
572
+ minY = Math.min(minY, positions[v * 3 + 1]);
573
+ maxY = Math.max(maxY, positions[v * 3 + 1]);
574
+ }
575
+ return { vs, x: (minX + maxX) / 2, y: (minY + maxY) / 2, size: Math.max(maxX - minX, maxY - minY) / 2 };
576
+ });
577
+ const near = boxes.reduce((a, b) => (Math.hypot(b.x - bone[0], b.y - bone[1]) < Math.hypot(a.x - bone[0], a.y - bone[1]) ? b : a));
578
+ return boxes.filter((b) => Math.hypot(b.x - near.x, b.y - near.y) <= near.size).flatMap((b) => b.vs);
579
+ }
580
+ /** Every vertex morph that moves this iris, with the mean offset it gives it. */
581
+ function irisShifts(model, verts) {
582
+ if (!verts.length)
583
+ return [];
584
+ const inIris = new Set(verts);
585
+ const out = [];
586
+ model.getMorphing().morphs.forEach((m, morph) => {
587
+ const d = [0, 0, 0];
588
+ for (const o of m.vertexOffsets ?? []) {
589
+ if (!inIris.has(o.vertexIndex))
590
+ continue;
591
+ d[0] += o.positionOffset[0];
592
+ d[1] += o.positionOffset[1];
593
+ d[2] += o.positionOffset[2];
594
+ }
595
+ d[0] /= verts.length;
596
+ d[1] /= verts.length;
597
+ d[2] /= verts.length;
598
+ if (Math.hypot(...d) > 1e-6)
599
+ out.push({ morph, d });
600
+ });
601
+ return out;
602
+ }
298
603
  /**
299
604
  * Each iris in bind space: the vertices the eye bones carry are exactly the
300
605
  * part of the eye that turns to look — the iris and pupil — so their centroid
@@ -321,35 +626,83 @@ function findIrises(model, l, r, both) {
321
626
  const leftSign = lHead && rHead ? Math.sign(lHead[0] - rHead[0]) || 1 : 1;
322
627
  const { positions } = model.getGeometry();
323
628
  const { joints, weights } = model.getSkinning();
324
- const sets = [[], []];
325
- const n = positions.length / 3;
326
- for (let v = 0; v < n; v++) {
327
- let wl = 0;
328
- let wr = 0;
329
- let wb = 0;
330
- for (let k = 0; k < 4; k++) {
331
- const j = joints[v * 4 + k];
332
- const w = weights[v * 4 + k];
333
- if (j === l)
334
- wl += w;
335
- else if (j === r)
336
- wr += w;
337
- else if (j === both)
338
- wb += w;
629
+ // Which eye each bone turns with: an eye bone, or any bone under one. Rigs
630
+ // differ in which carries the iris — some hang it on the eye bone itself,
631
+ // some on its tip (左目先/右目先), which turns with it all the same.
632
+ const owner = bones.map((_, i) => {
633
+ for (let b = i; b >= 0; b = bones[b].parentIndex) {
634
+ if (b === l)
635
+ return 0;
636
+ if (b === r)
637
+ return 1;
638
+ if (b === both)
639
+ return 2;
640
+ }
641
+ return -1;
642
+ });
643
+ // Per side, the vertices and how much iris weight each bone carries: the
644
+ // heaviest is the one the vertex shader scales by (one bone per eye).
645
+ //
646
+ // The per-eye trees first. 両目 is a fallback for rigs that carry both irises
647
+ // on it alone: where 左目/右目 do carry them, anything hung on 両目 is
648
+ // something else that turns with the eyes — an expression overlay above them
649
+ // (blush lines, sparkles) — and split by side it stretches each "iris" past
650
+ // any iris, so the size check rejects both and the eyes never move.
651
+ const gather = (useBoth) => {
652
+ const sets = [[], []];
653
+ const n = positions.length / 3;
654
+ for (let v = 0; v < n; v++) {
655
+ const w3 = [0, 0, 0];
656
+ for (let k = 0; k < 4; k++) {
657
+ const o = owner[joints[v * 4 + k]];
658
+ if (o >= 0)
659
+ w3[o] += weights[v * 4 + k];
660
+ }
661
+ let side;
662
+ if (w3[0] >= 128)
663
+ side = 0;
664
+ else if (w3[1] >= 128)
665
+ side = 1;
666
+ else if (useBoth && w3[2] >= 128)
667
+ side = Math.sign(positions[v * 3]) === leftSign ? 0 : 1;
668
+ else
669
+ continue;
670
+ sets[side].push(v);
671
+ }
672
+ return sets;
673
+ };
674
+ let gathered = gather(false);
675
+ if (gathered[0].length < 3 && gathered[1].length < 3)
676
+ gathered = gather(true);
677
+ // Of what turns with an eye, the iris is the mesh nearest its bone, with the
678
+ // layers stacked on it (an eye white, a highlight sheet over the same disc).
679
+ // A mesh elsewhere that the eye bones also carry — an expression overlay
680
+ // above the eyes — is left out, or it stretches the "iris" past any iris.
681
+ const sets = [irisOnly(model, gathered[0], lHead), irisOnly(model, gathered[1], rHead)];
682
+ const carried = [new Map(), new Map()];
683
+ for (const side of [0, 1]) {
684
+ for (const v of sets[side]) {
685
+ for (let k = 0; k < 4; k++) {
686
+ const j = joints[v * 4 + k];
687
+ if (owner[j] >= 0)
688
+ carried[side].set(j, (carried[side].get(j) ?? 0) + weights[v * 4 + k]);
689
+ }
339
690
  }
340
- if (wl >= 128)
341
- sets[0].push(v);
342
- else if (wr >= 128)
343
- sets[1].push(v);
344
- else if (wb >= 128)
345
- sets[Math.sign(positions[v * 3]) === leftSign ? 0 : 1].push(v);
346
691
  }
692
+ const carrier = (side) => {
693
+ let best = -1;
694
+ let most = 0;
695
+ for (const [j, w] of carried[side])
696
+ if (w > most)
697
+ [best, most] = [j, w];
698
+ return best;
699
+ };
347
700
  const apart = lHead && rHead ? Math.hypot(lHead[0] - rHead[0], lHead[1] - rHead[1]) : 0;
348
701
  const fallbackRadius = apart > 0 ? apart * 0.22 : 0.3;
349
- const rest = (set, bone, side) => {
702
+ const rest = (set, bone, side, carrierBone) => {
350
703
  if (set.length < 3) {
351
704
  const c = bone ?? [side * fallbackRadius * 2, 0, 0];
352
- return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 };
705
+ return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1, pivot: null };
353
706
  }
354
707
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, z = 0;
355
708
  for (const v of set) {
@@ -367,7 +720,7 @@ function findIrises(model, l, r, both) {
367
720
  const rx = (maxX - minX) / 2;
368
721
  const ry = (maxY - minY) / 2;
369
722
  if (apart > 0 && (rx > apart * 0.6 || ry > apart * 0.8)) {
370
- return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 };
723
+ return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1, pivot: null };
371
724
  }
372
725
  const centre = [(minX + maxX) / 2, (minY + maxY) / 2, z / set.length];
373
726
  const radius = [Math.max(rx, 1e-3), Math.max(ry, 1e-3)];
@@ -375,7 +728,7 @@ function findIrises(model, l, r, both) {
375
728
  // still turns its eyes visibly, so read it as a typical eyeball's.
376
729
  const r = (radius[0] + radius[1]) / 2;
377
730
  const lever = Math.max(bone ? Math.hypot(centre[0] - bone[0], centre[1] - bone[1], centre[2] - bone[2]) : 0, r * 1.5);
378
- return { centre, radius, lever };
731
+ return { centre, radius, lever, verts: set, bone: carrierBone, pivot: bone };
379
732
  };
380
- return [rest(sets[0], lHead, leftSign), rest(sets[1], rHead, -leftSign)];
733
+ return [rest(sets[0], lHead, leftSign, carrier(0)), rest(sets[1], rHead, -leftSign, carrier(1))];
381
734
  }