reze-engine 0.68.4 → 0.68.6

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/engine.ts CHANGED
@@ -428,6 +428,11 @@ export interface MidiNote {
428
428
  /** How many character positions an effect can read (viewU[11..14]). Defined
429
429
  * beside the shader that reads them — the layout arithmetic has to agree. */
430
430
  const MAX_EFFECT_SUBJECTS = EFFECT_SUBJECTS
431
+ /** The frame's half height (model units) at and past which a camera follow
432
+ * takes its full smoothing; closer shots follow more rigidly — see the
433
+ * follow in renderWithDelta(). About a full-body shot. */
434
+ const FOLLOW_LAG_FULL_HALF = 8
435
+
431
436
  /** Where a character IS, for an effect that follows them. センター carries a
432
437
  * motion's root movement — walking, jumping — where the model transform only
433
438
  * carries where the model was placed; 全ての親 is the fallback for a model that
@@ -3042,6 +3047,12 @@ export class Engine {
3042
3047
  private cameraFollowSmoothing = 0
3043
3048
  private cameraFollowSeeded = false
3044
3049
  private readonly cameraFollowPos = new Vec3(0, 0, 0)
3050
+ // Turning with the bone (setCameraFollow's `turn`): the bone's facing as yaw
3051
+ // and elevation, eased like the position, whose change each frame is added
3052
+ // to the orbit — so the shot keeps its angle to her face as she turns.
3053
+ private cameraFollowTurn = false
3054
+ private cameraFollowYaw = 0
3055
+ private cameraFollowPitch = 0
3045
3056
  private cameraTargetOffset: Vec3 = new Vec3(0, 0, 0)
3046
3057
 
3047
3058
  private lastFrameTime = performance.now()
@@ -10227,8 +10238,13 @@ export class Engine {
10227
10238
  this.cameraTargetOffset.z = offset?.z ?? 0
10228
10239
  }
10229
10240
 
10230
- /** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
10231
- setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3, smoothing?: number): void {
10241
+ /**
10242
+ * Souls-style follow cam: orbit center tracks a model bone each frame.
10243
+ * Shorthand for setCameraTarget(model, boneName, offset). With `turn`, the
10244
+ * orbit also turns with her face (the head bone) — the camera keeps its
10245
+ * angle to her face as she turns and nods, and a drag still orbits on top.
10246
+ */
10247
+ setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3, smoothing?: number, turn?: boolean): void {
10232
10248
  // Panning sets the very thing the follow overwrites each frame, so it is
10233
10249
  // goes to the offset for as long as the shot is riding a bone. See Camera.panSink.
10234
10250
  this.camera.panSink = model !== null ? this.cameraTargetOffset : null
@@ -10244,6 +10260,7 @@ export class Engine {
10244
10260
  // Handheld feel: seconds for the camera to close ~63% of the gap to the
10245
10261
  // bone (exponential, frame-rate independent). 0 = rigid instant follow.
10246
10262
  this.cameraFollowSmoothing = Math.max(0, smoothing ?? 0)
10263
+ this.cameraFollowTurn = turn ?? false
10247
10264
  this.cameraFollowSeeded = false
10248
10265
  }
10249
10266
 
@@ -10405,6 +10422,49 @@ export class Engine {
10405
10422
  }
10406
10423
  }
10407
10424
 
