reze-engine 0.68.5 → 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.
@@ -2,7 +2,7 @@ import type { Model } from "./model";
2
2
  export interface JellyEyeOptions {
3
3
  /** How much the eyes give: 0 still, 1 the intended bounce, up to 2. Default 1. */
4
4
  strength?: number;
5
- /** The idle taps: a small bounce every few seconds while nothing else moves them.
5
+ /** The idle taps: a ripple every 4–8 s while nothing else moves them.
6
6
  * Default true; false leaves only what her blinks and motion set off. */
7
7
  idle?: boolean;
8
8
  }
@@ -12,12 +12,15 @@ export declare class JellyEyes {
12
12
  private clock;
13
13
  private taps;
14
14
  private nextTap;
15
+ private tapKnock;
15
16
  private readonly rest;
16
17
  private readonly state;
17
18
  private readonly morphs;
18
19
  private readonly eyeBones;
19
20
  private readonly headBone;
20
21
  private fresh;
22
+ private readonly shifts;
23
+ private readonly centre;
21
24
  private readonly inertia;
22
25
  constructor(model: Model, options: JellyEyeOptions);
23
26
  /** Advance both springs by dt seconds of scene time. */
@@ -1 +1 @@
1
- {"version":3,"file":"jelly-eyes.d.ts","sourceRoot":"","sources":["../src/jelly-eyes.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAEpC,MAAM,WAAW,eAAe;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;8EAC0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AA0HD,qBAAa,SAAS;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,OAAO,CAAA;IAEb,OAAO,CAAC,KAAK,CAAI;IACjB,OAAO,CAAC,IAAI,CAAI;IAChB,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAoB;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiD;IACvE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;IACvD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,KAAK,CAAO;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2E;gBAEvF,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe;IAgBlD,wDAAwD;IACxD,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI;IAgEpC,kEAAkE;IAClE,KAAK,CAAC,CAAC,EAAE,YAAY,GAAG,IAAI;IAe5B;6CACyC;IACzC,OAAO,CAAC,MAAM;IAad;iDAC6C;IAC7C,OAAO,CAAC,WAAW;CAkCpB;AAED,qEAAqE;AACrE,wBAAgB,cAAc,CAAC,CAAC,EAAE,YAAY,GAAG,IAAI,CAEpD;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAE3D"}
1
+ {"version":3,"file":"jelly-eyes.d.ts","sourceRoot":"","sources":["../src/jelly-eyes.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAEpC,MAAM,WAAW,eAAe;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;8EAC0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AA2ID,qBAAa,SAAS;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,OAAO,CAAA;IAEb,OAAO,CAAC,KAAK,CAAI;IACjB,OAAO,CAAC,IAAI,CAAI;IAChB,OAAO,CAAC,OAAO,CAAa;IAE5B,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAoB;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiD;IACvE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;IACvD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,KAAK,CAAO;IAKpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8B;IACrD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsD;IAC7E,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2E;gBAEvF,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe;IAmBlD,wDAAwD;IACxD,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI;IA2EpC,kEAAkE;IAClE,KAAK,CAAC,CAAC,EAAE,YAAY,GAAG,IAAI;IAgB5B;6CACyC;IACzC,OAAO,CAAC,MAAM;IAad;iDAC6C;IAC7C,OAAO,CAAC,WAAW;CAkCpB;AAED,qEAAqE;AACrE,wBAAgB,cAAc,CAAC,CAAC,EAAE,YAAY,GAAG,IAAI,CAEpD;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAE3D"}
@@ -70,16 +70,21 @@ const PULSE_REF = 0.006;
70
70
  const MOTION_SQUASH = 0.18;
71
71
  const PULSE_HZ = 2.0;
72
72
  const PULSE_ZETA = 0.08;
73
- // The idle taps: every few seconds, at uneven times, a small knock into the
73
+ // The idle taps: every 4–8 s, at uneven times, a knock into the
74
74
  // same springs her motion drives — the iris dips, bounces and settles, then
75
75
  // rests — the way a jelly on a table is never quite still. A steady sine was
76
76
  // 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];
77
+ // what reads as jelly. A tap is a full KNOCK, exactly what her motion gives at
78
+ // its strongest — a gentler nudge was tried and went unnoticed — held for a
79
+ // moment and let go, so it rings through the same springs. The gaps come from
80
+ // a hash of the tap's count, so the scene clock alone decides them and an
81
+ // export taps where the screen did.
82
+ const IDLE_TAP_S = 0.12;
83
+ // How hard a tap knocks (motion's strongest is 1) and how far apart they fall:
84
+ // a full knock every 2–4 s read as the iris zooming constantly; rarer, a touch
85
+ // softer, each one reads as a ripple and the eye rests between.
86
+ const IDLE_TAP = 0.6;
87
+ const IDLE_GAP = [4, 8];
83
88
  const idleGap = (n) => {
84
89
  const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
85
90
  return IDLE_GAP[0] + (x - Math.floor(x)) * (IDLE_GAP[1] - IDLE_GAP[0]);
@@ -105,6 +110,8 @@ export class JellyEyes {
105
110
  this.clock = 0;
106
111
  this.taps = 0;
107
112
  this.nextTap = idleGap(0);
113
+ // The current tap's knock, 1 at the tap and fading over IDLE_TAP_S.
114
+ this.tapKnock = 0;
108
115
  this.state = [newState(), newState()];
109
116
  this.fresh = true;
110
117
  this.inertia = { p: [0, 0, 0], v: [0, 0, 0], head: [0, 0, 0], offset: [0, 0] };
@@ -115,16 +122,34 @@ export class JellyEyes {
115
122
  const both = index("両目");
116
123
  const l = index("左目");
117
124
  const r = index("右目");
118
- this.eyeBones = [l >= 0 ? l : both, r >= 0 ? r : both];
119
125
  this.headBone = index("頭");
120
126
  const names = model.getMorphing().morphs.map((m) => m.name);
121
127
  const find = (list) => list.map((n) => names.indexOf(n)).filter((i) => i >= 0);
122
128
  this.morphs = [find(BOTH), find(LEFT), find(RIGHT)];
123
129
  this.rest = findIrises(model, l, r, both);
130
+ // The bone that carries each iris, which may be under the eye bone.
131
+ this.eyeBones = [this.rest[0].bone, this.rest[1].bone];
132
+ this.shifts = [irisShifts(model, this.rest[0].verts), irisShifts(model, this.rest[1].verts)];
133
+ this.centre = [[...this.rest[0].centre], [...this.rest[1].centre]];
124
134
  }
125
135
  /** Advance both springs by dt seconds of scene time. */
126
136
  step(dt, model) {
127
137
  const weights = model.getEffectiveMorphWeights();
138
+ for (let e = 0; e < 2; e++) {
139
+ const c = this.centre[e];
140
+ const r = this.rest[e].centre;
141
+ c[0] = r[0];
142
+ c[1] = r[1];
143
+ c[2] = r[2];
144
+ for (const { morph, d } of this.shifts[e]) {
145
+ const w = weights[morph] ?? 0;
146
+ if (w === 0)
147
+ continue;
148
+ c[0] += d[0] * w;
149
+ c[1] += d[1] * w;
150
+ c[2] += d[2] * w;
151
+ }
152
+ }
128
153
  const sum = (list) => list.reduce((s, i) => s + (weights[i] ?? 0), 0);
129
154
  const both = sum(this.morphs[0]);
130
155
  const closure = [Math.min(1, both + sum(this.morphs[1])), Math.min(1, both + sum(this.morphs[2]))];
@@ -132,9 +157,12 @@ export class JellyEyes {
132
157
  const seek = this.fresh || dt > SEEK || dt < 0;
133
158
  if (dt > 0)
134
159
  this.clock += dt;
135
- let tap = false;
136
- if (!seek && this.idle && this.clock >= this.nextTap) {
137
- tap = true;
160
+ if (dt > 0)
161
+ this.tapKnock *= Math.exp(-dt / IDLE_TAP_S);
162
+ if (seek)
163
+ this.tapKnock = 0;
164
+ else if (this.idle && this.clock >= this.nextTap) {
165
+ this.tapKnock = IDLE_TAP;
138
166
  this.taps++;
139
167
  this.nextTap = this.clock + idleGap(this.taps);
140
168
  }
@@ -153,7 +181,7 @@ export class JellyEyes {
153
181
  s.settled += (s.closure - s.settled) * Math.min(1, dt / CLOSURE_FOLLOW_S);
154
182
  // Either way a lid moves squashes — opening too, which is the half of a
155
183
  // 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);
184
+ const knock = seek ? 0 : Math.max(this.tapKnock, Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF));
157
185
  const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled) + MOTION_SQUASH * knock;
158
186
  if (seek) {
159
187
  s.squash = squashTarget;
@@ -165,10 +193,6 @@ export class JellyEyes {
165
193
  s.pulse = s.pulseV = 0;
166
194
  continue;
167
195
  }
168
- if (tap) {
169
- s.squashV += IDLE_SQUASH_KICK;
170
- s.pulseV -= IDLE_PULSE_KICK;
171
- }
172
196
  const steps = Math.ceil(dt / SUBSTEP);
173
197
  const h = steps > 0 ? dt / steps : 0;
174
198
  const pulseTarget = PULSE_MAX * knock;
@@ -191,17 +215,18 @@ export class JellyEyes {
191
215
  }
192
216
  /** Write jelly[0..5] into an ObjectLight copy (floats 48..71). */
193
217
  write(o) {
194
- const [L, R] = this.rest;
195
- o.set([L.centre[0], L.centre[1], L.centre[2], this.strength], 48);
218
+ const [L, R] = this.centre;
219
+ o.set([L[0], L[1], L[2], this.strength], 48);
196
220
  // One scale bounce for both: the mean of the two eyes', so they swell together.
197
221
  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);
222
+ o.set([R[0], R[1], R[2], pulse], 52);
199
223
  for (let e = 0; e < 2; e++) {
200
224
  const s = this.state[e];
201
225
  const rest = this.rest[e];
202
226
  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);
227
+ // Word 65 stays 0 (the iris holds its place; only scale); 66 carries the
228
+ // scene clock for the gem's twinkles, wrapped so a float keeps it exact.
229
+ o.set([s.squash * this.strength, 0, this.clock % 1000, s.shine], 64 + e * 4);
205
230
  }
206
231
  }
207
232
  /** Where the iris sits off rest, in iris radii (right, up): the lag through
@@ -295,6 +320,71 @@ function look(eye, head, out) {
295
320
  }
296
321
  }
297
322
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
323
+ /**
324
+ * The iris out of everything one eye carries: grouped by material, the group
325
+ * whose centre is nearest the eye bone, and any other group centred on it
326
+ * (within its own size) — layers of the same eye. Without materials (or a
327
+ * bone), the set as it came.
328
+ */
329
+ function irisOnly(model, set, bone) {
330
+ const materials = model.getMaterials?.() ?? [];
331
+ const { positions, indices } = model.getGeometry();
332
+ if (set.length < 3 || !bone || !materials.length || !indices.length)
333
+ return set;
334
+ const material = new Int32Array(positions.length / 3).fill(-1);
335
+ let start = 0;
336
+ materials.forEach((m, i) => {
337
+ for (let k = start; k < start + m.vertexCount; k++)
338
+ if (material[indices[k]] < 0)
339
+ material[indices[k]] = i;
340
+ start += m.vertexCount;
341
+ });
342
+ const groups = new Map();
343
+ for (const v of set) {
344
+ const g = groups.get(material[v]);
345
+ if (g)
346
+ g.push(v);
347
+ else
348
+ groups.set(material[v], [v]);
349
+ }
350
+ if (groups.size < 2)
351
+ return set;
352
+ const boxes = [...groups.values()].map((vs) => {
353
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
354
+ for (const v of vs) {
355
+ minX = Math.min(minX, positions[v * 3]);
356
+ maxX = Math.max(maxX, positions[v * 3]);
357
+ minY = Math.min(minY, positions[v * 3 + 1]);
358
+ maxY = Math.max(maxY, positions[v * 3 + 1]);
359
+ }
360
+ return { vs, x: (minX + maxX) / 2, y: (minY + maxY) / 2, size: Math.max(maxX - minX, maxY - minY) / 2 };
361
+ });
362
+ 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));
363
+ return boxes.filter((b) => Math.hypot(b.x - near.x, b.y - near.y) <= near.size).flatMap((b) => b.vs);
364
+ }
365
+ /** Every vertex morph that moves this iris, with the mean offset it gives it. */
366
+ function irisShifts(model, verts) {
367
+ if (!verts.length)
368
+ return [];
369
+ const inIris = new Set(verts);
370
+ const out = [];
371
+ model.getMorphing().morphs.forEach((m, morph) => {
372
+ const d = [0, 0, 0];
373
+ for (const o of m.vertexOffsets ?? []) {
374
+ if (!inIris.has(o.vertexIndex))
375
+ continue;
376
+ d[0] += o.positionOffset[0];
377
+ d[1] += o.positionOffset[1];
378
+ d[2] += o.positionOffset[2];
379
+ }
380
+ d[0] /= verts.length;
381
+ d[1] /= verts.length;
382
+ d[2] /= verts.length;
383
+ if (Math.hypot(...d) > 1e-6)
384
+ out.push({ morph, d });
385
+ });
386
+ return out;
387
+ }
298
388
  /**
299
389
  * Each iris in bind space: the vertices the eye bones carry are exactly the
300
390
  * part of the eye that turns to look — the iris and pupil — so their centroid
@@ -321,35 +411,83 @@ function findIrises(model, l, r, both) {
321
411
  const leftSign = lHead && rHead ? Math.sign(lHead[0] - rHead[0]) || 1 : 1;
322
412
  const { positions } = model.getGeometry();
323
413
  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;
414
+ // Which eye each bone turns with: an eye bone, or any bone under one. Rigs
415
+ // differ in which carries the iris — some hang it on the eye bone itself,
416
+ // some on its tip (左目先/右目先), which turns with it all the same.
417
+ const owner = bones.map((_, i) => {
418
+ for (let b = i; b >= 0; b = bones[b].parentIndex) {
419
+ if (b === l)
420
+ return 0;
421
+ if (b === r)
422
+ return 1;
423
+ if (b === both)
424
+ return 2;
425
+ }
426
+ return -1;
427
+ });
428
+ // Per side, the vertices and how much iris weight each bone carries: the
429
+ // heaviest is the one the vertex shader scales by (one bone per eye).
430
+ //
431
+ // The per-eye trees first. 両目 is a fallback for rigs that carry both irises
432
+ // on it alone: where 左目/右目 do carry them, anything hung on 両目 is
433
+ // something else that turns with the eyes — an expression overlay above them
434
+ // (blush lines, sparkles) — and split by side it stretches each "iris" past
435
+ // any iris, so the size check rejects both and the eyes never move.
436
+ const gather = (useBoth) => {
437
+ const sets = [[], []];
438
+ const n = positions.length / 3;
439
+ for (let v = 0; v < n; v++) {
440
+ const w3 = [0, 0, 0];
441
+ for (let k = 0; k < 4; k++) {
442
+ const o = owner[joints[v * 4 + k]];
443
+ if (o >= 0)
444
+ w3[o] += weights[v * 4 + k];
445
+ }
446
+ let side;
447
+ if (w3[0] >= 128)
448
+ side = 0;
449
+ else if (w3[1] >= 128)
450
+ side = 1;
451
+ else if (useBoth && w3[2] >= 128)
452
+ side = Math.sign(positions[v * 3]) === leftSign ? 0 : 1;
453
+ else
454
+ continue;
455
+ sets[side].push(v);
456
+ }
457
+ return sets;
458
+ };
459
+ let gathered = gather(false);
460
+ if (gathered[0].length < 3 && gathered[1].length < 3)
461
+ gathered = gather(true);
462
+ // Of what turns with an eye, the iris is the mesh nearest its bone, with the
463
+ // layers stacked on it (an eye white, a highlight sheet over the same disc).
464
+ // A mesh elsewhere that the eye bones also carry — an expression overlay
465
+ // above the eyes — is left out, or it stretches the "iris" past any iris.
466
+ const sets = [irisOnly(model, gathered[0], lHead), irisOnly(model, gathered[1], rHead)];
467
+ const carried = [new Map(), new Map()];
468
+ for (const side of [0, 1]) {
469
+ for (const v of sets[side]) {
470
+ for (let k = 0; k < 4; k++) {
471
+ const j = joints[v * 4 + k];
472
+ if (owner[j] >= 0)
473
+ carried[side].set(j, (carried[side].get(j) ?? 0) + weights[v * 4 + k]);
474
+ }
339
475
  }
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
476
  }
477
+ const carrier = (side) => {
478
+ let best = -1;
479
+ let most = 0;
480
+ for (const [j, w] of carried[side])
481
+ if (w > most)
482
+ [best, most] = [j, w];
483
+ return best;
484
+ };
347
485
  const apart = lHead && rHead ? Math.hypot(lHead[0] - rHead[0], lHead[1] - rHead[1]) : 0;
348
486
  const fallbackRadius = apart > 0 ? apart * 0.22 : 0.3;
349
- const rest = (set, bone, side) => {
487
+ const rest = (set, bone, side, carrierBone) => {
350
488
  if (set.length < 3) {
351
489
  const c = bone ?? [side * fallbackRadius * 2, 0, 0];
352
- return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 };
490
+ return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1 };
353
491
  }
354
492
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, z = 0;
355
493
  for (const v of set) {
@@ -367,7 +505,7 @@ function findIrises(model, l, r, both) {
367
505
  const rx = (maxX - minX) / 2;
368
506
  const ry = (maxY - minY) / 2;
369
507
  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 };
508
+ return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1 };
371
509
  }
372
510
  const centre = [(minX + maxX) / 2, (minY + maxY) / 2, z / set.length];
373
511
  const radius = [Math.max(rx, 1e-3), Math.max(ry, 1e-3)];
@@ -375,7 +513,7 @@ function findIrises(model, l, r, both) {
375
513
  // still turns its eyes visibly, so read it as a typical eyeball's.
376
514
  const r = (radius[0] + radius[1]) / 2;
377
515
  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 };
516
+ return { centre, radius, lever, verts: set, bone: carrierBone };
379
517
  };
380
- return [rest(sets[0], lHead, leftSign), rest(sets[1], rHead, -leftSign)];
518
+ return [rest(sets[0], lHead, leftSign, carrier(0)), rest(sets[1], rHead, -leftSign, carrier(1))];
381
519
  }
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,oBAAoB,QAkXhC,CAAC;AAwHF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC6H,CAAA"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,oBAAoB,QAyZhC,CAAC;AAwHF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC6H,CAAA"}
@@ -219,7 +219,7 @@ fn _rzCastShadow(wp: vec3f) -> f32 {
219
219
  // [1] right iris centre, scale bounce (both eyes)
220
220
  // [2] left iris half extents, closure, eye bone (−1 none)
221
221
  // [3] the right's
222
- // [4] left squash, 0, 0, shine [5] the right's
222
+ // [4] left squash, 0, clock, shine [5] the right's
223
223
  struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9>, fill: vec4f, jelly: array<vec4f, 6> }
224
224
  @group(1) @binding(1) var<uniform> objectLight: ObjectLight;
225
225
 
@@ -231,6 +231,8 @@ struct RzJelly {
231
231
  w: f32,
232
232
  closure: f32,
233
233
  shine: f32,
234
+ /** Scene seconds (wrapped), for the twinkles. */
235
+ clock: f32,
234
236
  };
235
237
 
236
238
  /**
@@ -280,7 +282,7 @@ fn rzJellyEye(rp: vec3f) -> RzJelly {
280
282
  let a = select(qL, qR, right);
281
283
  let st = select(j[4], j[5], right);
282
284
  let w = (1.0 - smoothstep(0.85, 1.0, length(a))) * select(0.0, 1.0, j[0].w > 0.0);
283
- return RzJelly(a, w, select(j[2].z, j[3].z, right), st.w);
285
+ return RzJelly(a, w, select(j[2].z, j[3].z, right), st.w, j[4].z);
284
286
  }
285
287
 
286
288
  /**
@@ -308,7 +310,12 @@ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec
308
310
  g = g * min(1.0, 0.25 / max(length(g), 1e-6)) - vec2f(0.0, 0.1);
309
311
  g.y = min(g.y, 0.0);
310
312
  let aa = max(length(fwidth(jj.src)), 1e-4);
311
- let glint = 1.0 - smoothstep(0.16 - aa, 0.16 + aa, distance(jj.src, g));
313
+ // A star, not only a disc: the disc with two thin rays through it, the
314
+ // anime sparkle.
315
+ let dg = jj.src - g;
316
+ let rays = max(0.0, 1.0 - abs(dg.y) / 0.018) * (1.0 - smoothstep(0.0, 0.34, abs(dg.x)))
317
+ + max(0.0, 1.0 - abs(dg.x) / 0.018) * (1.0 - smoothstep(0.0, 0.26, abs(dg.y)));
318
+ let glint = max(1.0 - smoothstep(0.13 - aa, 0.13 + aa, length(dg)), rays * 0.8);
312
319
  let second = 1.0 - smoothstep(0.07 - aa, 0.07 + aa, distance(jj.src, -0.55 * g + vec2f(0.1, -0.12)));
313
320
  let facing = saturate(dot(fwd, v) * 2.0);
314
321
  let key = clamp(dot(sun, vec3f(0.2126, 0.7152, 0.0722)), 0.3, 2.0);
@@ -328,7 +335,9 @@ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec
328
335
  // the far side, so the iris glows in its own colour in a crescent opposite
329
336
  // the glint — the lit-from-within look of a gem, or a jelly.
330
337
  let r = length(jj.src);
331
- let away = -normalize(g + vec2f(0.0, -1e-3));
338
+ // Out the LOWER iris, always — the anime gem's bright underside — leaning
339
+ // away from the glint; never up, where its bloom would spill on the lid.
340
+ let away = normalize(vec2f(-g.x * 0.6, -1.0));
332
341
  let crescent = smoothstep(0.15, 0.7, dot(jj.src, away)) * (1.0 - smoothstep(0.8, 1.0, r));
333
342
  let through = tex * crescent * (2.2 + 1.0 * jj.shine) * lit;
334
343
  // And the colour runs deeper: the iris's own hue pushed, never its greys.
@@ -337,13 +346,43 @@ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec
337
346
  // The glass over it: a broad soft sheen across the upper iris, under the
338
347
  // lid line, the dome of a bead catching the room.
339
348
  let dome = (1.0 - smoothstep(0.0, 0.5, distance(jj.src * vec2f(0.8, 1.4), vec2f(0.0, 0.2)))) * 0.45 * lit;
340
- // The glints keep off the iris's top; the soft light (sheen, glow through,
341
- // colour) has no halo to spill, so it only needs the eye's own picture.
342
- let gem = (vec3f(glints) * onEye + (vec3f(dome) + through + vivid) * solid) * open * jj.w;
349
+ // Everything that blooms keeps to the lower iris, so no halo reaches the lid.
350
+ let lower = 1.0 - smoothstep(-0.05, 0.3, jj.src.y);
351
+ // The cut: soft facets fanning across the lower iris, light split as a cut
352
+ // stone splits it, turning slowly.
353
+ let ang = atan2(jj.src.y, jj.src.x);
354
+ let facets = pow(0.5 + 0.5 * cos(ang * 7.0 + jj.clock * 0.35), 6.0)
355
+ * smoothstep(0.3, 0.75, r) * (1.0 - smoothstep(0.82, 0.97, r)) * lower;
356
+ // The rim: a band of its own colour just inside the iris edge, HDR, which
357
+ // bloom spreads into a glow around the stone.
358
+ let rim = smoothstep(0.7, 0.84, r) * (1.0 - smoothstep(0.86, 0.97, r)) * lower;
359
+ // Twinkles: three specks in the lower iris, each flashing now and then on
360
+ // the scene clock.
361
+ var twinkle = 0.0;
362
+ for (var i = 0; i < 3; i++) {
363
+ let fi = f32(i);
364
+ let at = vec2f(0.42 * cos(3.6 + fi * 1.1), -0.25 - 0.18 * fi + 0.1 * sin(fi * 2.3));
365
+ let flash = pow(max(0.0, sin(jj.clock * (1.3 + 0.37 * fi) + fi * 2.1)), 12.0);
366
+ twinkle += (1.0 - smoothstep(0.0, 0.05 + aa, distance(jj.src, at))) * flash;
367
+ }
368
+ let sparkle = (vec3f(glints + twinkle * 2.0) * onEye
369
+ + tex * facets * 2.5 * lit * solid * (1.0 + 0.6 * jj.shine));
370
+ // The soft light (sheen, glow through, colour) needs only the eye's picture.
371
+ let gem = (sparkle + (vec3f(dome) + through + vivid) * solid) * open * jj.w;
372
+ // The edge, which jj.w (fading out from 0.85) would hide: the glowing rim
373
+ // band, and a thin glossy line right at the border — light running round
374
+ // the stone's edge — all the way round, bright below and dim above, where a
375
+ // halo would reach the lid. Up to the mesh's edge and no further.
376
+ let on = select(0.0, 1.0, strength > 0.0) * (1.0 - smoothstep(1.0 - aa, 1.0, r));
377
+ let edgeLine = smoothstep(0.92, 0.955, r) * (1.0 - smoothstep(0.965, 0.995, r));
378
+ let edgeLit = mix(0.3, 1.0, lower) * lit * (1.0 + 0.6 * jj.shine);
379
+ let edge = (tex * rim * 2.0 + vec3f(rim * 0.25)) * lit * (1.0 + 0.6 * jj.shine)
380
+ + (vec3f(0.55) + tex * 0.8) * edgeLine * edgeLit;
381
+ let border = edge * solid * open * on;
343
382
  // The whole eye shines a little brighter, more while it wobbles — not just
344
383
  // the iris, so this one is not held to it (jj.w).
345
384
  let lift = tex * (0.25 + 0.35 * jj.shine) * select(0.0, 1.0, strength > 0.0);
346
- return (gem + lift) * min(strength, 1.0);
385
+ return (gem + border + lift) * min(strength, 1.0);
347
386
  }
348
387
 
349
388
  /** The light arriving at a surface of this object facing n: its own ambient
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.68.5",
3
+ "version": "0.68.6",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/jelly-eyes.ts CHANGED
@@ -22,7 +22,7 @@ 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 small bounce every few seconds while nothing else moves them.
25
+ /** The idle taps: a ripple every 4–8 s while nothing else moves them.
26
26
  * Default true; false leaves only what her blinks and motion set off. */
27
27
  idle?: boolean
28
28
  }
@@ -82,16 +82,21 @@ const PULSE_REF = 0.006
82
82
  const MOTION_SQUASH = 0.18
83
83
  const PULSE_HZ = 2.0
84
84
  const PULSE_ZETA = 0.08
85
- // The idle taps: every few seconds, at uneven times, a small knock into the
85
+ // The idle taps: every 4–8 s, at uneven times, a knock into the
86
86
  // same springs her motion drives — the iris dips, bounces and settles, then
87
87
  // rests — the way a jelly on a table is never quite still. A steady sine was
88
88
  // 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]
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]
95
100
  const idleGap = (n: number): number => {
96
101
  const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453
97
102
  return IDLE_GAP[0] + (x - Math.floor(x)) * (IDLE_GAP[1] - IDLE_GAP[0])
@@ -132,6 +137,18 @@ interface EyeRest {
132
137
  // Iris centre to the eye bone's head: what a turn moves the iris by, per
133
138
  // radian.
134
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]
135
152
  }
136
153
 
137
154
  const newState = (): EyeState => ({
@@ -154,12 +171,20 @@ export class JellyEyes {
154
171
  private clock = 0
155
172
  private taps = 0
156
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
157
176
  private readonly rest: [EyeRest, EyeRest]
158
177
  private readonly state: [EyeState, EyeState] = [newState(), newState()]
159
178
  private readonly morphs: [number[], number[], number[]]
160
179
  private readonly eyeBones: [number, number]
161
180
  private readonly headBone: number
162
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]]
163
188
  private readonly inertia: Inertia = { p: [0, 0, 0], v: [0, 0, 0], head: [0, 0, 0], offset: [0, 0] }
164
189
 
165
190
  constructor(model: Model, options: JellyEyeOptions) {
@@ -170,26 +195,44 @@ export class JellyEyes {
170
195
  const both = index("両目")
171
196
  const l = index("左目")
172
197
  const r = index("右目")
173
- this.eyeBones = [l >= 0 ? l : both, r >= 0 ? r : both]
174
198
  this.headBone = index("頭")
175
199
  const names = model.getMorphing().morphs.map((m) => m.name)
176
200
  const find = (list: string[]) => list.map((n) => names.indexOf(n)).filter((i) => i >= 0)
177
201
  this.morphs = [find(BOTH), find(LEFT), find(RIGHT)]
178
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]]
179
207
  }
180
208
 
181
209
  /** Advance both springs by dt seconds of scene time. */
182
210
  step(dt: number, model: Model): void {
183
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
+ }
184
226
  const sum = (list: number[]) => list.reduce((s, i) => s + (weights[i] ?? 0), 0)
185
227
  const both = sum(this.morphs[0])
186
228
  const closure = [Math.min(1, both + sum(this.morphs[1])), Math.min(1, both + sum(this.morphs[2]))]
187
229
  const head = this.headBone >= 0 ? model.getBoneWorldMatrixAt(this.headBone) : null
188
230
  const seek = this.fresh || dt > SEEK || dt < 0
189
231
  if (dt > 0) this.clock += dt
190
- let tap = false
191
- if (!seek && this.idle && this.clock >= this.nextTap) {
192
- tap = true
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
193
236
  this.taps++
194
237
  this.nextTap = this.clock + idleGap(this.taps)
195
238
  }
@@ -206,7 +249,7 @@ export class JellyEyes {
206
249
  else s.settled += (s.closure - s.settled) * Math.min(1, dt / CLOSURE_FOLLOW_S)
207
250
  // Either way a lid moves squashes — opening too, which is the half of a
208
251
  // 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)
252
+ const knock = seek ? 0 : Math.max(this.tapKnock, Math.tanh(Math.hypot(...this.offset(s, e)) / PULSE_REF))
210
253
  const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled) + MOTION_SQUASH * knock
211
254
  if (seek) {
212
255
  s.squash = squashTarget
@@ -218,10 +261,6 @@ export class JellyEyes {
218
261
  s.pulse = s.pulseV = 0
219
262
  continue
220
263
  }
221
- if (tap) {
222
- s.squashV += IDLE_SQUASH_KICK
223
- s.pulseV -= IDLE_PULSE_KICK
224
- }
225
264
  const steps = Math.ceil(dt / SUBSTEP)
226
265
  const h = steps > 0 ? dt / steps : 0
227
266
  const pulseTarget = PULSE_MAX * knock
@@ -245,17 +284,18 @@ export class JellyEyes {
245
284
 
246
285
  /** Write jelly[0..5] into an ObjectLight copy (floats 48..71). */
247
286
  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)
287
+ const [L, R] = this.centre
288
+ o.set([L[0], L[1], L[2], this.strength], 48)
250
289
  // One scale bounce for both: the mean of the two eyes', so they swell together.
251
290
  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)
291
+ o.set([R[0], R[1], R[2], pulse], 52)
253
292
  for (let e = 0; e < 2; e++) {
254
293
  const s = this.state[e]
255
294
  const rest = this.rest[e]
256
295
  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)
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)
259
299
  }
