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.
package/src/jelly-eyes.ts CHANGED
@@ -3,26 +3,32 @@
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
 
20
26
  import type { Model } from "./model"
21
27
 
22
28
  export interface JellyEyeOptions {
23
29
  /** How much the eyes give: 0 still, 1 the intended bounce, up to 2. Default 1. */
24
30
  strength?: number
25
- /** The idle taps: a small bounce every few seconds while nothing else moves them.
31
+ /** The idle taps: a ripple every 4–8 s while nothing else moves them.
26
32
  * Default true; false leaves only what her blinks and motion set off. */
27
33
  idle?: boolean
28
34
  }
@@ -34,17 +40,81 @@ const BOTH = ["まばたき", "笑い", "はぅ", "blink", "Blink", "eye_close",
34
40
  const LEFT = ["ウィンク", "ウィンク2", "ウィンク", "ウィンク2", "wink", "Wink", "blink_L", "Fcl_EYE_Close_L"]
35
41
  const RIGHT = ["ウィンク右", "ウィンク2右", "ウィンク右", "ウィンク2右", "wink_R", "Wink_R", "blink_R", "Fcl_EYE_Close_R"]
36
42
 
37
- // The squash: how far a closed eye presses the picture, and the spring that
38
- // follows it — lightly damped, so one knock ripples: the squash, a rebound past
39
- // rest, and several smaller wobbles before it settles. One dip and done read as
40
- // a scale, not a jelly; the ripple is the "Duang" the effect is named for.
41
- // Only a CHANGE of closure squashes: the eye's closure less a slow follower of
42
- // it, so a blink squeezes then bounces, while a held half-close (a smile)
43
- // leaves the iris at rest instead of squashed for as long as she smiles.
44
- const SQUASH_CLOSED = 0.35
43
+ // The blink, as the MME runs it (its JellyEye_Settings.fxsub defaults). The
44
+ // eye's closure (0..1) is differentiated twice. Its speed drives a target the
45
+ // spring chases: CLOSE_DRIVE while closing, OPEN_DRIVE while opening, eased by
46
+ // how fast. Every change between gaining and losing pace kicks the spring's
47
+ // velocity. The spring is an exact under-damped step at SPRING_HZ whose
48
+ // envelope halves every HALF_LIFE.
49
+ const ACCEL_MIN = 0.5
50
+ const ACCEL_FULL = 2
51
+ const ACCEL_KICK = 0.32
52
+ const RELEASE_KICK = 0.2
53
+ const CLOSE_DRIVE = 0.85
54
+ const OPEN_DRIVE = 0.2
55
+ const DRIVE_SPEED_MIN = 0.04
56
+ const DRIVE_SPEED_FULL = 2.5
57
+ const DRIVE_CURVE = 0.85
58
+ const MORPH_SPEED_LIMIT = 30
59
+ const ACCEL_LIMIT = 240
60
+ const MAX_STEP = 0.12
61
+ const SPRING_HZ = 3
62
+ const HALF_LIFE = 0.28
63
+ const ELASTIC_S = 2
64
+ const STOP_EPS = 0.0025
65
+ const MIN_RELEASE = 0.12
66
+ const SPRING_LIMIT = 1.25
67
+ // What the spring does to the iris, per unit of it: wider far more than
68
+ // taller, a little less on the rebound side; the lid's press, flatter by
69
+ // SQUEEZE_Y at full closure; a twist (degrees); and a drift (iris radii: the
70
+ // MME's model units over a typical iris of 0.1). Twist and drift take both the
71
+ // spring's place (_POS) and its velocity (_VEL). The twist is ten and five
72
+ // times the MME's defaults (0.2°, 0.8°): at those it stays under a degree and
73
+ // the iris never visibly turns as it rings.
74
+ const SCALE_GAIN: [number, number] = [0.2, 0.05]
75
+ const REBOUND: [number, number] = [0.7, 0.8]
76
+ const SQUEEZE_Y = 0.18
77
+ const TWIST_POS = 2
78
+ const TWIST_VEL = 4
79
+ const DRIFT_POS: [number, number] = [0.008, 0.015]
80
+ const DRIFT_VEL: [number, number] = [0.012, 0.006]
81
+ // The highlight layer's own motion: a held random spot, re-drawn JITTER_HZ
82
+ // times a second per axis, up to JITTER of an iris radius; and, on the spring,
83
+ // a further sway (iris radii) and turn (degrees) of its own.
84
+ const JITTER_HZ: [number, number] = [9, 11]
85
+ const JITTER: [number, number] = [0.03, 0.02]
86
+ const HL_SWAY: [number, number] = [0.03, 0.045]
87
+ const HL_TURN = 10
88
+ const HL_SCALE: [number, number] = [0.055, 0.045]
89
+ const HL_SCALE_MIN = 0.85
90
+ const HL_SCALE_MAX = 1.2
91
+ // The roll: while a closing expression holds, the iris turns about its eye
92
+ // bone's X axis (左目/右目, the eyeball's centre), rolling down with the lid —
93
+ // the angle at weight 1, and simultaneous expressions add. And it comes
94
+ // forward a little (iris radii) with the expression and with the spring, so
95
+ // the roll does not sink it into the white. The MME rolls with the lid
96
+ // exactly, which the lid hides; here the roll follows on a spring of the same
97
+ // pace and decay as the blink's, so as the eye opens the iris swings up past
98
+ // rest and wobbles back, where it can be seen.
99
+ const ROLL: Record<string, number> = {
100
+ まばたき: -15, blink: -15, Blink: -15, eye_close: -15, Fcl_EYE_Close: -15, はぅ: -15, 笑い: -5,
101
+ ウィンク: -5, ウィンク: -5, wink: -5, Wink: -5, ウィンク2: -15, ウィンク2: -15, blink_L: -15, Fcl_EYE_Close_L: -15,
102
+ ウィンク右: -5, ウィンク右: -5, wink_R: -5, Wink_R: -5, ウィンク2右: -15, ウィンク2右: -15, blink_R: -15, Fcl_EYE_Close_R: -15,
103
+ }
104
+ const ROLL_HZ = SPRING_HZ
105
+ const ROLL_ZETA = Math.LN2 / HALF_LIFE / (2 * Math.PI * SPRING_HZ)
106
+ // How much of the spring's swing about the expression angle shows: the whole
107
+ // of it carried the iris up most of its radius after a blink.
108
+ const ROLL_SWING = 0.4
109
+ const PUSH_EXPRESSION = 0.15
110
+ const PUSH_ELASTIC = 0.04
111
+ // A highlight sheet by name, for rigs that keep it on its own material.
112
+ const HIGHLIGHT = /ハイライト|ハイライト|高光|highlight|hilight|(^|[^a-z])hl([^a-z]|$)/i
113
+ const DEG = Math.PI / 180
114
+ // The knock's squash (her motion, the idle taps): nearly round, lightly damped
115
+ // so one knock ripples.
45
116
  const SQUASH_HZ = 1.8
46
117
  const SQUASH_ZETA = 0.08
47
- const CLOSURE_FOLLOW_S = 0.35
48
118
  // The lag: the picture trails the eye's turn and catches up past it. Radians of
49
119
  // lag to eye radii of offset; the head's own turn counts for a third, so a
50
120
  // sudden stop of the head wobbles the eyes without a dance keeping them
@@ -82,16 +152,21 @@ const PULSE_REF = 0.006
82
152
  const MOTION_SQUASH = 0.18
83
153
  const PULSE_HZ = 2.0
84
154
  const PULSE_ZETA = 0.08
85
- // The idle taps: every few seconds, at uneven times, a small knock into the
155
+ // The idle taps: every 4–8 s, at uneven times, a knock into the
86
156
  // same springs her motion drives — the iris dips, bounces and settles, then
87
157
  // rests — the way a jelly on a table is never quite still. A steady sine was
88
158
  // tried, as size (a zoom) and as wider-then-taller (queasy); a settling tap is
89
- // what reads as jelly. Velocity kicks, so the bounce keeps the springs' own
90
- // timing; the gaps come from a hash of the tap's count, so the scene clock
91
- // alone decides them and an export taps where the screen did.
92
- const IDLE_SQUASH_KICK = 0.45
93
- const IDLE_PULSE_KICK = 0.25
94
- const IDLE_GAP: [number, number] = [1.8, 3.8]
159
+ // what reads as jelly. A tap is a full KNOCK, exactly what her motion gives at
160
+ // its strongest — a gentler nudge was tried and went unnoticed — held for a
161
+ // moment and let go, so it rings through the same springs. The gaps come from
162
+ // a hash of the tap's count, so the scene clock alone decides them and an
163
+ // export taps where the screen did.
164
+ const IDLE_TAP_S = 0.12
165
+ // How hard a tap knocks (motion's strongest is 1) and how far apart they fall:
166
+ // a full knock every 2–4 s read as the iris zooming constantly; rarer, a touch
167
+ // softer, each one reads as a ripple and the eye rests between.
168
+ const IDLE_TAP = 0.6
169
+ const IDLE_GAP: [number, number] = [4, 8]
95
170
  const idleGap = (n: number): number => {
96
171
  const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453
97
172
  return IDLE_GAP[0] + (x - Math.floor(x)) * (IDLE_GAP[1] - IDLE_GAP[0])
@@ -102,16 +177,25 @@ const SEEK = 0.25
102
177
 
103
178
  interface EyeState {
104
179
  closure: number
105
- // The slow follower of closure; closure − settled is what squashes.
106
- settled: number
180
+ // The MME blink: last frame's closure and its speed, the last pace trend
181
+ // (+1 gaining, −1 losing, 0 still), the spring's place and velocity, and
182
+ // the seconds since the lids last drove it.
183
+ prev: number
184
+ speed: number
185
+ trend: number
186
+ x: number
187
+ v: number
188
+ age: number
107
189
  squash: number
108
190
  squashV: number
109
191
  // Lagged look (yaw, pitch) and its velocity; the offset is lagged − actual.
110
192
  lag: [number, number]
111
193
  lagV: [number, number]
112
194
  look: [number, number]
113
- shine: number
114
195
  pulse: number
196
+ // The roll (degrees) and its velocity, chasing the expressions' angle.
197
+ roll: number
198
+ rollV: number
115
199
  pulseV: number
116
200
  }
117
201
 
@@ -132,18 +216,38 @@ interface EyeRest {
132
216
  // Iris centre to the eye bone's head: what a turn moves the iris by, per
133
217
  // radian.
134
218
  lever: number
219
+ // The vertices it was measured from (empty for a fallback), and the bone
220
+ // that carries them (−1 when none does).
221
+ verts: number[]
222
+ bone: number
223
+ // The eye bone's head, which the roll turns about (null: no eye bone).
224
+ pivot: [number, number, number] | null
225
+ }
226
+
227
+ // A vertex morph's pull on an iris: the mean offset it gives the iris's
228
+ // vertices at weight 1. Morphs add linearly, so the iris's centre under any
229
+ // mix of them is the rest centre plus these, weighted.
230
+ interface MorphShift {
231
+ morph: number
232
+ d: [number, number, number]
135
233
  }
136
234
 
137
235
  const newState = (): EyeState => ({
138
236
  closure: 0,
139
- settled: 0,
237
+ prev: 0,
238
+ speed: 0,
239
+ trend: 0,
240
+ x: 0,
241
+ v: 0,
242
+ age: ELASTIC_S,
140
243
  squash: 0,
141
244
  squashV: 0,
142
245
  lag: [0, 0],
143
246
  lagV: [0, 0],
144
247
  look: [0, 0],
145
- shine: 0,
146
248
  pulse: 0,
249
+ roll: 0,
250
+ rollV: 0,
147
251
  pulseV: 0,
148
252
  })
149
253
 
@@ -154,13 +258,26 @@ export class JellyEyes {
154
258
  private clock = 0
155
259
  private taps = 0
156
260
  private nextTap = idleGap(0)
261
+ // The current tap's knock, 1 at the tap and fading over IDLE_TAP_S.
262
+ private tapKnock = 0
157
263
  private readonly rest: [EyeRest, EyeRest]
158
264
  private readonly state: [EyeState, EyeState] = [newState(), newState()]
159
265
  private readonly morphs: [number[], number[], number[]]
160
266
  private readonly eyeBones: [number, number]
161
267
  private readonly headBone: number
162
268
  private fresh = true
269
+ // Each iris's centre THIS frame: the rest centre carried by the morphs that
270
+ // move the iris (a pupil-up, a look-left) — the vertex shader scales the
271
+ // morphed iris, so a centre left at rest scales it about a point it has
272
+ // already left, and it drifts off as it shrinks and grows.
273
+ private readonly shifts: [MorphShift[], MorphShift[]]
274
+ private readonly centre: [[number, number, number], [number, number, number]]
163
275
  private readonly inertia: Inertia = { p: [0, 0, 0], v: [0, 0, 0], head: [0, 0, 0], offset: [0, 0] }
276
+ // The highlight layers' draw ids (MaterialUniforms.materialId), up to two.
277
+ readonly highlights: number[]
278
+ // Each eye's closing expressions with their roll, and the angle they set this frame (degrees).
279
+ private readonly rolls: [{ morph: number; deg: number }[], { morph: number; deg: number }[]]
280
+ private readonly roll: [number, number] = [0, 0]
164
281
 
165
282
  constructor(model: Model, options: JellyEyeOptions) {
166
283
  this.strength = clampStrength(options.strength)
@@ -170,26 +287,48 @@ export class JellyEyes {
170
287
  const both = index("両目")
171
288
  const l = index("左目")
172
289
  const r = index("右目")
173
- this.eyeBones = [l >= 0 ? l : both, r >= 0 ? r : both]
174
290
  this.headBone = index("頭")
175
291
  const names = model.getMorphing().morphs.map((m) => m.name)
176
292
  const find = (list: string[]) => list.map((n) => names.indexOf(n)).filter((i) => i >= 0)
177
293
  this.morphs = [find(BOTH), find(LEFT), find(RIGHT)]
294
+ const rolls = (list: string[]) => list.map((n) => ({ morph: names.indexOf(n), deg: ROLL[n] ?? 0 })).filter((r) => r.morph >= 0 && r.deg !== 0)
295
+ this.rolls = [rolls([...BOTH, ...LEFT]), rolls([...BOTH, ...RIGHT])]
178
296
  this.rest = findIrises(model, l, r, both)
297
+ // The bone that carries each iris, which may be under the eye bone.
298
+ this.eyeBones = [this.rest[0].bone, this.rest[1].bone]
299
+ this.shifts = [irisShifts(model, this.rest[0].verts), irisShifts(model, this.rest[1].verts)]
300
+ this.centre = [[...this.rest[0].centre], [...this.rest[1].centre]]
301
+ this.highlights = findHighlights(model, this.rest)
179
302
  }
180
303
 
181
304
  /** Advance both springs by dt seconds of scene time. */
182
305
  step(dt: number, model: Model): void {
183
306
  const weights = model.getEffectiveMorphWeights()
307
+ for (let e = 0; e < 2; e++) {
308
+ const c = this.centre[e]
309
+ const r = this.rest[e].centre
310
+ c[0] = r[0]
311
+ c[1] = r[1]
312
+ c[2] = r[2]
313
+ for (const { morph, d } of this.shifts[e]) {
314
+ const w = weights[morph] ?? 0
315
+ if (w === 0) continue
316
+ c[0] += d[0] * w
317
+ c[1] += d[1] * w
318
+ c[2] += d[2] * w
319
+ }
320
+ }
184
321
  const sum = (list: number[]) => list.reduce((s, i) => s + (weights[i] ?? 0), 0)
185
322
  const both = sum(this.morphs[0])
186
323
  const closure = [Math.min(1, both + sum(this.morphs[1])), Math.min(1, both + sum(this.morphs[2]))]
324
+ for (let e = 0; e < 2; e++) this.roll[e] = this.rolls[e].reduce((a, r) => a + r.deg * Math.max(0, Math.min(1, weights[r.morph] ?? 0)), 0)
187
325
  const head = this.headBone >= 0 ? model.getBoneWorldMatrixAt(this.headBone) : null
188
326
  const seek = this.fresh || dt > SEEK || dt < 0
189
327
  if (dt > 0) this.clock += dt
190
- let tap = false
191
- if (!seek && this.idle && this.clock >= this.nextTap) {
192
- tap = true
328
+ if (dt > 0) this.tapKnock *= Math.exp(-dt / IDLE_TAP_S)
329
+ if (seek) this.tapKnock = 0
330
+ else if (this.idle && this.clock >= this.nextTap) {
331
+ this.tapKnock = IDLE_TAP
193
332
  this.taps++
194
333
  this.nextTap = this.clock + idleGap(this.taps)
195
334
  }
@@ -202,26 +341,24 @@ export class JellyEyes {
202
341
  // Only her motion — the head turning and moving (the inertia) — and her
203
342
  // blinks do.
204
343
  look(head, null, s.look)
205
- if (seek) s.settled = s.closure
206
- else s.settled += (s.closure - s.settled) * Math.min(1, dt / CLOSURE_FOLLOW_S)
207
- // Either way a lid moves squashes — opening too, which is the half of a
208
- // blink you can see (closing, the lid hides the iris anyway).
209
- const knock = seek ? 0 : Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF)
210
- const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled) + MOTION_SQUASH * knock
344
+ const knock = seek ? 0 : Math.max(this.tapKnock, Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF))
345
+ const squashTarget = MOTION_SQUASH * knock
211
346
  if (seek) {
347
+ // Landing on a pose is no blink: the spring rests where it lands.
348
+ s.prev = s.closure
349
+ s.speed = s.trend = s.x = s.v = 0
350
+ s.age = ELASTIC_S
212
351
  s.squash = squashTarget
213
352
  s.squashV = 0
214
353
  s.lag[0] = s.look[0]
215
354
  s.lag[1] = s.look[1]
216
355
  s.lagV[0] = s.lagV[1] = 0
217
- s.shine = 0
218
356
  s.pulse = s.pulseV = 0
357
+ s.roll = this.roll[e]
358
+ s.rollV = 0
219
359
  continue
220
360
  }
221
- if (tap) {
222
- s.squashV += IDLE_SQUASH_KICK
223
- s.pulseV -= IDLE_PULSE_KICK
224
- }
361
+ blink(s, dt)
225
362
  const steps = Math.ceil(dt / SUBSTEP)
226
363
  const h = steps > 0 ? dt / steps : 0
227
364
  const pulseTarget = PULSE_MAX * knock
@@ -230,32 +367,63 @@ export class JellyEyes {
230
367
  s.pulse += h * s.pulseV
231
368
  s.squashV += h * spring(s.squash - squashTarget, s.squashV, SQUASH_HZ, SQUASH_ZETA)
232
369
  s.squash += h * s.squashV
370
+ s.rollV += h * spring(s.roll - this.roll[e], s.rollV, ROLL_HZ, ROLL_ZETA)
371
+ s.roll += h * s.rollV
233
372
  for (let k = 0; k < 2; k++) {
234
373
  s.lagV[k] += h * spring(s.lag[k] - s.look[k], s.lagV[k], LAG_HZ, LAG_ZETA)
235
374
  s.lag[k] += h * s.lagV[k]
236
375
  }
237
376
  }
238
- // Shine follows the motion: bright while the jelly moves, fading as it
239
- // settles, eased so a single wobble does not strobe.
240
- const motion = Math.min(1, 2.5 * Math.abs(s.squash - squashTarget) + 4 * Math.hypot(...this.offset(s, e)))
241
- s.shine += (motion - s.shine) * Math.min(1, dt * 12)
242
377
  }
243
378
  this.fresh = false
244
379
  }
245
380
 
246
- /** Write jelly[0..5] into an ObjectLight copy (floats 48..71). */
381
+ /** Write jelly[0..11] into an ObjectLight copy (floats 48..95). */
247
382
  write(o: Float32Array): void {
248
- const [L, R] = this.rest
249
- o.set([L.centre[0], L.centre[1], L.centre[2], this.strength], 48)
250
- // One scale bounce for both: the mean of the two eyes', so they swell together.
251
- const pulse = ((this.state[0].pulse + this.state[1].pulse) / 2) * this.strength
252
- o.set([R.centre[0], R.centre[1], R.centre[2], pulse], 52)
383
+ const [L, R] = this.centre
384
+ const g = this.strength
385
+ o.set([L[0], L[1], L[2], g], 48)
386
+ // The highlight layers' draw ids, two packed in one float (exact to 4095).
387
+ const [a = 0, b = 0] = this.highlights
388
+ o.set([R[0], R[1], R[2], a + b * 4096], 52)
389
+ // One knock swell for both: the mean of the two eyes', so they swell together.
390
+ const pulse = (this.state[0].pulse + this.state[1].pulse) / 2
391
+ const omega = 2 * Math.PI * SPRING_HZ
392
+ // Strength scales every departure from rest, then the bounds hold: the
393
+ // swell under 1.08 tall so it cannot rise past the upper lid.
394
+ const by = (k: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, 1 + (k - 1) * g))
253
395
  for (let e = 0; e < 2; e++) {
254
396
  const s = this.state[e]
255
397
  const rest = this.rest[e]
398
+ const r = (rest.radius[0] + rest.radius[1]) / 2
399
+ // The MME spring's place, and its velocity in the same units.
400
+ const x = s.x
401
+ const vn = s.v / omega
402
+ const gain = (k: 0 | 1) => SCALE_GAIN[k] * (x >= 0 ? 1 : REBOUND[k])
403
+ const kx = (1 + x * gain(0)) * (1 - 0.8 * s.squash + pulse)
404
+ const ky = (1 + x * gain(1)) * (1 - SQUEEZE_Y * s.closure) * (1 - s.squash + pulse)
256
405
  o.set([rest.radius[0], rest.radius[1], s.closure, this.eyeBones[e]], 56 + e * 4)
257
- // Offset words (65, 66) stay 0: the iris holds its place; only scale.
258
- o.set([s.squash * this.strength, 0, 0, s.shine], 64 + e * 4)
406
+ // [4]/[5]: scale across and down, the iris's twist, the highlight's turn.
407
+ 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)
408
+ // [6]/[7]: the iris's drift, then the highlight's on top (bind units).
409
+ // The tremble is held random spots on the scene clock, so an export
410
+ // trembles where the screen did, and a pause holds it.
411
+ const tick = (hz: number, seed: number) => (this.idle ? hashSigned(Math.floor(this.clock * hz) + seed + e * 101) : 0)
412
+ o.set(
413
+ [
414
+ (x * DRIFT_POS[0] + vn * DRIFT_VEL[0]) * r * g,
415
+ (x * DRIFT_POS[1] + vn * DRIFT_VEL[1]) * r * g,
416
+ (vn * HL_SWAY[0] + tick(JITTER_HZ[0], 17) * JITTER[0]) * r * g,
417
+ (vn * HL_SWAY[1] + tick(JITTER_HZ[1], 53) * JITTER[1]) * r * g,
418
+ ],
419
+ 72 + e * 4,
420
+ )
421
+ // [8]/[9]: the roll's pivot and angle; [10]/[11]: the highlight's own
422
+ // scale, and how far the iris comes forward (bind units).
423
+ const pivot = rest.pivot
424
+ 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)
425
+ const hs = (k: 0 | 1) => Math.max(HL_SCALE_MIN, Math.min(HL_SCALE_MAX, 1 + x * HL_SCALE[k] * g))
426
+ 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)
259
427
  }