10425
+ /**
10426
+ * The follow's turn: her FACE's direction — the head bone (頭), whatever bone
10427
+ * the position rides, since an eye under eye tracking turns back toward the
10428
+ * camera as the face turns and so never points anywhere new. MMD rests facing
10429
+ * −Z, so forward is its negated third column, placed by the model's
10430
+ * rotation (the followed bone stands in on a rig with no 頭) — as yaw and
10431
+ * elevation, eased by the follow's own lag, and each frame's change added to
10432
+ * the orbit (alpha turns with the yaw, beta with the elevation) and to the
10433
+ * offset (turned about the vertical, so a framing off to her side stays at
10434
+ * her side). Seeds without moving the camera, on the frame the follow is
10435
+ * (re)bound — cameraFollowSeeded, cleared there, is set right after this.
10436
+ */
10437
+ private turnWithFollowBone(m: Model, tau: number, deltaTime: number): void {
10438
+ const w = m.getBoneWorldMatrix("頭") ?? m.getBoneWorldMatrix(this.cameraTargetBoneName)
10439
+ if (!w) return
10440
+ const fwd = new Vec3(-w[8], -w[9], -w[10])
10441
+ Quat.rotateVecInto(m.rotation, fwd, fwd)
10442
+ const len = Math.hypot(fwd.x, fwd.y, fwd.z)
10443
+ if (len < 1e-6) return
10444
+ const yaw = Math.atan2(fwd.x, fwd.z)
10445
+ const pitch = Math.asin(Math.max(-1, Math.min(1, fwd.y / len)))
10446
+ if (!this.cameraFollowSeeded) {
10447
+ this.cameraFollowYaw = yaw
10448
+ this.cameraFollowPitch = pitch
10449
+ return
10450
+ }
10451
+ const wrap = (a: number) => a - 2 * Math.PI * Math.round(a / (2 * Math.PI))
10452
+ const k = tau > 0 ? 1 - Math.exp(-deltaTime / tau) : 1
10453
+ const dYaw = wrap(yaw - this.cameraFollowYaw) * k
10454
+ const dPitch = (pitch - this.cameraFollowPitch) * k
10455
+ this.cameraFollowYaw = wrap(this.cameraFollowYaw + dYaw)
10456
+ this.cameraFollowPitch += dPitch
10457
+ const cam = this.camera
10458
+ cam.alpha += dYaw
10459
+ cam.beta = Math.max(0.01, Math.min(Math.PI - 0.01, cam.beta - dPitch))
10460
+ const o = this.cameraTargetOffset
10461
+ const c = Math.cos(dYaw)
10462
+ const sn = Math.sin(dYaw)
10463
+ const ox = o.x
10464
+ o.x = ox * c + o.z * sn
10465
+ o.z = -ox * sn + o.z * c
10466
+ }
10467
+
10408
10468
  /** True while the orbit target is riding a model bone (setCameraFollow). */