260
300
  }
261
301
 
@@ -355,6 +395,64 @@ function look(eye: Float32Array | null, head: Float32Array | null, out: [number,
355
395
 
356
396
  const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
357
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
+
358
456
  /**
359
457
  * Each iris in bind space: the vertices the eye bones carry are exactly the
360
458
  * part of the eye that turns to look — the iris and pupil — so their centroid
@@ -380,29 +478,71 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
380
478
  const leftSign = lHead && rHead ? Math.sign(lHead[0] - rHead[0]) || 1 : 1
381
479
  const { positions } = model.getGeometry()
382
480
  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
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
395
489
  }
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)
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
399
539
  }
400
540
  const apart = lHead && rHead ? Math.hypot(lHead[0] - rHead[0], lHead[1] - rHead[1]) : 0
401
541
  const fallbackRadius = apart > 0 ? apart * 0.22 : 0.3
402
- 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 => {
403
543
  if (set.length < 3) {
404
544
  const c: [number, number, number] = bone ?? [side * fallbackRadius * 2, 0, 0]
405
- return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 }
545
+ return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1 }
406
546
  }
407
547
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, z = 0
408
548
  for (const v of set) {
@@ -420,7 +560,7 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
420
560
  const rx = (maxX - minX) / 2
421
561
  const ry = (maxY - minY) / 2
422
562
  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 }
563
+ return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2, verts: [], bone: -1 }
424
564
  }
425
565
  const centre: [number, number, number] = [(minX + maxX) / 2, (minY + maxY) / 2, z / set.length]
426
566
  const radius: [number, number] = [Math.max(rx, 1e-3), Math.max(ry, 1e-3)]
@@ -428,7 +568,7 @@ function findIrises(model: Model, l: number, r: number, both: number): [EyeRest,
428
568
  // still turns its eyes visibly, so read it as a typical eyeball's.
429
569
  const r = (radius[0] + radius[1]) / 2
430
570
  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 }
571
+ return { centre, radius, lever, verts: set, bone: carrierBone }
432
572
  }
433
- 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))]
434
574
  }
@@ -222,7 +222,7 @@ fn _rzCastShadow(wp: vec3f) -> f32 {
222
222
  // [1] right iris centre, scale bounce (both eyes)
223
223
  // [2] left iris half extents, closure, eye bone (−1 none)
224
224
  // [3] the right's
225
- // [4] left squash, 0, 0, shine [5] the right's
225
+ // [4] left squash, 0, clock, shine [5] the right's
226
226
  struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9>, fill: vec4f, jelly: array<vec4f, 6> }
227
227
  @group(1) @binding(1) var<uniform> objectLight: ObjectLight;
228
228
 
@@ -234,6 +234,8 @@ struct RzJelly {
234
234
  w: f32,
235
235
  closure: f32,
236
236
  shine: f32,
237
+ /** Scene seconds (wrapped), for the twinkles. */
238
+ clock: f32,
237
239
  };
238
240
 
239
241
  /**
@@ -283,7 +285,7 @@ fn rzJellyEye(rp: vec3f) -> RzJelly {
283
285
  let a = select(qL, qR, right);
284
286
  let st = select(j[4], j[5], right);
285
287
  let w = (1.0 - smoothstep(0.85, 1.0, length(a))) * select(0.0, 1.0, j[0].w > 0.0);
286
- return RzJelly(a, w, select(j[2].z, j[3].z, right), st.w);
288
+ return RzJelly(a, w, select(j[2].z, j[3].z, right), st.w, j[4].z);
287
289
  }
288
290
 
289
291
  /**
@@ -311,7 +313,12 @@ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec
311
313
  g = g * min(1.0, 0.25 / max(length(g), 1e-6)) - vec2f(0.0, 0.1);
312
314
  g.y = min(g.y, 0.0);
313
315
  let aa = max(length(fwidth(jj.src)), 1e-4);
314
- let glint = 1.0 - smoothstep(0.16 - aa, 0.16 + aa, distance(jj.src, g));
316
+ // A star, not only a disc: the disc with two thin rays through it, the
317
+ // anime sparkle.
318
+ let dg = jj.src - g;
319
+ let rays = max(0.0, 1.0 - abs(dg.y) / 0.018) * (1.0 - smoothstep(0.0, 0.34, abs(dg.x)))
320
+ + max(0.0, 1.0 - abs(dg.x) / 0.018) * (1.0 - smoothstep(0.0, 0.26, abs(dg.y)));
321
+ let glint = max(1.0 - smoothstep(0.13 - aa, 0.13 + aa, length(dg)), rays * 0.8);
315
322
  let second = 1.0 - smoothstep(0.07 - aa, 0.07 + aa, distance(jj.src, -0.55 * g + vec2f(0.1, -0.12)));
316
323
  let facing = saturate(dot(fwd, v) * 2.0);
317
324
  let key = clamp(dot(sun, vec3f(0.2126, 0.7152, 0.0722)), 0.3, 2.0);
@@ -331,7 +338,9 @@ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec
331
338
  // the far side, so the iris glows in its own colour in a crescent opposite
332
339
  // the glint — the lit-from-within look of a gem, or a jelly.
333
340
  let r = length(jj.src);
334
- let away = -normalize(g + vec2f(0.0, -1e-3));
341
+ // Out the LOWER iris, always — the anime gem's bright underside — leaning
342
+ // away from the glint; never up, where its bloom would spill on the lid.
343
+ let away = normalize(vec2f(-g.x * 0.6, -1.0));
335
344
  let crescent = smoothstep(0.15, 0.7, dot(jj.src, away)) * (1.0 - smoothstep(0.8, 1.0, r));
336
345
  let through = tex * crescent * (2.2 + 1.0 * jj.shine) * lit;
337
346
  // And the colour runs deeper: the iris's own hue pushed, never its greys.
@@ -340,13 +349,43 @@ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec
340
349
  // The glass over it: a broad soft sheen across the upper iris, under the
341
350
  // lid line, the dome of a bead catching the room.
342
351
  let dome = (1.0 - smoothstep(0.0, 0.5, distance(jj.src * vec2f(0.8, 1.4), vec2f(0.0, 0.2)))) * 0.45 * lit;
343
- // The glints keep off the iris's top; the soft light (sheen, glow through,
344
- // colour) has no halo to spill, so it only needs the eye's own picture.
345
- let gem = (vec3f(glints) * onEye + (vec3f(dome) + through + vivid) * solid) * open * jj.w;
352
+ // Everything that blooms keeps to the lower iris, so no halo reaches the lid.
353
+ let lower = 1.0 - smoothstep(-0.05, 0.3, jj.src.y);
354
+ // The cut: soft facets fanning across the lower iris, light split as a cut
355
+ // stone splits it, turning slowly.
356
+ let ang = atan2(jj.src.y, jj.src.x);
357
+ let facets = pow(0.5 + 0.5 * cos(ang * 7.0 + jj.clock * 0.35), 6.0)
358
+ * smoothstep(0.3, 0.75, r) * (1.0 - smoothstep(0.82, 0.97, r)) * lower;
359
+ // The rim: a band of its own colour just inside the iris edge, HDR, which
360
+ // bloom spreads into a glow around the stone.
361
+ let rim = smoothstep(0.7, 0.84, r) * (1.0 - smoothstep(0.86, 0.97, r)) * lower;
362
+ // Twinkles: three specks in the lower iris, each flashing now and then on
363
+ // the scene clock.
364
+ var twinkle = 0.0;
365
+ for (var i = 0; i < 3; i++) {
366
+ let fi = f32(i);
367
+ let at = vec2f(0.42 * cos(3.6 + fi * 1.1), -0.25 - 0.18 * fi + 0.1 * sin(fi * 2.3));
368
+ let flash = pow(max(0.0, sin(jj.clock * (1.3 + 0.37 * fi) + fi * 2.1)), 12.0);
369
+ twinkle += (1.0 - smoothstep(0.0, 0.05 + aa, distance(jj.src, at))) * flash;
370
+ }
371
+ let sparkle = (vec3f(glints + twinkle * 2.0) * onEye
372
+ + tex * facets * 2.5 * lit * solid * (1.0 + 0.6 * jj.shine));
373
+ // The soft light (sheen, glow through, colour) needs only the eye's picture.
374
+ let gem = (sparkle + (vec3f(dome) + through + vivid) * solid) * open * jj.w;
375
+ // The edge, which jj.w (fading out from 0.85) would hide: the glowing rim
376
+ // band, and a thin glossy line right at the border — light running round
377
+ // the stone's edge — all the way round, bright below and dim above, where a
378
+ // halo would reach the lid. Up to the mesh's edge and no further.
379
+ let on = select(0.0, 1.0, strength > 0.0) * (1.0 - smoothstep(1.0 - aa, 1.0, r));
380
+ let edgeLine = smoothstep(0.92, 0.955, r) * (1.0 - smoothstep(0.965, 0.995, r));
381
+ let edgeLit = mix(0.3, 1.0, lower) * lit * (1.0 + 0.6 * jj.shine);
382
+ let edge = (tex * rim * 2.0 + vec3f(rim * 0.25)) * lit * (1.0 + 0.6 * jj.shine)
383
+ + (vec3f(0.55) + tex * 0.8) * edgeLine * edgeLit;
384
+ let border = edge * solid * open * on;
346
385
  // The whole eye shines a little brighter, more while it wobbles — not just
347
386
  // the iris, so this one is not held to it (jj.w).
348
387
  let lift = tex * (0.25 + 0.35 * jj.shine) * select(0.0, 1.0, strength > 0.0);
349
- return (gem + lift) * min(strength, 1.0);
388
+ return (gem + border + lift) * min(strength, 1.0);
350
389
  }
351
390
 
352
391
  /** The light arriving at a surface of this object facing n: its own ambient