reze-engine 0.68.2 → 0.68.4

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.
@@ -0,0 +1,340 @@
1
+ // Jelly eyes — the MMD 果冻眼 / Live2D highlight wobble, for any PMX.
2
+ //
3
+ // What every working version of the effect has in common, whether it is keyed
4
+ // by hand in MMD, a pendulum in Live2D, or a jiggle bone in Blender: an INPUT
5
+ // (a blink, a turn), something that REMEMBERS the last frame (a spring), and a
6
+ // cheap OUTPUT. Here the output is the iris's SCALE, in place: it flattens and
7
+ // bulges as a lid moves (opening too), swells as her head turns or moves, and
8
+ // wobbles back. It never slides — tried, it read as the iris coming loose. The
9
+ // inputs are her blinks and her head, never her eyes' own turn: eye tracking
10
+ // turns the eyes after the camera, and the camera must not shake the jelly.
11
+ //
12
+ // The springs run on the CPU, stepped by the engine's frame delta — the same
13
+ // exact delta renderFrame() hands an export, so a blink bounces identically on
14
+ // screen and in the file. Their state rides in the model's ObjectLight
15
+ // (jelly[0..5], see materials/common.ts): the vertex shader scales the iris —
16
+ // the vertices the eye bones carry — about its centre, and the eye render class
17
+ // adds the gem look after its graph (glints, light through the iris, deeper
18
+ // colour), so every eye graph becomes jelly without knowing it.
19
+ // The closing morphs, by the names MMD, VRoid and the usual converters give
20
+ // them. Group morphs (a smile that closes the eyes) resolve into these through
21
+ // the effective weights, so only the leaves need naming.
22
+ const BOTH = ["まばたき", "笑い", "はぅ", "blink", "Blink", "eye_close", "Fcl_EYE_Close"];
23
+ const LEFT = ["ウィンク", "ウィンク2", "ウィンク", "ウィンク2", "wink", "Wink", "blink_L", "Fcl_EYE_Close_L"];
24
+ const RIGHT = ["ウィンク右", "ウィンク2右", "ウィンク右", "ウィンク2右", "wink_R", "Wink_R", "blink_R", "Fcl_EYE_Close_R"];
25
+ // The squash: how far a closed eye presses the picture, and the spring that
26
+ // follows it — underdamped, so the eye opening overshoots into a stretch and
27
+ // settles in two or three wobbles, the "Duang" the effect is named for.
28
+ // Only a CHANGE of closure squashes: the eye's closure less a slow follower of
29
+ // it, so a blink squeezes then bounces, while a held half-close (a smile)
30
+ // leaves the iris at rest instead of squashed for as long as she smiles.
31
+ const SQUASH_CLOSED = 0.35;
32
+ const SQUASH_HZ = 1.4;
33
+ const SQUASH_ZETA = 0.22;
34
+ const CLOSURE_FOLLOW_S = 0.35;
35
+ // The lag: the picture trails the eye's turn and catches up past it. Radians of
36
+ // lag to eye radii of offset; the head's own turn counts for a third, so a
37
+ // sudden stop of the head wobbles the eyes without a dance keeping them
38
+ // sloshing.
39
+ const LAG_HZ = 3.5;
40
+ const LAG_ZETA = 0.4;
41
+ // The lag is an angle: how far the iris trails the eye's own turn, as a share
42
+ // of the lag in look, capped at a quarter of eye tracking's default 20° reach —
43
+ // a wobble on the turn, never a second turn. It becomes iris radii through the
44
+ // eye's own lever (iris centre to eye bone), the same displacement a real turn
45
+ // of that angle gives, so it scales with whatever the rig's eyes do.
46
+ const LAG_GAIN = 0.5;
47
+ const LAG_MAX = (3 * Math.PI) / 180;
48
+ // And never past this many iris radii, whatever the lever, at strength 1.
49
+ const OFFSET_MAX = 0.06;
50
+ const HEAD_SHARE = 0.35;
51
+ // The inertia: the iris has a little mass, hung on a spring from the head, so
52
+ // her own motion — a bob, a step, a sway of the dance — leaves it behind and
53
+ // it swings back. Model units of lag to iris radii, scaled by INERTIA_GAIN.
54
+ const INERTIA_HZ = 3.0;
55
+ const INERTIA_ZETA = 0.4;
56
+ const INERTIA_GAIN = 0.04;
57
+ // The jelly's give: the iris never moves, it swells. The lag and the inertia
58
+ // are how hard it is being knocked (in iris radii, as if it could move); that
59
+ // knock grows it a little and it settles in a wobble — the scale bounce on top
60
+ // of the blink's squash. Iris scale per iris radius of knock.
61
+ const PULSE_GAIN = 1.6;
62
+ const PULSE_HZ = 1.6;
63
+ const PULSE_ZETA = 0.24;
64
+ // Integration step, and the jump past which a delta is a seek, not time.
65
+ const SUBSTEP = 1 / 240;
66
+ const SEEK = 0.25;
67
+ const newState = () => ({
68
+ closure: 0,
69
+ settled: 0,
70
+ squash: 0,
71
+ squashV: 0,
72
+ lag: [0, 0],
73
+ lagV: [0, 0],
74
+ look: [0, 0],
75
+ shine: 0,
76
+ pulse: 0,
77
+ pulseV: 0,
78
+ });
79
+ export class JellyEyes {
80
+ constructor(model, options) {
81
+ this.state = [newState(), newState()];
82
+ this.fresh = true;
83
+ this.inertia = { p: [0, 0, 0], v: [0, 0, 0], head: [0, 0, 0], offset: [0, 0] };
84
+ this.strength = clampStrength(options.strength);
85
+ const bones = model.getSkeleton().bones;
86
+ const index = (name) => bones.findIndex((b) => b.name === name);
87
+ const both = index("両目");
88
+ const l = index("左目");
89
+ const r = index("右目");
90
+ this.eyeBones = [l >= 0 ? l : both, r >= 0 ? r : both];
91
+ this.headBone = index("頭");
92
+ const names = model.getMorphing().morphs.map((m) => m.name);
93
+ const find = (list) => list.map((n) => names.indexOf(n)).filter((i) => i >= 0);
94
+ this.morphs = [find(BOTH), find(LEFT), find(RIGHT)];
95
+ this.rest = findIrises(model, l, r, both);
96
+ }
97
+ /** Advance both springs by dt seconds of scene time. */
98
+ step(dt, model) {
99
+ const weights = model.getEffectiveMorphWeights();
100
+ const sum = (list) => list.reduce((s, i) => s + (weights[i] ?? 0), 0);
101
+ const both = sum(this.morphs[0]);
102
+ const closure = [Math.min(1, both + sum(this.morphs[1])), Math.min(1, both + sum(this.morphs[2]))];
103
+ const head = this.headBone >= 0 ? model.getBoneWorldMatrixAt(this.headBone) : null;
104
+ const seek = this.fresh || dt > SEEK || dt < 0;
105
+ this.stepInertia(dt, head, seek);
106
+ for (let e = 0; e < 2; e++) {
107
+ const s = this.state[e];
108
+ s.closure = closure[e];
109
+ // Her HEAD's turn, not her eyes': eye tracking turns the eyes after the
110
+ // camera, so the eyes' own turn would make the camera shake the jelly.
111
+ // Only her motion — the head turning and moving (the inertia) — and her
112
+ // blinks do.
113
+ look(head, null, s.look);
114
+ if (seek)
115
+ s.settled = s.closure;
116
+ else
117
+ s.settled += (s.closure - s.settled) * Math.min(1, dt / CLOSURE_FOLLOW_S);
118
+ // Either way a lid moves squashes — opening too, which is the half of a
119
+ // blink you can see (closing, the lid hides the iris anyway).
120
+ const squashTarget = SQUASH_CLOSED * Math.abs(s.closure - s.settled);
121
+ if (seek) {
122
+ s.squash = squashTarget;
123
+ s.squashV = 0;
124
+ s.lag[0] = s.look[0];
125
+ s.lag[1] = s.look[1];
126
+ s.lagV[0] = s.lagV[1] = 0;
127
+ s.shine = 0;
128
+ s.pulse = s.pulseV = 0;
129
+ continue;
130
+ }
131
+ const steps = Math.ceil(dt / SUBSTEP);
132
+ const h = steps > 0 ? dt / steps : 0;
133
+ const pulseTarget = PULSE_GAIN * Math.hypot(...this.offset(s, e));
134
+ for (let i = 0; i < steps; i++) {
135
+ s.pulseV += h * spring(s.pulse - pulseTarget, s.pulseV, PULSE_HZ, PULSE_ZETA);
136
+ s.pulse += h * s.pulseV;
137
+ s.squashV += h * spring(s.squash - squashTarget, s.squashV, SQUASH_HZ, SQUASH_ZETA);
138
+ s.squash += h * s.squashV;
139
+ for (let k = 0; k < 2; k++) {
140
+ s.lagV[k] += h * spring(s.lag[k] - s.look[k], s.lagV[k], LAG_HZ, LAG_ZETA);
141
+ s.lag[k] += h * s.lagV[k];
142
+ }
143
+ }
144
+ // Shine follows the motion: bright while the jelly moves, fading as it
145
+ // settles, eased so a single wobble does not strobe.
146
+ const motion = Math.min(1, 2.5 * Math.abs(s.squash - squashTarget) + 4 * Math.hypot(...this.offset(s, e)));
147
+ s.shine += (motion - s.shine) * Math.min(1, dt * 12);
148
+ }
149
+ this.fresh = false;
150
+ }
151
+ /** Write jelly[0..5] into an ObjectLight copy (floats 48..71). */
152
+ write(o) {
153
+ const [L, R] = this.rest;
154
+ o.set([L.centre[0], L.centre[1], L.centre[2], this.strength], 48);
155
+ // One scale bounce for both: the mean of the two eyes', so they swell together.
156
+ const pulse = ((this.state[0].pulse + this.state[1].pulse) / 2) * this.strength;
157
+ o.set([R.centre[0], R.centre[1], R.centre[2], pulse], 52);
158
+ for (let e = 0; e < 2; e++) {
159
+ const s = this.state[e];
160
+ const rest = this.rest[e];
161
+ o.set([rest.radius[0], rest.radius[1], s.closure, this.eyeBones[e]], 56 + e * 4);
162
+ // Offset words (65, 66) stay 0: the iris holds its place; only scale.
163
+ o.set([s.squash * this.strength, 0, 0, s.shine], 64 + e * 4);
164
+ }
165
+ }
166
+ /** Where the iris sits off rest, in iris radii (right, up): the lag through
167
+ * the eye's lever, plus the inertia. */
168
+ offset(s, e) {
169
+ const angle = (x) => Math.max(-LAG_MAX, Math.min(LAG_MAX, x * LAG_GAIN));
170
+ const rest = this.rest[e];
171
+ const r = (rest.radius[0] + rest.radius[1]) / 2;
172
+ const per = rest.lever / r;
173
+ const io = this.inertia.offset;
174
+ const x = angle(s.lag[0] - s.look[0]) * per + io[0];
175
+ const y = angle(s.lag[1] - s.look[1]) * per + io[1];
176
+ const n = Math.hypot(x, y);
177
+ const cap = n > OFFSET_MAX ? OFFSET_MAX / n : 1;
178
+ return [x * cap, y * cap];
179
+ }
180
+ /** The mass on the head: semi-implicit steps toward where the head is, the
181
+ * head moving linearly across the frame. */
182
+ stepInertia(dt, head, seek) {
183
+ const m = this.inertia;
184
+ if (!head) {
185
+ m.offset[0] = m.offset[1] = 0;
186
+ return;
187
+ }
188
+ const now = [head[12], head[13], head[14]];
189
+ if (seek) {
190
+ m.p = [...now];
191
+ m.v = [0, 0, 0];
192
+ m.head = [...now];
193
+ m.offset[0] = m.offset[1] = 0;
194
+ return;
195
+ }
196
+ const hv = [0, 1, 2].map((k) => (dt > 0 ? (now[k] - m.head[k]) / dt : 0));
197
+ const steps = Math.ceil(dt / SUBSTEP);
198
+ const h = steps > 0 ? dt / steps : 0;
199
+ for (let i = 1; i <= steps; i++) {
200
+ const t = i / steps;
201
+ for (let k = 0; k < 3; k++) {
202
+ const at = m.head[k] + (now[k] - m.head[k]) * t;
203
+ m.v[k] += h * spring(m.p[k] - at, m.v[k] - hv[k], INERTIA_HZ, INERTIA_ZETA);
204
+ m.p[k] += h * m.v[k];
205
+ }
206
+ }
207
+ m.head = now;
208
+ const u = [m.p[0] - now[0], m.p[1] - now[1], m.p[2] - now[2]];
209
+ const right = [head[0], head[1], head[2]];
210
+ const up = [head[4], head[5], head[6]];
211
+ const size = Math.max(1e-3, (this.rest[0].radius[0] + this.rest[0].radius[1] + this.rest[1].radius[0] + this.rest[1].radius[1]) / 4);
212
+ const cap = (x) => Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, x));
213
+ m.offset[0] = cap((dot(u, right) / Math.hypot(...right) / size) * INERTIA_GAIN);
214
+ m.offset[1] = cap((dot(u, up) / Math.hypot(...up) / size) * INERTIA_GAIN);
215
+ }
216
+ }
217
+ /** Zero jelly[0..5] — strength 0 is what the shader reads as off. */
218
+ export function clearJellyEyes(o) {
219
+ o.fill(0, 48, 72);
220
+ }
221
+ export function clampStrength(s) {
222
+ return Math.max(0, Math.min(2, s ?? 1));
223
+ }
224
+ /** Acceleration of a damped spring at displacement x, velocity v. */
225
+ function spring(x, v, hz, zeta) {
226
+ const w = 2 * Math.PI * hz;
227
+ return -w * w * x - 2 * zeta * w * v;
228
+ }
229
+ /**
230
+ * Where an eye looks, as (yaw, pitch) radians: the eye in the head's frame,
231
+ * plus a share of the head in the model's. Bone matrices are column-major and
232
+ * MMD rests facing −Z, so forward is the negated third column.
233
+ */
234
+ function look(eye, head, out) {
235
+ out[0] = out[1] = 0;
236
+ if (!eye)
237
+ return;
238
+ const f = [-eye[8], -eye[9], -eye[10]];
239
+ if (head && head !== eye) {
240
+ const right = [head[0], head[1], head[2]];
241
+ const up = [head[4], head[5], head[6]];
242
+ const hf = [-head[8], -head[9], -head[10]];
243
+ const fz = dot(f, hf);
244
+ out[0] = Math.atan2(dot(f, right), fz);
245
+ out[1] = Math.atan2(dot(f, up), fz);
246
+ out[0] += HEAD_SHARE * Math.atan2(hf[0], -hf[2]);
247
+ out[1] += HEAD_SHARE * Math.asin(Math.max(-1, Math.min(1, hf[1] / Math.hypot(hf[0], hf[1], hf[2]))));
248
+ }
249
+ else {
250
+ // Yaw positive toward +X, as in the head-frame branch: the shader warps
251
+ // along bind-space +X, so the two must agree.
252
+ out[0] = Math.atan2(f[0], -f[2]);
253
+ out[1] = Math.asin(Math.max(-1, Math.min(1, f[1] / Math.hypot(f[0], f[1], f[2]))));
254
+ }
255
+ }
256
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
257
+ /**
258
+ * Each iris in bind space: the vertices the eye bones carry are exactly the
259
+ * part of the eye that turns to look — the iris and pupil — so their centroid
260
+ * is its centre and their spread its size. A rig that carries both eyes on
261
+ * 両目 alone is split by side. With no carried vertices at all, the eye bones'
262
+ * own heads stand in, sized from how far apart they are.
263
+ */
264
+ function findIrises(model, l, r, both) {
265
+ const bones = model.getSkeleton().bones;
266
+ const head = (i) => {
267
+ if (i < 0)
268
+ return null;
269
+ const p = [0, 0, 0];
270
+ for (let b = i; b >= 0; b = bones[b].parentIndex) {
271
+ p[0] += bones[b].bindTranslation[0];
272
+ p[1] += bones[b].bindTranslation[1];
273
+ p[2] += bones[b].bindTranslation[2];
274
+ }
275
+ return p;
276
+ };
277
+ const lHead = head(l);
278
+ const rHead = head(r);
279
+ // +X is the model's left; trust the bones when they say otherwise.
280
+ const leftSign = lHead && rHead ? Math.sign(lHead[0] - rHead[0]) || 1 : 1;
281
+ const { positions } = model.getGeometry();
282
+ const { joints, weights } = model.getSkinning();
283
+ const sets = [[], []];
284
+ const n = positions.length / 3;
285
+ for (let v = 0; v < n; v++) {
286
+ let wl = 0;
287
+ let wr = 0;
288
+ let wb = 0;
289
+ for (let k = 0; k < 4; k++) {
290
+ const j = joints[v * 4 + k];
291
+ const w = weights[v * 4 + k];
292
+ if (j === l)
293
+ wl += w;
294
+ else if (j === r)
295
+ wr += w;
296
+ else if (j === both)
297
+ wb += w;
298
+ }
299
+ if (wl >= 128)
300
+ sets[0].push(v);
301
+ else if (wr >= 128)
302
+ sets[1].push(v);
303
+ else if (wb >= 128)
304
+ sets[Math.sign(positions[v * 3]) === leftSign ? 0 : 1].push(v);
305
+ }
306
+ const apart = lHead && rHead ? Math.hypot(lHead[0] - rHead[0], lHead[1] - rHead[1]) : 0;
307
+ const fallbackRadius = apart > 0 ? apart * 0.22 : 0.3;
308
+ const rest = (set, bone, side) => {
309
+ if (set.length < 3) {
310
+ const c = bone ?? [side * fallbackRadius * 2, 0, 0];
311
+ return { centre: c, radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 };
312
+ }
313
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, z = 0;
314
+ for (const v of set) {
315
+ const x = positions[v * 3];
316
+ const y = positions[v * 3 + 1];
317
+ minX = Math.min(minX, x);
318
+ maxX = Math.max(maxX, x);
319
+ minY = Math.min(minY, y);
320
+ maxY = Math.max(maxY, y);
321
+ z += positions[v * 3 + 2];
322
+ }
323
+ // A carried set wider than the gap between the eyes is not an iris (a
324
+ // whole-face rig weighted to the eye bone): fall back rather than wobble
325
+ // half the face.
326
+ const rx = (maxX - minX) / 2;
327
+ const ry = (maxY - minY) / 2;
328
+ if (apart > 0 && (rx > apart * 0.6 || ry > apart * 0.8)) {
329
+ return { centre: bone ?? [side * fallbackRadius * 2, 0, 0], radius: [fallbackRadius, fallbackRadius], lever: fallbackRadius * 2 };
330
+ }
331
+ const centre = [(minX + maxX) / 2, (minY + maxY) / 2, z / set.length];
332
+ const radius = [Math.max(rx, 1e-3), Math.max(ry, 1e-3)];
333
+ // An eye bone sitting on the iris itself would give no lever; such a rig
334
+ // still turns its eyes visibly, so read it as a typical eyeball's.
335
+ const r = (radius[0] + radius[1]) / 2;
336
+ const lever = Math.max(bone ? Math.hypot(centre[0] - bone[0], centre[1] - bone[1], centre[2] - bone[2]) : 0, r * 1.5);
337
+ return { centre, radius, lever };
338
+ };
339
+ return [rest(sets[0], lHead, leftSign), rest(sets[1], rHead, -leftSign)];
340
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/shaders/materials/common.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,oBAAoB,QAgPhC,CAAC;AAuHF,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,QAsWhC,CAAC;AAwHF,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,aAAa,orMAgHzB,CAAA;AAQD,eAAO,MAAM,4BAA4B,QAC6H,CAAA"}
@@ -213,9 +213,127 @@ fn _rzCastShadow(wp: vec3f) -> f32 {
213
213
  // sh that ambient's irradiance, folded as the world's (ibl.ts);
214
214
  // fill.rgb its fill (Engine.setModelFill), added after its graph times its
215
215
  // surface colour — zero for a model that has none.
216
- struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9>, fill: vec4f }
216
+ // jelly its jelly eyes (Engine.setModelJellyEyes, jelly-eyes.ts), not
217
+ // lighting but per-model all the same, so it rides here:
218
+ // [0] left iris centre (bind space), strength — 0 is off
219
+ // [1] right iris centre, scale bounce (both eyes)
220
+ // [2] left iris half extents, closure, eye bone (−1 none)
221
+ // [3] the right's
222
+ // [4] left squash, 0, 0, shine [5] the right's
223
+ struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9>, fill: vec4f, jelly: array<vec4f, 6> }
217
224
  @group(1) @binding(1) var<uniform> objectLight: ObjectLight;