10409
10469
  isCameraFollowing(): boolean {
10410
10470
  return this.cameraTargetModel !== null
@@ -13736,7 +13796,7 @@ export class Engine {
13736
13796
  }
13737
13797
 
13738
13798
  /**
13739
- * Jelly eyes: her irises flatten and bulge as her lids move, swell as her
13799
+ * Jelly eyes: her irises shrink and spring back as her lids move, swell as her
13740
13800
  * head turns or moves, and wobble back, and shine like a gem — two hard
13741
13801
  * glints, light glowing through the iris, deeper colour. MMD's 果冻眼, on any
13742
13802
  * PMX and under any eye graph. Null stills them again.
@@ -13755,14 +13815,17 @@ export class Engine {
13755
13815
  this.device.queue.writeBuffer(inst.lightBuffer, 0, inst.objectLight)
13756
13816
  return true
13757
13817
  }
13758
- if (inst.jellyEyes) inst.jellyEyes.strength = clampStrength(options.strength)
13818
+ if (inst.jellyEyes) {
13819
+ inst.jellyEyes.strength = clampStrength(options.strength)
13820
+ inst.jellyEyes.idle = options.idle ?? true
13821
+ }
13759
13822
  else inst.jellyEyes = new JellyEyes(inst.model, options)
13760
13823
  return true
13761
13824
  }
13762
13825
 
13763
13826
  getModelJellyEyes(modelName: string): Required<JellyEyeOptions> | null {
13764
13827
  const jelly = this.modelInstances.get(modelName)?.jellyEyes
13765
- return jelly ? { strength: jelly.strength } : null
13828
+ return jelly ? { strength: jelly.strength, idle: jelly.idle } : null
13766
13829
  }
13767
13830
 
13768
13831
  getModelCastShadow(modelName: string): boolean {
@@ -17439,8 +17502,16 @@ export class Engine {
17439
17502
  py += pos.y
17440
17503
  pz += pos.z
17441
17504
  }
17442
- const tau = this.cameraFollowSmoothing
17505
+ // The lag scales with how much the shot holds. Eased, the pivot trails
17506
+ // the bone by its speed × tau — nothing in a full-body shot, but in a
17507
+ // close-up of her eye it is most of the frame, and the eye swims out
17508
+ // of it as she moves. So: the full handheld lag once the frame is
17509
+ // FOLLOW_LAG_FULL_HALF tall (half height, model units — about a
17510
+ // full-body shot), shrinking linearly to rigid as the shot closes in.
17511
+ const half = this.camera.radius * Math.tan(this.camera.fov / 2)
17512
+ const tau = this.cameraFollowSmoothing * Math.min(1, half / FOLLOW_LAG_FULL_HALF)
17443
17513
  const f = this.cameraFollowPos
17514
+ if (this.cameraFollowTurn) this.turnWithFollowBone(m, tau, deltaTime)
17444
17515
  if (tau > 0 && this.cameraFollowSeeded) {
17445
17516
  // Exponential lag toward the bone: the handheld-camera feel. The
17446
17517
  // orbit pivot trails the target and eases in, never snapping.
package/src/jelly-eyes.ts CHANGED
@@ -3,8 +3,8 @@
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 flattens and
7
- // bulges as a lid moves (opening too), swells as her head turns or moves, and
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
8
  // wobbles back. It never slides — tried, it read as the iris coming loose. The
9
9
  // inputs are her blinks and her head, never her eyes' own turn: eye tracking
10
10
  // turns the eyes after the camera, and the camera must not shake the jelly.
@@ -22,6 +22,9 @@ import type { Model } from "./model"
22
22
  export interface JellyEyeOptions {
23
23
  /** How much the eyes give: 0 still, 1 the intended bounce, up to 2. Default 1. */
24
24
  strength?: number
25
+ /** The idle taps: a ripple every 4–8 s while nothing else moves them.
26
+ * Default true; false leaves only what her blinks and motion set off. */
27
+ idle?: boolean
25
28
  }
26
29
 
27
30
  // The closing morphs, by the names MMD, VRoid and the usual converters give
