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.
- package/dist/jelly-eyes.d.ts +4 -1
- package/dist/jelly-eyes.d.ts.map +1 -1
- package/dist/jelly-eyes.js +185 -47
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +47 -8
- package/package.json +1 -1
- package/src/jelly-eyes.ts +182 -42
- package/src/shaders/materials/common.ts +47 -8
package/dist/jelly-eyes.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/jelly-eyes.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/jelly-eyes.js
CHANGED
|
@@ -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
|
|
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.
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
195
|
-
o.set([L
|
|
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
|
|
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
|
-
//
|
|
204
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
let
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
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
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
|
|
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
|
|
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.
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
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
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
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.
|
|
249
|
-
o.set([L
|
|
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
|
|
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
|
-
//
|
|
258
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
let
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
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
|