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