@@ -32,14 +35,15 @@ const LEFT = ["ウィンク", "ウィンク2", "ウィンク", "ウィンク
32
35
  const RIGHT = ["ウィンク右", "ウィンク2右", "ウィンク右", "ウィンク2右", "wink_R", "Wink_R", "blink_R", "Fcl_EYE_Close_R"]
33
36
 
34
37
  // The squash: how far a closed eye presses the picture, and the spring that
35
- // follows it — underdamped, so the eye opening overshoots into a stretch and
36
- // settles in two or three wobbles, the "Duang" the effect is named for.
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.
37
41
  // Only a CHANGE of closure squashes: the eye's closure less a slow follower of
38
42
  // it, so a blink squeezes then bounces, while a held half-close (a smile)
39
43
  // leaves the iris at rest instead of squashed for as long as she smiles.
40
44
  const SQUASH_CLOSED = 0.35
41
- const SQUASH_HZ = 1.4
42
- const SQUASH_ZETA = 0.22
45
+ const SQUASH_HZ = 1.8
46
+ const SQUASH_ZETA = 0.08
43
47
  const CLOSURE_FOLLOW_S = 0.35
44
48
  // The lag: the picture trails the eye's turn and catches up past it. Radians of
45
49
  // lag to eye radii of offset; the head's own turn counts for a third, so a
@@ -55,7 +59,7 @@ const LAG_ZETA = 0.4
55
59
  const LAG_GAIN = 0.5
56
60
  const LAG_MAX = (3 * Math.PI) / 180
57
61
  // And never past this many iris radii, whatever the lever, at strength 1.
58
- const OFFSET_MAX = 0.06
62
+ const OFFSET_MAX = 0.2
59
63
  const HEAD_SHARE = 0.35
60
64
  // The inertia: the iris has a little mass, hung on a spring from the head, so
61
65
  // her own motion — a bob, a step, a sway of the dance — leaves it behind and
@@ -65,11 +69,38 @@ const INERTIA_ZETA = 0.4
65
69
  const INERTIA_GAIN = 0.04
66
70
  // The jelly's give: the iris never moves, it swells. The lag and the inertia
67
71
  // are how hard it is being knocked (in iris radii, as if it could move); that
68
- // knock grows it a little and it settles in a wobble — the scale bounce on top
69
- // of the blink's squash. Iris scale per iris radius of knock.
70
- const PULSE_GAIN = 1.6
71
- const PULSE_HZ = 1.6
72
- const PULSE_ZETA = 0.24
72
+ // knock grows it a little and it ripples back, as the squash does — the bounce on top
73
+ // of the blink's squash. Eased, not linear: the swell is PULSE_MAX·tanh(knock /
74
+ // PULSE_REF), so the small steady knocks of a dance still show while a jolt
75
+ // (play from a pause: the head going from still to moving at once) cannot
76
+ // swell it past the cap.
77
+ const PULSE_MAX = 0.07
78
+ const PULSE_REF = 0.006
79
+ // And the same knock squashes it as a blink does — nearly round, on
80
+ // the blink's spring — so her motion reads as plainly as a blink. Eased by the
81
+ // same curve, up to MOTION_SQUASH (a blink's opening gives about 0.2).
82
+ const MOTION_SQUASH = 0.18
83
+ const PULSE_HZ = 2.0
84
+ const PULSE_ZETA = 0.08
85
+ // The idle taps: every 4–8 s, at uneven times, a knock into the
86
+ // same springs her motion drives — the iris dips, bounces and settles, then
87
+ // rests — the way a jelly on a table is never quite still. A steady sine was
88
+ // tried, as size (a zoom) and as wider-then-taller (queasy); a settling tap is
89
+ // what reads as jelly. A tap is a full KNOCK, exactly what her motion gives at
90
+ // its strongest — a gentler nudge was tried and went unnoticed — held for a
91
+ // moment and let go, so it rings through the same springs. The gaps come from
92
+ // a hash of the tap's count, so the scene clock alone decides them and an
93
+ // export taps where the screen did.
94
+ const IDLE_TAP_S = 0.12
95
+ // How hard a tap knocks (motion's strongest is 1) and how far apart they fall:
96
+ // a full knock every 2–4 s read as the iris zooming constantly; rarer, a touch
97
+ // softer, each one reads as a ripple and the eye rests between.
98
+ const IDLE_TAP = 0.6
99
+ const IDLE_GAP: [number, number] = [4, 8]
100
+ const idleGap = (n: number): number => {
101
+ const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453
102
+ return IDLE_GAP[0] + (x - Math.floor(x)) * (IDLE_GAP[1] - IDLE_GAP[0])
103
+ }
73
104
  // Integration step, and the jump past which a delta is a seek, not time.
74
105
  const SUBSTEP = 1 / 240
75
106
  const SEEK = 0.25
@@ -106,6 +137,18 @@ interface EyeRest {
106
137
  // Iris centre to the eye bone's head: what a turn moves the iris by, per
107
138
  // radian.
108
139
  lever: number
140
+ // The vertices it was measured from (empty for a fallback), and the bone
141
+ // that carries them (−1 when none does).
142
+ verts: number[]
143
+ bone: number
144
+ }
145
+
146
+ // A vertex morph's pull on an iris: the mean offset it gives the iris's
147
+ // vertices at weight 1. Morphs add linearly, so the iris's centre under any
148
+ // mix of them is the rest centre plus these, weighted.
149
+ interface MorphShift {
150
+ morph: number
151
+ d: [number, number, number]
109
152
  }
110
153
 
111
154
  const newState = (): EyeState => ({
@@ -123,37 +166,76 @@ const newState = (): EyeState => ({
123
166
 
124
167
  export class JellyEyes {
125
168
  strength: number
169
+ idle: boolean
170
+ // Scene time stepped so far, and the idle taps: how many, and when the next.
171
+ private clock = 0
172
+ private taps = 0
173
+ private nextTap = idleGap(0)
174
+ // The current tap's knock, 1 at the tap and fading over IDLE_TAP_S.
175
+ private tapKnock = 0
126
176
  private readonly rest: [EyeRest, EyeRest]
127
177
  private readonly state: [EyeState, EyeState] = [newState(), newState()]
128
178
  private readonly morphs: [number[], number[], number[]]
129
179
  private readonly eyeBones: [number, number]
130
180
  private readonly headBone: number
131
181
  private fresh = true
182
+ // Each iris's centre THIS frame: the rest centre carried by the morphs that
183
+ // move the iris (a pupil-up, a look-left) — the vertex shader scales the
184
+ // morphed iris, so a centre left at rest scales it about a point it has
185
+ // already left, and it drifts off as it shrinks and grows.
186
+ private readonly shifts: [MorphShift[], MorphShift[]]
187
+ private readonly centre: [[number, number, number], [number, number, number]]
132
188
  private readonly inertia: Inertia = { p: [0, 0, 0], v: [0, 0, 0], head: [0, 0, 0], offset: [0, 0] }
133
189
 
134
190
  constructor(model: Model, options: JellyEyeOptions) {
135
191
  this.strength = clampStrength(options.strength)
192
+ this.idle = options.idle ?? true
136
193
  const bones = model.getSkeleton().bones
137
194
  const index = (name: string) => bones.findIndex((b) => b.name === name)
138
195
  const both = index("両目")
139
196
  const l = index("左目")
140
197
  const r = index("右目")
141
- this.eyeBones = [l >= 0 ? l : both, r >= 0 ? r : both]
142
198
  this.headBone = index("頭")
143
199
  const names = model.getMorphing().morphs.map((m) => m.name)
144
200
  const find = (list: string[]) => list.map((n) => names.indexOf(n)).filter((i) => i >= 0)
145
201
  this.morphs = [find(BOTH), find(LEFT), find(RIGHT)]
146
202
  this.rest = findIrises(model, l, r, both)
203
+ // The bone that carries each iris, which may be under the eye bone.
204
+ this.eyeBones = [this.rest[0].bone, this.rest[1].bone]
205
+ this.shifts = [irisShifts(model, this.rest[0].verts), irisShifts(model, this.rest[1].verts)]
206
+ this.centre = [[...this.rest[0].centre], [...this.rest[1].centre]]
147
207
  }
148
208
 
149
209
  /** Advance both springs by dt seconds of scene time. */
150
210
  step(dt: number, model: Model): void {
151
211
  const weights = model.getEffectiveMorphWeights()
212
+ for (let e = 0; e < 2; e++) {
213
+ const c = this.centre[e]
214
+ const r = this.rest[e].centre
215
+ c[0] = r[0]
216
+ c[1] = r[1]
217
+ c[2] = r[2]
218
+ for (const { morph, d } of this.shifts[e]) {
219
+ const w = weights[morph] ?? 0
220
+ if (w === 0) continue
221
+ c[0] += d[0] * w
222
+ c[1] += d[1] * w
223
+ c[2] += d[2] * w
224
+ }
225
+ }
152
226
  const sum = (list: number[]) => list.reduce((s, i) => s + (weights[i] ?? 0), 0)
153
227
  const both = sum(this.morphs[0])
154
228
  const closure = [Math.min(1, both + sum(this.morphs[1])), Math.min(1, both + sum(this.morphs[2]))]
155
229
  const head = this.headBone >= 0 ? model.getBoneWorldMatrixAt(this.headBone) : null
156
230
  const seek = this.fresh || dt > SEEK || dt < 0
231
+ if (dt > 0) this.clock += dt
232
+ if (dt > 0) this.tapKnock *= Math.exp(-dt / IDLE_TAP_S)
233
+ if (seek) this.tapKnock = 0
234
+ else if (this.idle && this.clock >= this.nextTap) {
235
+ this.tapKnock = IDLE_TAP
236
+ this.taps++
237
+ this.nextTap = this.clock + idleGap(this.taps)
238
+ }
157
239
  this.stepInertia(dt, head, seek)
158
240
  for (let e = 0; e < 2; e++) {
159
241
  const s = this.state[e]
@@ -167,7 +249,8 @@ export class JellyEyes {
167
249
  else s.settled += (s.closure - s.settled) * Math.min(1, dt / CLOSURE_FOLLOW_S)
168
250
  // Either way a lid moves squashes — opening too, which is the half of a
169
251
  // blink you can see (closing, the lid hides the iris anyway).
170
- const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled)
252
+ const knock = seek ? 0 : Math.max(this.tapKnock, Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF))
253
+ const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled) + MOTION_SQUASH * knock
171
254
  if (seek) {
172
255
  s.squash = squashTarget
173
256
  s.squashV = 0
@@ -180,7 +263,7 @@ export class JellyEyes {
180
263
  }
181
264
  const steps = Math.ceil(dt / SUBSTEP)
182
265
  const h = steps > 0 ? dt / steps : 0
183
- const pulseTarget = PULSE_GAIN * Math.hypot(...this.offset(s, e))
266
+ const pulseTarget = PULSE_MAX * knock
184
267
  for (let i = 0; i < steps; i++) {
185
268
  s.pulseV += h * spring(s.pulse - pulseTarget, s.pulseV, PULSE_HZ, PULSE_ZETA)
186
269
  s.pulse += h * s.pulseV
@@ -201,17 +284,18 @@ export class JellyEyes {
201
284
 
202
285
  /** Write jelly[0..5] into an ObjectLight copy (floats 48..71). */
203
286
  write(o: Float32Array): void {
204
- const [L, R] = this.rest
205
- o.set([L.centre[0], L.centre[1], L.centre[2], this.strength], 48)
287
+ const [L, R] = this.centre
288
+ o.set([L[0], L[1], L[2], this.strength], 48)
206
289
  // One scale bounce for both: the mean of the two eyes', so they swell together.
207
290
  const pulse = ((this.state[0].pulse + this.state[1].pulse) / 2) * this.strength
208
- o.set([R.centre[0], R.centre[1], R.centre[2], pulse], 52)
291
+ o.set([R[0], R[1], R[2], pulse], 52)
209
292
  for (let e = 0; e < 2; e++) {
210
293
  const s = this.state[e]
211
294
  const rest = this.rest[e]
212
295
  o.set([rest.radius[0], rest.radius[1], s.closure, this.eyeBones[e]], 56 + e * 4)
213
- // Offset words (65, 66) stay 0: the iris holds its place; only scale.
214
- o.set([s.squash * this.strength, 0, 0, s.shine], 64 + e * 4)
296
+ // Word 65 stays 0 (the iris holds its place; only scale); 66 carries the
297
+ // scene clock for the gem's twinkles, wrapped so a float keeps it exact.
298
+ o.set([s.squash * this.strength, 0, this.clock % 1000, s.shine], 64 + e * 4)
215
299
  }
216
300
  }
217
301
 
@@ -311,6 +395,64 @@ function look(eye: Float32Array | null, head: Float32Array | null, out: [number,
311
395
 
312
396
  const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
313
397
 
398
+ /**
399
+ * The iris out of everything one eye carries: grouped by material, the group
400
+ * whose centre is nearest the eye bone, and any other group centred on it
401
+ * (within its own size) — layers of the same eye. Without materials (or a
402
+ * bone), the set as it came.
403
+ */
404
+ function irisOnly(model: Model, set: number[], bone: [number, number, number] | null): number[] {
405
+ const materials = model.getMaterials?.() ?? []
406
+ const { positions, indices } = model.getGeometry()
407
+ if (set.length < 3 || !bone || !materials.length || !indices.length) return set
408
+ const material = new Int32Array(positions.length / 3).fill(-1)
409
+ let start = 0
410
+ materials.forEach((m, i) => {
411
+ for (let k = start; k < start + m.vertexCount; k++) if (material[indices[k]] < 0) material[indices[k]] = i
412
+ start += m.vertexCount
413
+ })
414
+ const groups = new Map<number, number[]>()
415
+ for (const v of set) {
416
+ const g = groups.get(material[v])
417
+ if (g) g.push(v)
418
+ else groups.set(material[v], [v])
419
+ }
420
+ if (groups.size < 2) return set
421
+ const boxes = [...groups.values()].map((vs) => {
422
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
423
+ for (const v of vs) {
424
+ minX = Math.min(minX, positions[v * 3])
425
+ maxX = Math.max(maxX, positions[v * 3])
426
+ minY = Math.min(minY, positions[v * 3 + 1])
427
+ maxY = Math.max(maxY, positions[v * 3 + 1])
428
+ }
429
+ return { vs, x: (minX + maxX) / 2, y: (minY + maxY) / 2, size: Math.max(maxX - minX, maxY - minY) / 2 }
430
+ })
431
+ 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))
432
+ return boxes.filter((b) => Math.hypot(b.x - near.x, b.y - near.y) <= near.size).flatMap((b) => b.vs)
433
+ }
434
+
435
+ /** Every vertex morph that moves this iris, with the mean offset it gives it. */
436
+ function irisShifts(model: Model, verts: number[]): MorphShift[] {
437
+ if (!verts.length) return []
438
+ const inIris = new Set(verts)
439
+ const out: MorphShift[] = []
440
+ model.getMorphing().morphs.forEach((m, morph) => {
441
+ const d: [number, number, number] = [0, 0, 0]
442
+ for (const o of m.vertexOffsets ?? []) {
443
+ if (!inIris.has(o.vertexIndex)) continue
444
+ d[0] += o.positionOffset[0]
445
+ d[1] += o.positionOffset[1]
446
+ d[2] += o.positionOffset[2]
447
+ }
448
+ d[0] /= verts.length
449
+ d[1] /= verts.length
450
+ d[2] /= verts.length
451
+ if (Math.hypot(...d) > 1e-6) out.push({ morph, d })
452
+ })
453
+ return out
454
+ }
455
+
314
456
  /**
315
457
  * Each iris in bind space: the vertices the eye bones carry are exactly the
316
458
  * part of the eye that turns to look — the iris and pupil — so their centroid
@@ -336,29 +478,71 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
336
478
  const leftSign = lHead && rHead ? Math.sign(lHead[0] - rHead[0]) || 1 : 1
337
479
  const { positions } = model.getGeometry()
338
480
  const { joints, weights } = model.getSkinning()
339
- const sets: [number[], number[]] = [[], []]
340
- const n = positions.length / 3
341
- for (let v = 0; v < n; v++) {
342
- let wl = 0
343
- let wr = 0
344
- let wb = 0
345
- for (let k = 0; k < 4; k++) {
346
- const j = joints[v * 4 + k]
347
- const w = weights[v * 4 + k]
348
- if (j === l) wl += w
349
- else if (j === r) wr += w
350
- else if (j === both) wb += w
481
+ // Which eye each bone turns with: an eye bone, or any bone under one. Rigs
482
+ // differ in which carries the iris — some hang it on the eye bone itself,
483
+ // some on its tip (左目先/右目先), which turns with it all the same.
484
+ const owner = bones.map((_, i) => {
485
+ for (let b = i; b >= 0; b = bones[b].parentIndex) {
486
+ if (b === l) return 0
487
+ if (b === r) return 1
488
+ if (b === both) return 2
351
489
  }
352
- if (wl >= 128) sets[0].push(v)
353
- else if (wr >= 128) sets[1].push(v)
354
- else if (wb >= 128) sets[Math.sign(positions[v * 3]) === leftSign ? 0 : 1].push(v)
490
+ return -1
491
+ })
492
+ // Per side, the vertices and how much iris weight each bone carries: the
493
+ // heaviest is the one the vertex shader scales by (one bone per eye).
494
+ //
495
+ // The per-eye trees first. 両目 is a fallback for rigs that carry both irises
496
+ // on it alone: where 左目/右目 do carry them, anything hung on 両目 is
497
+ // something else that turns with the eyes — an expression overlay above them
498
+ // (blush lines, sparkles) — and split by side it stretches each "iris" past
499
+ // any iris, so the size check rejects both and the eyes never move.
500
+ const gather = (useBoth: boolean) => {
501
+ const sets: [number[], number[]] = [[], []]
502
+ const n = positions.length / 3
503
+ for (let v = 0; v < n; v++) {
504
+ const w3 = [0, 0, 0]
505
+ for (let k = 0; k < 4; k++) {
506
+ const o = owner[joints[v * 4 + k]]
507
+ if (o >= 0) w3[o] += weights[v * 4 + k]
508
+ }
509
+ let side: 0 | 1
510
+ if (w3[0] >= 128) side = 0
511
+ else if (w3[1] >= 128) side = 1
512
+ else if (useBoth && w3[2] >= 128) side = Math.sign(positions[v * 3]) === leftSign ? 0 : 1
513
+ else continue
514
+ sets[side].push(v)
515
+ }
516
+ return sets
517
+ }
518
+ let gathered = gather(false)
519
+ if (gathered[0].length < 3 && gathered[1].length < 3) gathered = gather(true)
520
+ // Of what turns with an eye, the iris is the mesh nearest its bone, with the
521
+ // layers stacked on it (an eye white, a highlight sheet over the same disc).
522
+ // A mesh elsewhere that the eye bones also carry — an expression overlay
523
+ // above the eyes — is left out, or it stretches the "iris" past any iris.
524
+ const sets: [number[], number[]] = [irisOnly(model, gathered[0], lHead), irisOnly(model, gathered[1], rHead)]
525
+ const carried: [Map<number, number>, Map<number, number>] = [new Map(), new Map()]
526
+ for (const side of [0, 1] as const) {
527
+ for (const v of sets[side]) {
528
+ for (let k = 0; k < 4; k++) {
529
+ const j = joints[v * 4 + k]
530
+ if (owner[j] >= 0) carried[side].set(j, (carried[side].get(j) ?? 0) + weights[v * 4 + k])
531
+ }
532
+ }
533
+ }
534
+ const carrier = (side: 0 | 1): number => {
535
+ let best = -1
536
+ let most = 0
537
+ for (const [j, w] of carried[side]) if (w > most) [best, most] = [j, w]
538
+ return best
355
539
  }
356
540
  const apart = lHead && rHead ? Math.hypot(lHead[0] - rHead[0], lHead[1] - rHead[1]) : 0
357
541
  const fallbackRadius = apart > 0 ? apart * 0.22 : 0.3
358
- const rest = (set: number[], bone: [number, number, number] | null, side: number): EyeRest => {
542
+ const rest = (set: number[], bone: [number, number, number] | null, side: number, carrierBone: number): EyeRest => {
359
543
  if (set.length < 3) {
360
544
  const c: [number, number, number] = bone ?? [side * fallbackRadius * 2, 0, 0]
361
- return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 }
545
+ return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1 }
362
546
  }
363
547
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, z = 0
364
548
  for (const v of set) {
@@ -376,7 +560,7 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
376
560
  const rx = (maxX - minX) / 2
377
561
  const ry = (maxY - minY) / 2
378
562
  if (apart > 0 && (rx > apart * 0.6 || ry > apart * 0.8)) {
379
- return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 }
563
+ return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1 }
380
564
  }
381
565
  const centre: [number, number, number] = [(minX + maxX) / 2, (minY + maxY) / 2, z / set.length]
382
566
  const radius: [number, number] = [Math.max(rx, 1e-3), Math.max(ry, 1e-3)]
@@ -384,7 +568,7 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
384
568
  // still turns its eyes visibly, so read it as a typical eyeball's.
385
569
  const r = (radius[0] + radius[1]) / 2
386
570
  const lever = Math.max(bone ? Math.hypot(centre[0] - bone[0], centre[1] - bone[1], centre[2] - bone[2]) : 0, r * 1.5)
387
- return { centre, radius, lever }
571
+ return { centre, radius, lever, verts: set, bone: carrierBone }
388
572
  }
389
- return [rest(sets[0], lHead, leftSign), rest(sets[1], rHead, -leftSign)]
573
+ return [rest(sets[0], lHead, leftSign, carrier(0)), rest(sets[1], rHead, -leftSign, carrier(1))]
390
574
  }