260
428
  }
261
429
 
@@ -312,9 +480,109 @@ export class JellyEyes {
312
480
  }
313
481
  }
314
482
 
315
- /** Zero jelly[0..5] — strength 0 is what the shader reads as off. */
483
+ /** Zero jelly[0..11] — strength 0 is what the shader reads as off. */
316
484
  export function clearJellyEyes(o: Float32Array): void {
317
- o.fill(0, 48, 72)
485
+ o.fill(0, 48, 96)
486
+ }
487
+
488
+ /** A held random value in −1..1 for an integer tick, as the MME hashes it. */
489
+ function hashSigned(n: number): number {
490
+ const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453
491
+ return (x - Math.floor(x)) * 2 - 1
492
+ }
493
+
494
+ /**
495
+ * One frame of the MME's blink spring for one eye (JellyEye_StateUpdate.fxsub,
496
+ * JE_ComputeNextState), from its closure this frame.
497
+ */
498
+ function blink(s: EyeState, dt: number): void {
499
+ if (!(dt > 0)) return
500
+ dt = Math.min(dt, MAX_STEP)
501
+ const omega = 2 * Math.PI * SPRING_HZ
502
+ const clamp = (x: number, m: number) => Math.max(-m, Math.min(m, x))
503
+ const speed = clamp((s.closure - s.prev) / dt, MORPH_SPEED_LIMIT)
504
+ // The change of SPEED, not of signed velocity: gaining pace or losing it.
505
+ const accel = clamp((Math.abs(speed) - Math.abs(s.speed)) / dt, ACCEL_LIMIT)
506
+ const trend = accel > ACCEL_MIN ? 1 : accel < -ACCEL_MIN ? -1 : 0
507
+ // One run of gaining (or losing) pace is one kick.
508
+ const kicked = trend !== 0 && trend !== s.trend
509
+ let latched = trend !== 0 ? trend : s.trend
510
+ if (trend === 0 && Math.abs(speed) < DRIVE_SPEED_MIN && Math.abs(s.speed) < DRIVE_SPEED_MIN) latched = 0
511
+ // While the lids move, a target the spring chases, eased by how fast.
512
+ const amount = Math.max(0, Math.min(1, (Math.abs(speed) - DRIVE_SPEED_MIN) / (DRIVE_SPEED_FULL - DRIVE_SPEED_MIN))) ** DRIVE_CURVE
513
+ const target = amount * (speed > DRIVE_SPEED_MIN ? CLOSE_DRIVE : speed < -DRIVE_SPEED_MIN ? OPEN_DRIVE : 0)
514
+ const driven = Math.abs(target) > 1e-5
515
+ const x0 = s.x
516
+ let v0 = s.v
517
+ if (kicked) v0 += (trend > 0 ? ACCEL_KICK : -RELEASE_KICK) * Math.min(1, Math.abs(accel) / ACCEL_FULL) * omega
518
+ const age = driven || kicked ? 0 : Math.min(s.age + dt, ELASTIC_S)
519
+ let x = 0
520
+ let v = 0
521
+ if (driven || kicked || s.age < ELASTIC_S || Math.abs(x0) > STOP_EPS || Math.abs(v0) / omega > STOP_EPS) {
522
+ // The exact under-damped step about the target.
523
+ const decay = Math.LN2 / HALF_LIFE
524
+ const envelope = Math.exp(-decay * dt)
525
+ const c = Math.cos(omega * dt)
526
+ const sn = Math.sin(omega * dt)
527
+ const rel = x0 - target
528
+ x = target + envelope * (rel * c + ((v0 + decay * rel) * sn) / omega)
529
+ v = envelope * (v0 * c - ((decay * v0 + (decay * decay + omega * omega) * rel) * sn) / omega)
530
+ }
531
+ x = clamp(x, SPRING_LIMIT)
532
+ v = clamp(v, omega * SPRING_LIMIT * 2)
533
+ s.prev = s.closure
534
+ s.speed = speed
535
+ s.trend = latched
536
+ const settled = !driven && age >= MIN_RELEASE && Math.abs(x) <= STOP_EPS && Math.abs(v) / omega <= STOP_EPS
537
+ if (settled || (!driven && age >= ELASTIC_S)) {
538
+ s.x = s.v = 0
539
+ s.age = ELASTIC_S
540
+ } else {
541
+ s.x = x
542
+ s.v = v
543
+ s.age = age
544
+ }
545
+ }
546
+
547
+ /**
548
+ * The highlight layers: a material drawn over an iris — after it, on the same
549
+ * disc, made of vertices the eye bones carry — or one named as a highlight.
550
+ * As draw ids (1-based over the materials that draw, as the engine numbers
551
+ * MaterialUniforms.materialId), at most two. A rig whose highlight is painted
552
+ * into the iris has none, and only the iris moves.
553
+ */
554
+ function findHighlights(model: Model, rest: [EyeRest, EyeRest]): number[] {
555
+ const materials = model.getMaterials?.() ?? []
556
+ const { positions, indices } = model.getGeometry()
557
+ const iris = new Set([...rest[0].verts, ...rest[1].verts])
558
+ if (!materials.length || !indices.length || !iris.size) return []
559
+ const layers: { id: number; name: string; minX: number; maxX: number; minY: number; maxY: number }[] = []
560
+ let start = 0
561
+ let id = 0
562
+ for (const m of materials) {
563
+ const count = m.vertexCount
564
+ if (count === 0) continue
565
+ id++
566
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
567
+ for (let k = start; k < start + count; k++) {
568
+ const v = indices[k]
569
+ if (!iris.has(v)) continue
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
+ start += count
576
+ if (minX <= maxX) layers.push({ id, name: m.name, minX, maxX, minY, maxY })
577
+ }
578
+ const out: number[] = []
579
+ layers.forEach((l, i) => {
580
+ const cx = (l.minX + l.maxX) / 2
581
+ const cy = (l.minY + l.maxY) / 2
582
+ const over = layers.slice(0, i).some((u) => cx >= u.minX && cx <= u.maxX && cy >= u.minY && cy <= u.maxY)
583
+ if ((over || HIGHLIGHT.test(l.name)) && out.length < 2) out.push(l.id)
584
+ })
585
+ return out
318
586
  }
319
587
 
320
588
  export function clampStrength(s: number | undefined): number {
@@ -355,6 +623,64 @@ function look(eye: Float32Array | null, head: Float32Array | null, out: [number,
355
623
 
356
624
  const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
357
625
 
626
+ /**
627
+ * The iris out of everything one eye carries: grouped by material, the group
628
+ * whose centre is nearest the eye bone, and any other group centred on it
629
+ * (within its own size) — layers of the same eye. Without materials (or a
630
+ * bone), the set as it came.
631
+ */
632
+ function irisOnly(model: Model, set: number[], bone: [number, number, number] | null): number[] {
633
+ const materials = model.getMaterials?.() ?? []
634
+ const { positions, indices } = model.getGeometry()
635
+ if (set.length < 3 || !bone || !materials.length || !indices.length) return set
636
+ const material = new Int32Array(positions.length / 3).fill(-1)
637
+ let start = 0
638
+ materials.forEach((m, i) => {
639
+ for (let k = start; k < start + m.vertexCount; k++) if (material[indices[k]] < 0) material[indices[k]] = i
640
+ start += m.vertexCount
641
+ })
642
+ const groups = new Map<number, number[]>()
643
+ for (const v of set) {
644
+ const g = groups.get(material[v])
645
+ if (g) g.push(v)
646
+ else groups.set(material[v], [v])
647
+ }
648
+ if (groups.size < 2) return set
649
+ const boxes = [...groups.values()].map((vs) => {
650
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
651
+ for (const v of vs) {
652
+ minX = Math.min(minX, positions[v * 3])
653
+ maxX = Math.max(maxX, positions[v * 3])
654
+ minY = Math.min(minY, positions[v * 3 + 1])
655
+ maxY = Math.max(maxY, positions[v * 3 + 1])
656
+ }
657
+ return { vs, x: (minX + maxX) / 2, y: (minY + maxY) / 2, size: Math.max(maxX - minX, maxY - minY) / 2 }
658
+ })
659
+ 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))
660
+ return boxes.filter((b) => Math.hypot(b.x - near.x, b.y - near.y) <= near.size).flatMap((b) => b.vs)
661
+ }
662
+
663
+ /** Every vertex morph that moves this iris, with the mean offset it gives it. */
664
+ function irisShifts(model: Model, verts: number[]): MorphShift[] {
665
+ if (!verts.length) return []
666
+ const inIris = new Set(verts)
667
+ const out: MorphShift[] = []
668
+ model.getMorphing().morphs.forEach((m, morph) => {
669
+ const d: [number, number, number] = [0, 0, 0]
670
+ for (const o of m.vertexOffsets ?? []) {
671
+ if (!inIris.has(o.vertexIndex)) continue
672
+ d[0] += o.positionOffset[0]
673
+ d[1] += o.positionOffset[1]
674
+ d[2] += o.positionOffset[2]
675
+ }
676
+ d[0] /= verts.length
677
+ d[1] /= verts.length
678
+ d[2] /= verts.length
679
+ if (Math.hypot(...d) > 1e-6) out.push({ morph, d })
680
+ })
681
+ return out
682
+ }
683
+
358
684
  /**
359
685
  * Each iris in bind space: the vertices the eye bones carry are exactly the
360
686
  * part of the eye that turns to look — the iris and pupil — so their centroid
@@ -380,29 +706,71 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
380
706
  const leftSign = lHead && rHead ? Math.sign(lHead[0] - rHead[0]) || 1 : 1
381
707
  const { positions } = model.getGeometry()
382
708
  const { joints, weights } = model.getSkinning()
383
- const sets: [number[], number[]] = [[], []]
384
- const n = positions.length / 3
385
- for (let v = 0; v < n; v++) {
386
- let wl = 0
387
- let wr = 0
388
- let wb = 0
389
- for (let k = 0; k < 4; k++) {
390
- const j = joints[v * 4 + k]
391
- const w = weights[v * 4 + k]
392
- if (j === l) wl += w
393
- else if (j === r) wr += w
394
- else if (j === both) wb += w
709
+ // Which eye each bone turns with: an eye bone, or any bone under one. Rigs
710
+ // differ in which carries the iris — some hang it on the eye bone itself,
711
+ // some on its tip (左目先/右目先), which turns with it all the same.
712
+ const owner = bones.map((_, i) => {
713
+ for (let b = i; b >= 0; b = bones[b].parentIndex) {
714
+ if (b === l) return 0
715
+ if (b === r) return 1
716
+ if (b === both) return 2
717
+ }
718
+ return -1
719
+ })
720
+ // Per side, the vertices and how much iris weight each bone carries: the
721
+ // heaviest is the one the vertex shader scales by (one bone per eye).
722
+ //
723
+ // The per-eye trees first. 両目 is a fallback for rigs that carry both irises
724
+ // on it alone: where 左目/右目 do carry them, anything hung on 両目 is
725
+ // something else that turns with the eyes — an expression overlay above them
726
+ // (blush lines, sparkles) — and split by side it stretches each "iris" past
727
+ // any iris, so the size check rejects both and the eyes never move.
728
+ const gather = (useBoth: boolean) => {
729
+ const sets: [number[], number[]] = [[], []]
730
+ const n = positions.length / 3
731
+ for (let v = 0; v < n; v++) {
732
+ const w3 = [0, 0, 0]
733
+ for (let k = 0; k < 4; k++) {
734
+ const o = owner[joints[v * 4 + k]]
735
+ if (o >= 0) w3[o] += weights[v * 4 + k]
736
+ }
737
+ let side: 0 | 1
738
+ if (w3[0] >= 128) side = 0
739
+ else if (w3[1] >= 128) side = 1
740
+ else if (useBoth && w3[2] >= 128) side = Math.sign(positions[v * 3]) === leftSign ? 0 : 1
741
+ else continue
742
+ sets[side].push(v)
395
743
  }
396
- if (wl >= 128) sets[0].push(v)
397
- else if (wr >= 128) sets[1].push(v)
398
- else if (wb >= 128) sets[Math.sign(positions[v * 3]) === leftSign ? 0 : 1].push(v)
744
+ return sets
745
+ }
746
+ let gathered = gather(false)
747
+ if (gathered[0].length < 3 && gathered[1].length < 3) gathered = gather(true)
748
+ // Of what turns with an eye, the iris is the mesh nearest its bone, with the
749
+ // layers stacked on it (an eye white, a highlight sheet over the same disc).
750
+ // A mesh elsewhere that the eye bones also carry — an expression overlay
751
+ // above the eyes — is left out, or it stretches the "iris" past any iris.
752
+ const sets: [number[], number[]] = [irisOnly(model, gathered[0], lHead), irisOnly(model, gathered[1], rHead)]
753
+ const carried: [Map<number, number>, Map<number, number>] = [new Map(), new Map()]
754
+ for (const side of [0, 1] as const) {
755
+ for (const v of sets[side]) {
756
+ for (let k = 0; k < 4; k++) {
757
+ const j = joints[v * 4 + k]
758
+ if (owner[j] >= 0) carried[side].set(j, (carried[side].get(j) ?? 0) + weights[v * 4 + k])
759
+ }
760
+ }
761
+ }
762
+ const carrier = (side: 0 | 1): number => {
763
+ let best = -1
764
+ let most = 0
765
+ for (const [j, w] of carried[side]) if (w > most) [best, most] = [j, w]
766
+ return best
399
767
  }
400
768
  const apart = lHead && rHead ? Math.hypot(lHead[0] - rHead[0], lHead[1] - rHead[1]) : 0
401
769
  const fallbackRadius = apart > 0 ? apart * 0.22 : 0.3
402
- const rest = (set: number[], bone: [number, number, number] | null, side: number): EyeRest => {
770
+ const rest = (set: number[], bone: [number, number, number] | null, side: number, carrierBone: number): EyeRest => {
403
771
  if (set.length < 3) {
404
772
  const c: [number, number, number] = bone ?? [side * fallbackRadius * 2, 0, 0]
405
- return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 }
773
+ return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1, pivot: null }
406
774
  }
407
775
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, z = 0
408
776
  for (const v of set) {
@@ -420,7 +788,7 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
420
788
  const rx = (maxX - minX) / 2
421
789
  const ry = (maxY - minY) / 2
422
790
  if (apart > 0 && (rx > apart * 0.6 || ry > apart * 0.8)) {
423
- return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 }
791
+ return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1, pivot: null }
424
792
  }
425
793
  const centre: [number, number, number] = [(minX + maxX) / 2, (minY + maxY) / 2, z / set.length]
426
794
  const radius: [number, number] = [Math.max(rx, 1e-3), Math.max(ry, 1e-3)]
@@ -428,7 +796,7 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
428
796
  // still turns its eyes visibly, so read it as a typical eyeball's.
429
797
  const r = (radius[0] + radius[1]) / 2
430
798
  const lever = Math.max(bone ? Math.hypot(centre[0] - bone[0], centre[1] - bone[1], centre[2] - bone[2]) : 0, r * 1.5)
431
- return { centre, radius, lever }
799
+ return { centre, radius, lever, verts: set, bone: carrierBone, pivot: bone }
432
800
  }
433
- return [rest(sets[0], lHead, leftSign), rest(sets[1], rHead, -leftSign)]
801
+ return [rest(sets[0], lHead, leftSign, carrier(0)), rest(sets[1], rHead, -leftSign, carrier(1))]
434
802
  }