218
225
 
226
+ /** What the glints need to know about a fragment of the eye render class. */
227
+ struct RzJelly {
228
+ /** Where in the iris this fragment is, in iris radii. */
229
+ src: vec2f,
230
+ /** 1 inside the iris, easing to 0 at its rim (0 everywhere when off). */
231
+ w: f32,
232
+ closure: f32,
233
+ shine: f32,
234
+ };
235
+
236
+ /**
237
+ * Where the jelly puts an iris vertex. The iris is the part of the eye the eye
238
+ * bones carry, so it is moved as a whole, in bind space before skinning:
239
+ * flattened about its centre while the eye closes, bulging across as jelly does,
240
+ * grown past rest as it springs back, and swelling as a turn or her motion
241
+ * knocks it — scale only, in place, inside the white, which stays where it is. Vertices take it by
242
+ * their weight on the eye bones (jelly[2].w, jelly[3].w), so nothing else on
243
+ * the face moves.
244
+ */
245
+ fn rzJellyVertex(p: vec3f, joints: vec4<u32>, nw: vec4f) -> vec3f {
246
+ let j = objectLight.jelly;
247
+ var out = p;
248
+ for (var e = 0u; e < 2u; e++) {
249
+ let bone = j[2u + e].w;
250
+ if (bone < 0.0) { continue; }
251
+ let b = u32(bone);
252
+ let w = dot(nw, select(vec4f(0.0), vec4f(1.0), joints == vec4<u32>(b)));
253
+ if (w <= 0.0) { continue; }
254
+ let c = j[e].xyz;
255
+ // A rig that carries both eyes on one bone: each vertex follows its own side.
256
+ if (j[2].w == j[3].w && e == 0u && distance(p.xy, j[1].xy) < distance(p.xy, j[0].xy)) { continue; }
257
+ if (j[2].w == j[3].w && e == 1u && distance(p.xy, j[0].xy) <= distance(p.xy, j[1].xy)) { continue; }
258
+ let st = j[4u + e];
259
+ // The blink's squash, as jelly gives: flattened down, it bulges across
260
+ // (and a stretch thins it), and the bounce both eyes share (jelly[1].w).
261
+ // Height is held under 1.06 so a swell cannot rise past the upper lid.
262
+ let k = clamp(vec2f(1.0 + 0.45 * st.x, 1.0 - st.x) + j[1].w, vec2f(0.9, 0.78), vec2f(1.1, 1.06));
263
+ let moved = c.xy + (p.xy - c.xy) * k;
264
+ out = vec3f(mix(p.xy, moved, w), p.z);
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /**
270
+ * The glints' frame for an eye fragment: its place in the iris in iris radii,
271
+ * from the bind-space position, which the jelly moved the iris with — so the
272
+ * glints ride the sloshing iris. Strength 0 gives w 0, which turns them off.
273
+ */
274
+ fn rzJellyEye(rp: vec3f) -> RzJelly {
275
+ let j = objectLight.jelly;
276
+ let qL = (rp.xy - j[0].xy) / max(j[2].xy, vec2f(1e-4));
277
+ let qR = (rp.xy - j[1].xy) / max(j[3].xy, vec2f(1e-4));
278
+ let right = dot(qR, qR) < dot(qL, qL);
279
+ let a = select(qL, qR, right);
280
+ let st = select(j[4], j[5], right);
281
+ let w = (1.0 - smoothstep(0.85, 1.0, length(a))) * select(0.0, 1.0, j[0].w > 0.0);
282
+ return RzJelly(a, w, select(j[2].z, j[3].z, right), st.w);
283
+ }
284
+
285
+ /**
286
+ * The jelly shine, added after the graph: two hard glints where the light
287
+ * would catch a bead — placed by the half vector in the head's frame, so they
288
+ * hold still while the iris turns under them — and the eye brightening while
289
+ * it wobbles. Flat, cel discs rather than a gradient lobe; HDR, so bloom
290
+ * takes them. Shut eyes catch nothing.
291
+ */
292
+ fn rzJellyGlow(jj: RzJelly, tex: vec3f, alpha: f32, v: vec3f, l: vec3f, sun: vec3f, shadow: f32) -> vec3f {
293
+ let strength = objectLight.jelly[0].w;
294
+ var right = vec3f(1.0, 0.0, 0.0);
295
+ var up = vec3f(0.0, 1.0, 0.0);
296
+ var fwd = vec3f(0.0, 0.0, -1.0);
297
+ if (material.headBoneIndex >= 0.0) {
298
+ let hm = skinMats[u32(material.headBoneIndex)];
299
+ right = normalize(hm[0].xyz);
300
+ up = normalize(hm[1].xyz);
301
+ fwd = -normalize(hm[2].xyz);
302
+ }
303
+ let h = normalize(l + v);
304
+ // Held near the middle of the iris, a little low: the top of an iris sits
305
+ // under the upper lid, and a glint there pokes out over it.
306
+ var g = vec2f(dot(h, right), dot(h, up)) * 0.6;
307
+ g = g * min(1.0, 0.3 / max(length(g), 1e-6)) - vec2f(0.0, 0.08);
308
+ let aa = max(length(fwidth(jj.src)), 1e-4);
309
+ let glint = 1.0 - smoothstep(0.16 - aa, 0.16 + aa, distance(jj.src, g));
310
+ let second = 1.0 - smoothstep(0.07 - aa, 0.07 + aa, distance(jj.src, -0.55 * g + vec2f(0.1, -0.12)));
311
+ let facing = saturate(dot(fwd, v) * 2.0);
312
+ let key = clamp(dot(sun, vec3f(0.2126, 0.7152, 0.0722)), 0.3, 2.0);
313
+ let lit = facing * key * (0.35 + 0.65 * shadow);
314
+ // HDR, well past 1, so bloom takes them as the hard sparkle of a gem.
315
+ let glints = (glint + 0.8 * second) * lit * (2.6 + 1.6 * jj.shine);
316
+ let open = 1.0 - smoothstep(0.3, 0.8, jj.closure);
317
+ // Only where the eye's own picture is (its alpha), never on the iris's top
318
+ // rim, and gone as the lid comes down.
319
+ let onEye = smoothstep(0.5, 0.9, alpha) * (1.0 - smoothstep(0.35, 0.6, jj.src.y));
320
+ // Light through the jelly: the light enters where the glint is and comes out
321
+ // the far side, so the iris glows in its own colour in a crescent opposite
322
+ // the glint — the lit-from-within look of a gem, or a jelly.
323
+ let r = length(jj.src);
324
+ let away = -normalize(g + vec2f(0.0, -1e-3));
325
+ let crescent = smoothstep(0.15, 0.7, dot(jj.src, away)) * (1.0 - smoothstep(0.8, 1.0, r));
326
+ let through = tex * crescent * (0.9 + 0.6 * jj.shine) * lit;
327
+ // And the colour runs deeper: the iris's own hue pushed, never its greys.
328
+ let lum = dot(tex, vec3f(0.2126, 0.7152, 0.0722));
329
+ let vivid = max(tex - vec3f(lum), vec3f(0.0)) * 0.7;
330
+ let gem = (vec3f(glints) + through + vivid) * open * onEye * jj.w;
331
+ // The whole eye shines a little brighter, more while it wobbles — not just
332
+ // the iris, so this one is not held to it (jj.w).
333
+ let lift = tex * (0.15 + 0.35 * jj.shine) * select(0.0, 1.0, strength > 0.0);
334
+ return (gem + lift) * min(strength, 1.0);
335
+ }
336
+
219
337
  /** The light arriving at a surface of this object facing n: its own ambient
220
338
  * where loaded data gave it one (a stage's probes, a character's), else the
221
339
  * world's. The same polynomial rzWorldAmbient evaluates. */
@@ -322,10 +440,11 @@ const COMMON_VS_WGSL = /* wgsl */ `
322
440
  var output: VertexOutput;
323
441
  output.vcolor = vcolor;
324
442
  output.uv2 = uv2;
325
- let pos4 = vec4f(position, 1.0);
326
443
  let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
327
444
  let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
328
445
  let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
446
+ var pos4 = vec4f(position, 1.0);
447
+ if (objectLight.jelly[0].w > 0.0) { pos4 = vec4f(rzJellyVertex(position, joints0, nw), 1.0); }
329
448
  var skinnedPos = vec4f(0.0);
330
449
  var skinnedNrm = vec3f(0.0);
331
450
  for (var i = 0u; i < 4u; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.68.2",
3
+ "version": "0.68.4",
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/engine.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  type SimClock,
44
44
  } from "./effect-schedule"
45
45
  import { parentKeySpan, type ModelParentKey } from "./parent-keys"
46
+ import { JellyEyes, clampStrength, clearJellyEyes, type JellyEyeOptions } from "./jelly-eyes"
46
47
  import { NativeHost } from "./unity/host"
47
48
  import { NativeLooks, type NativeLook } from "./unity/looks"
48
49
  import { NativeStage, type NativeStageLights, type NativeStagePackage, type NativeStageReader } from "./unity/stage"
@@ -1004,6 +1005,9 @@ interface ModelInstance {
1004
1005
  * Off drops every draw from the shadow list; on hands it back to the PMX
1005
1006
  * flag on each material. */
1006
1007
  castShadow: boolean
1008
+ /** Its jelly eyes' springs, or null while its eyes are still — see
1009
+ * setModelJellyEyes. */
1010
+ jellyEyes: JellyEyes | null
1007
1011
  basePath: string
1008
1012
  assetReader: AssetReader
1009
1013
  gpuBuffers: GPUBuffer[]
@@ -8311,8 +8315,9 @@ export class Engine {
8311
8315
  visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
8312
8316
  buffer: { type: "read-only-storage" },
8313
8317
  },
8314
- // The model's own light — see setModelFill and setModelSun.
8315
- { binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8318
+ // The model's own light — see setModelFill and setModelSun. VERTEX
8319
+ // too: jelly eyes move the iris in the vertex shader (jelly[] in it).
8320
+ { binding: 1, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
8316
8321
  ],
8317
8322
  })
8318
8323
  // group 2: per-material (textures + material uniforms) — bound per draw call.
@@ -13730,6 +13735,36 @@ export class Engine {
13730
13735
  return true
13731
13736
  }
13732
13737
 
13738
+ /**
13739
+ * Jelly eyes: her irises flatten and bulge as her lids move, swell as her
13740
+ * head turns or moves, and wobble back, and shine like a gem — two hard
13741
+ * glints, light glowing through the iris, deeper colour. MMD's 果冻眼, on any
13742
+ * PMX and under any eye graph. Null stills them again.
13743
+ *
13744
+ * Driven by her closing morphs (まばたき, the winks) and her head bone, never
13745
+ * her eyes' own turn, so eye tracking following the camera leaves it still.
13746
+ * The springs step on the scene clock, so an export bounces exactly as the
13747
+ * screen did. The iris only scales in place (see jelly-eyes.ts).
13748
+ */
13749
+ setModelJellyEyes(modelName: string, options: JellyEyeOptions | null): boolean {
13750
+ const inst = this.modelInstances.get(modelName)
13751
+ if (!inst || !this.device) return false
13752
+ if (!options) {
13753
+ inst.jellyEyes = null
13754
+ clearJellyEyes(inst.objectLight)
13755
+ this.device.queue.writeBuffer(inst.lightBuffer, 0, inst.objectLight)
13756
+ return true
13757
+ }
13758
+ if (inst.jellyEyes) inst.jellyEyes.strength = clampStrength(options.strength)
13759
+ else inst.jellyEyes = new JellyEyes(inst.model, options)
13760
+ return true
13761
+ }
13762
+
13763
+ getModelJellyEyes(modelName: string): Required<JellyEyeOptions> | null {
13764
+ const jelly = this.modelInstances.get(modelName)?.jellyEyes
13765
+ return jelly ? { strength: jelly.strength } : null
13766
+ }
13767
+
13733
13768
  getModelCastShadow(modelName: string): boolean {
13734
13769
  return this.modelInstances.get(modelName)?.castShadow ?? true
13735
13770
  }
@@ -13783,7 +13818,7 @@ export class Engine {
13783
13818
  // pass entirely while it is idle. Morph changes still come through, since
13784
13819
  // that is the one thing a stage's controls do move. A prop idles the same
13785
13820
  // way while it stands on its own; hung from a hand it moves every frame.
13786
- const stageIdle = (inst.isStage || inst.isPlane || inst.isProp) && !attached && inst.model.isIdle()
13821
+ const stageIdle = (inst.isStage || inst.isPlane || inst.isProp) && !attached && !inst.physics && inst.model.isIdle()
13787
13822
  let verticesChanged = false
13788
13823
  if (!stageIdle) {
13789
13824
  // The camera in the model's own space, for the eyes — the placement
@@ -13800,6 +13835,12 @@ export class Engine {
13800
13835
  verticesChanged = inst.model.update(deltaTime, inst.isStage || inst.isPlane ? false : this.ikEnabled)
13801
13836
  inst.skinMatricesDirty = true
13802
13837
  }
13838
+ // After the pose, so the springs read this frame's blink and eye turn.
13839
+ if (inst.jellyEyes) {
13840
+ inst.jellyEyes.step(deltaTime, inst.model)
13841
+ inst.jellyEyes.write(inst.objectLight)
13842
+ this.device.queue.writeBuffer(inst.lightBuffer, 48 * 4, inst.objectLight, 48, 24)
13843
+ }
13803
13844
  animMs += performance.now() - tAnim
13804
13845
  // Material morphs ride the same weight change as vertex morphs but land in
13805
13846
  // uniform buffers, so they consume their own flag — a model whose only
@@ -15104,9 +15145,9 @@ export class Engine {
15104
15145
 
15105
15146
  // Its ObjectLight: the world's ambient, and the layers its kind draws on —
15106
15147
  // the cast on the character layer as well as the default one. Words:
15107
- // layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47] — see
15108
- // materials/common.ts.
15109
- const objectLight = new Float32Array(48)
15148
+ // layers [0..3], ambient flag [4..7], SH [8..43], fill [44..47], jelly
15149
+ // eyes [48..71] — see materials/common.ts.
15150
+ const objectLight = new Float32Array(72)
15110
15151
  new Uint32Array(objectLight.buffer)[0] =
15111
15152
  isStage || isPlane || isProp ? RENDERING_LAYER_DEFAULT : (RENDERING_LAYER_DEFAULT | RENDERING_LAYER_CHARACTER) >>> 0
15112
15153
  const lightBuffer = this.device.createBuffer({
@@ -15142,6 +15183,7 @@ export class Engine {
15142
15183
  model,
15143
15184
  simulateWhileHidden: false,
15144
15185
  castShadow: true,
15186
+ jellyEyes: null,
15145
15187
  basePath,
15146
15188
  assetReader,
15147
15189
  gpuBuffers,
@@ -57,6 +57,16 @@ const EYE_REAR_GATE = `
57
57
  }
58
58
  `
59
59
 
60
+ // Eye's jelly (Engine.setModelJellyEyes): the vertex shader moves the iris
61
+ // itself; the fragment only adds the glints, computed once the lighting context
62
+ // exists and added in the epilogue after the graph. Off, they are zero.
63
+ const PLAIN_SAMPLE = ` let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
64
+ `
65
+ const EYE_JELLY_SAMPLE = ` let rz_jelly = rzJellyEye(input.restPos);
66
+ ${PLAIN_SAMPLE}`
67
+ const EYE_JELLY_GLOW = ` let rz_jelly_glow = rzJellyGlow(rz_jelly, tex_color, tex_s.a, v, l, sun, shadow);
68
+ `
69
+
60
70
  // ── Adjust-tier uniforms — fixed 16-vec4f block (256 B) shared by the single material
61
71
  // bind group layout; non-graph draws bind a zero buffer. ──
62
72
  const STYLE_UNIFORMS_WGSL = `struct StyleUniforms { p: array<vec4f, 16> };
@@ -104,8 +114,7 @@ function prelude(renderClass: RenderClass, alphaMode: AlphaMode, blend: StyleBle
104
114
  const flip =
105
115
  renderClass === "eye" ? "" : "\n n = select(-n, n, dot(n, v) >= 0.0);"
106
116
  return `@fragment fn fs(input: VertexOutput) -> FSOut {
107
- let tex_s = textureSample(diffuseTexture, diffuseSampler, input.uv);
108
- // MMD alpha semantics: material alpha × texture alpha.
117
+ ${renderClass === "eye" ? EYE_JELLY_SAMPLE : PLAIN_SAMPLE} // MMD alpha semantics: material alpha × texture alpha.
109
118
  let alpha = material.alpha * tex_s.a;
110
119
  ${discard}
111
120
 
@@ -152,7 +161,7 @@ ${gate}
152
161
  // The normal the lamps' diffuse layer is lit by, for a principled walk to
153
162
  // compute it on the way (rzLightsDiffuseOnce). n is final from here on.
154
163
  _rzLampN = n;
155
-
164
+ ${renderClass === "eye" ? EYE_JELLY_GLOW : ""}
156
165
  `
157
166
  }
158
167
 
@@ -205,7 +214,7 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
205
214
  // it is emission, not a surface colour, so nothing may ramp or shadow it. Zero
206
215
  // on every fragment of every material that is not dissolving, which is what
207
216
  // keeps this out of the way of a scene that never uses it.
208
- const BURN = ` + RZ_BURN_COLOR * rz_burn`
217
+ const BURN = ` + RZ_BURN_COLOR * rz_burn${renderClass === "eye" ? " + rz_jelly_glow" : ""}`
209
218
  // The scene fog, last, as the game lays it over a lit surface; an
210
219
  // emission-only graph takes none (the game's effect shaders have no fog).
211
220
  // A premultiplied colour takes its fog premultiplied too: the haze a pane
package/src/index.ts CHANGED
@@ -220,6 +220,7 @@ export {
220
220
  */
221
221
  export { EFFECT_MATH_API, PARTICLE_STRUCT_WGSL } from "./shaders/passes/hosted-api"
222
222
  export type { EyeTrackingOptions } from "./model"
223
+ export type { JellyEyeOptions } from "./jelly-eyes"
223
224
 
224
225
  /**
225
226
  * The game's own materials (src/unity): a stage package drawn by its translated