@voqalize/avatar 0.3.0 → 0.4.1
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/LICENSE-CC-BY-4.0 +396 -0
- package/README.md +65 -17
- package/assets/README.md +48 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/AvatarClient.ts +337 -42
- package/client/arjun.ts +2 -0
- package/client/createAvatar.ts +38 -0
- package/client/index.ts +6 -1
- package/client/internal.ts +31 -3
- package/client/ishita.ts +2 -0
- package/client/kabir.ts +2 -0
- package/client/meera.ts +2 -0
- package/client/naina.ts +2 -0
- package/client/playout.ts +95 -0
- package/client/supports.ts +28 -0
- package/client/three/assets.ts +22 -0
- package/client/three/budgets.ts +27 -0
- package/client/three/holds.ts +64 -0
- package/client/three/internal.ts +54 -0
- package/client/three/motion-limits.json +116 -0
- package/client/three/sequences.ts +281 -0
- package/client/three/tanya-asset.ts +6 -0
- package/client/three/tanya.ts +69 -0
- package/client/three/tara-asset.ts +25 -0
- package/client/three/tara-rig.ts +1571 -0
- package/client/three/tara.ts +77 -0
- package/client/three/tushar-asset.ts +6 -0
- package/client/three/tushar.ts +65 -0
- package/client/types.ts +109 -20
- package/client/vikram.ts +2 -0
- package/dist/AvatarClient.d.ts +89 -11
- package/dist/AvatarClient.d.ts.map +1 -1
- package/dist/AvatarClient.js +297 -35
- package/dist/AvatarClient.js.map +1 -1
- package/dist/arjun.d.ts +1 -0
- package/dist/arjun.d.ts.map +1 -1
- package/dist/arjun.js +2 -0
- package/dist/arjun.js.map +1 -1
- package/dist/createAvatar.d.ts +36 -0
- package/dist/createAvatar.d.ts.map +1 -1
- package/dist/createAvatar.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +9 -5
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +13 -5
- package/dist/internal.js.map +1 -1
- package/dist/ishita.d.ts +1 -0
- package/dist/ishita.d.ts.map +1 -1
- package/dist/ishita.js +2 -0
- package/dist/ishita.js.map +1 -1
- package/dist/kabir.d.ts +1 -0
- package/dist/kabir.d.ts.map +1 -1
- package/dist/kabir.js +2 -0
- package/dist/kabir.js.map +1 -1
- package/dist/meera.d.ts +1 -0
- package/dist/meera.d.ts.map +1 -1
- package/dist/meera.js +2 -0
- package/dist/meera.js.map +1 -1
- package/dist/naina.d.ts +1 -0
- package/dist/naina.d.ts.map +1 -1
- package/dist/naina.js +2 -0
- package/dist/naina.js.map +1 -1
- package/dist/playout.d.ts +42 -0
- package/dist/playout.d.ts.map +1 -0
- package/dist/playout.js +85 -0
- package/dist/playout.js.map +1 -0
- package/dist/supports.d.ts +20 -0
- package/dist/supports.d.ts.map +1 -0
- package/dist/supports.js +26 -0
- package/dist/supports.js.map +1 -0
- package/dist/three/assets.d.ts +8 -0
- package/dist/three/assets.d.ts.map +1 -0
- package/dist/three/assets.js +19 -0
- package/dist/three/assets.js.map +1 -0
- package/dist/three/budgets.d.ts +17 -0
- package/dist/three/budgets.d.ts.map +1 -0
- package/dist/three/budgets.js +21 -0
- package/dist/three/budgets.js.map +1 -0
- package/dist/three/holds.d.ts +33 -0
- package/dist/three/holds.d.ts.map +1 -0
- package/dist/three/holds.js +56 -0
- package/dist/three/holds.js.map +1 -0
- package/dist/three/internal.d.ts +52 -0
- package/dist/three/internal.d.ts.map +1 -0
- package/dist/three/internal.js +49 -0
- package/dist/three/internal.js.map +1 -0
- package/dist/three/motion-limits.json +116 -0
- package/dist/three/sequences.d.ts +71 -0
- package/dist/three/sequences.d.ts.map +1 -0
- package/dist/three/sequences.js +262 -0
- package/dist/three/sequences.js.map +1 -0
- package/dist/three/tanya-asset.d.ts +7 -0
- package/dist/three/tanya-asset.d.ts.map +1 -0
- package/dist/three/tanya-asset.js +7 -0
- package/dist/three/tanya-asset.js.map +1 -0
- package/dist/three/tanya.d.ts +35 -0
- package/dist/three/tanya.d.ts.map +1 -0
- package/dist/three/tanya.js +51 -0
- package/dist/three/tanya.js.map +1 -0
- package/dist/three/tara-asset.d.ts +26 -0
- package/dist/three/tara-asset.d.ts.map +1 -0
- package/dist/three/tara-asset.js +26 -0
- package/dist/three/tara-asset.js.map +1 -0
- package/dist/three/tara-rig.d.ts +409 -0
- package/dist/three/tara-rig.d.ts.map +1 -0
- package/dist/three/tara-rig.js +1504 -0
- package/dist/three/tara-rig.js.map +1 -0
- package/dist/three/tara.d.ts +35 -0
- package/dist/three/tara.d.ts.map +1 -0
- package/dist/three/tara.js +59 -0
- package/dist/three/tara.js.map +1 -0
- package/dist/three/tushar-asset.d.ts +7 -0
- package/dist/three/tushar-asset.d.ts.map +1 -0
- package/dist/three/tushar-asset.js +7 -0
- package/dist/three/tushar-asset.js.map +1 -0
- package/dist/three/tushar.d.ts +31 -0
- package/dist/three/tushar.d.ts.map +1 -0
- package/dist/three/tushar.js +47 -0
- package/dist/three/tushar.js.map +1 -0
- package/dist/types.d.ts +65 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +58 -11
- package/dist/types.js.map +1 -1
- package/dist/vikram.d.ts +1 -0
- package/dist/vikram.d.ts.map +1 -1
- package/dist/vikram.js +2 -0
- package/dist/vikram.js.map +1 -1
- package/package.json +34 -6
- package/src/avatar.d.ts +59 -2
- package/src/avatar.js +743 -94
- package/src/behavior.d.ts +28 -12
- package/src/behavior.js +54 -42
- package/src/canvas/create-rig.js +1 -1
- package/src/canvas/src/live.js +2 -2
- package/src/conformance.js +1 -1
- package/src/emotions.js +4 -2
- package/src/gaze.js +334 -73
- package/src/head.js +134 -0
- package/src/idle.js +210 -19
- package/src/interjections.js +70 -26
- package/src/prosody.js +647 -0
- package/src/visemes.js +85 -15
package/src/gaze.js
CHANGED
|
@@ -9,8 +9,17 @@
|
|
|
9
9
|
* after — accelerating, cruising, braking to a stop (see HEAD_ACCEL).
|
|
10
10
|
* 2. The head only goes part of the way. Real people under-rotate the head
|
|
11
11
|
* and let the eyes carry the remainder.
|
|
12
|
-
* 3. A blink
|
|
13
|
-
*
|
|
12
|
+
* 3. A blink rides a large shift. Gaze-evoked blinks are involuntary, more
|
|
13
|
+
* likely the bigger the shift (Evinger et al. 1994), and their absence
|
|
14
|
+
* is uncanny even though nobody can name what's wrong.
|
|
15
|
+
*
|
|
16
|
+
* A rig that states what a pose unit is in degrees (`angles`) gets the rest of
|
|
17
|
+
* the eye-head system too: its own target table, where the head carries most
|
|
18
|
+
* of a look and the eyes land about a third of the way off centre (Freedman &
|
|
19
|
+
* Sparks; Pejsa & Andrist's gaze model), and a vestibulo-ocular reflex in the
|
|
20
|
+
* mixer that holds the eyes on their target while the head moves under them.
|
|
21
|
+
* Without the reflex every nod and every speech pose change is also a look
|
|
22
|
+
* somewhere else.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
export const GAZE_TARGETS = {
|
|
@@ -29,6 +38,12 @@ export const GAZE_TARGETS = {
|
|
|
29
38
|
SCREEN_BOTTOM: { px: 0.00, py: 0.62, hx: 0.00, hy: 0.24 },
|
|
30
39
|
SCREEN_WORK: { px: -0.62, py: 0.18, hx: -0.26, hy: 0.12 },
|
|
31
40
|
NOTES: { px: 0.18, py: 0.72, hx: 0.04, hy: 0.28 },
|
|
41
|
+
// The agent's own display, just under the camera: where anyone on a video
|
|
42
|
+
// call looks while they do something for you. Eyes a little down and the
|
|
43
|
+
// head barely joins, because reading your own screen is an eye movement.
|
|
44
|
+
// SCREEN_WORK turned the head a quarter of the way to the left and held it
|
|
45
|
+
// there for the whole task, which read as turning away from the user.
|
|
46
|
+
OWN_SCREEN: { px: 0.04, py: 0.30, hx: 0.02, hy: 0.07 },
|
|
32
47
|
// The classic "recalling something" break of eye contact. Keep it for the
|
|
33
48
|
// stylized "let me think" beat — audiences read up-and-away regardless of
|
|
34
49
|
// whether real thinkers do it.
|
|
@@ -39,12 +54,64 @@ export const GAZE_TARGETS = {
|
|
|
39
54
|
// that the lid follow shades the eyes without sealing them; the head
|
|
40
55
|
// carries a share so the pupils stay inside the aperture at tile size.
|
|
41
56
|
AWAY_DOWN: { px: -0.45, py: 0.42, hx: -0.18, hy: 0.20, roll: 0.04 },
|
|
57
|
+
// Level and to the side: the other third of measured cognitive aversions
|
|
58
|
+
// (31.3%, §4.2), neither up nor down. Right, so a thinker who alternates it
|
|
59
|
+
// with AWAY_DOWN's left is not looking at the same patch of floor twice.
|
|
60
|
+
AWAY_SIDE: { px: 0.55, py: 0.08, hx: 0.20, hy: 0.03, roll: -0.03 },
|
|
42
61
|
};
|
|
43
62
|
|
|
44
63
|
export const GAZE_NAMES = Object.keys(GAZE_TARGETS);
|
|
45
64
|
|
|
46
65
|
const HEAD_FOLLOW_TAU = 0.34; // roll only — the head lags the eyes badly, on purpose
|
|
47
|
-
|
|
66
|
+
// Gaze-evoked blink odds, ramping with the size of the shift: none below the
|
|
67
|
+
// first number, certain above the second. Degrees on a rig that states them —
|
|
68
|
+
// stylised, well under Evinger's 10-40°, because every look here is scaled
|
|
69
|
+
// down to what a head-and-shoulders crop shows: a thinking look (~11°) always
|
|
70
|
+
// blinks, a speaker's glance aside (~5°) about a third of the time, a reading
|
|
71
|
+
// step never. Pose units on one that does not, where 0.45 was once a hard
|
|
72
|
+
// threshold and now blinks a quarter of the time.
|
|
73
|
+
const BLINK_RAMP_DEG = [3, 9];
|
|
74
|
+
const BLINK_RAMP_UNITS = [0.3, 0.9];
|
|
75
|
+
// The upper lid rides the eye, as a share of the pupil's travel from USER. A
|
|
76
|
+
// line face's lid follows down only: its lid travels further than its iris,
|
|
77
|
+
// and one that opened past rest on an upward look bared a band of sclera and
|
|
78
|
+
// read as alarm. A rig whose lid and iris travel alike follows both ways.
|
|
79
|
+
const LID_FOLLOW = { down: 0.34, up: 0 };
|
|
80
|
+
// A turning head dips. Heads travel in arcs, not on a flat plane — a turn that
|
|
81
|
+
// holds its pitch reads as a turntable (Williams, *The Animator's Survival
|
|
82
|
+
// Kit*, on head turns). A share of the turn's length, read off the yaw speed
|
|
83
|
+
// as v²/a — which at the middle of a braked turn *is* its length — so the dip
|
|
84
|
+
// peaks mid-turn, is gone when the head lands, and sizes itself on a rig whose
|
|
85
|
+
// head moves four times faster. A 0.75-unit look on tara sinks ~0.8°.
|
|
86
|
+
const HEAD_DIP = 0.06;
|
|
87
|
+
// A gated aversion waits this long past its due time for a phrase boundary,
|
|
88
|
+
// then goes anyway: a run of speech with no pause in it still gets its look.
|
|
89
|
+
const GATE_WAIT = 1.5;
|
|
90
|
+
// How much of a drift the head joins. Little: a drift is a reader's scan or a
|
|
91
|
+
// thinker's gaze moving where it rests, and both are eye movements. At 0.35
|
|
92
|
+
// the head nodded along with every step of a reading scan, which on a
|
|
93
|
+
// photographic face reads as a bobbing head rather than a moving eye.
|
|
94
|
+
const DRIFT_HEAD = 0.12;
|
|
95
|
+
|
|
96
|
+
// Where the eyes land inside a fixation on the user: the user's two eyes and,
|
|
97
|
+
// less often, the mouth — the triangle a listener's gaze scans a face with.
|
|
98
|
+
// A fixation that sits on one point is the stare; one that visits these
|
|
99
|
+
// reads as someone looking at a person. Pose units at scanGain 1.
|
|
100
|
+
const FACE_POINTS = [[-0.05, 0], [0.05, 0], [-0.05, 0], [0.05, 0], [0, 0.07], [0, 0]];
|
|
101
|
+
// Default gap between those fixational saccades, seconds. Eyes Alive
|
|
102
|
+
// (docs/research-biomechanics.md §4.5) is that this interval differs by mode,
|
|
103
|
+
// so states set their own through the mixer.
|
|
104
|
+
const SCAN_EVERY = [0.5, 1.6];
|
|
105
|
+
// Fixational jumps inside a look away, as a share of the usual size. The look
|
|
106
|
+
// is one saccade out and one back; big jumps inside it read as searching, and
|
|
107
|
+
// on a rig with a large scanGain one could land the eyes back on the face
|
|
108
|
+
// mid-look, so the look happened and did not read.
|
|
109
|
+
const AVERT_SCAN = 0.35;
|
|
110
|
+
// The least a new fixation moves from the last, pose units at scanGain 1.
|
|
111
|
+
// Drawn independently, one in a few landed on the one before, and two jumps
|
|
112
|
+
// that go nowhere are one long stare: 6 s on a single point, measured, in the
|
|
113
|
+
// state that is waiting for the user to come back.
|
|
114
|
+
const MIN_STEP = 0.025;
|
|
48
115
|
|
|
49
116
|
// Head follow is ballistic, not exponential. An exponential chase has its peak
|
|
50
117
|
// velocity at t=0 and then creeps forever — motion that starts instantly and
|
|
@@ -56,8 +123,51 @@ const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked bli
|
|
|
56
123
|
// 0.55-unit swing in ~0.85s. The mixer still low-passes headYaw/headPitch at
|
|
57
124
|
// τ=0.16 downstream, which rounds the hard stop into a short settle — that
|
|
58
125
|
// cascade is deliberate, so do not "help" by softening the brake here too.
|
|
126
|
+
// A rig whose head unit is a few degrees sets its own (`headAccel`,
|
|
127
|
+
// `headSpeed`): an amble sized in line-face units took a 7° look 1.2 s.
|
|
59
128
|
const HEAD_ACCEL = 4.0; // units/s² — sets both launch and braking firmness
|
|
60
129
|
const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach it
|
|
130
|
+
// The head takes only a fraction of what an aversion takes — a brief look-away
|
|
131
|
+
// is an eye movement that the head barely joins, and a head that follows it
|
|
132
|
+
// fully reads as turning away. Sized for a line face, whose pupils travel far
|
|
133
|
+
// inside a big eye; a rig sets its own split (`avertSplit`).
|
|
134
|
+
const HEAD_SHARE = 0.22;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Ballistic follow: steer velocity toward "full speed at the goal, but never
|
|
138
|
+
* faster than can still brake to a stop within the distance left". The
|
|
139
|
+
* braking bound is v² = 2·a·d solved for v, with a half-step correction for
|
|
140
|
+
* discrete time (the −maxA term) so a frame never lands past the goal. As d
|
|
141
|
+
* shrinks the bound falls to zero, which *is* the deceleration — no separate
|
|
142
|
+
* easing curve.
|
|
143
|
+
*/
|
|
144
|
+
function chase(pos, vel, gx, gy, dt, accel, speed) {
|
|
145
|
+
const dx = gx - pos.x;
|
|
146
|
+
const dy = gy - pos.y;
|
|
147
|
+
const d = Math.hypot(dx, dy);
|
|
148
|
+
const maxA = accel * dt;
|
|
149
|
+
const brake = 0.5 * (Math.sqrt(maxA * maxA + 8 * accel * d) - maxA);
|
|
150
|
+
const goal = Math.min(speed, brake);
|
|
151
|
+
let ax = (d ? (dx / d) * goal : 0) - vel.x;
|
|
152
|
+
let ay = (d ? (dy / d) * goal : 0) - vel.y;
|
|
153
|
+
const a = Math.hypot(ax, ay);
|
|
154
|
+
if (a > maxA) { ax *= maxA / a; ay *= maxA / a; }
|
|
155
|
+
vel.x += ax;
|
|
156
|
+
vel.y += ay;
|
|
157
|
+
// A dropped-frame dt could step past the goal; land on it instead. Judged
|
|
158
|
+
// on the toward-goal component so a mid-retarget frame with sideways
|
|
159
|
+
// velocity keeps flying rather than teleporting.
|
|
160
|
+
const along = d ? ((vel.x * dx + vel.y * dy) / d) * dt : 0;
|
|
161
|
+
if (along >= d && d >= 0) {
|
|
162
|
+
pos.x = gx;
|
|
163
|
+
pos.y = gy;
|
|
164
|
+
vel.x = 0;
|
|
165
|
+
vel.y = 0;
|
|
166
|
+
} else {
|
|
167
|
+
pos.x += vel.x * dt;
|
|
168
|
+
pos.y += vel.y * dt;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
61
171
|
|
|
62
172
|
/**
|
|
63
173
|
* Aversion profiles: a brief, deliberate break of eye contact that returns to
|
|
@@ -88,6 +198,18 @@ const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach i
|
|
|
88
198
|
* often each is taken; magnitude is scaled per-fire.
|
|
89
199
|
*/
|
|
90
200
|
export const AVERSION = {
|
|
201
|
+
// Idle, and before a call: present and relaxed rather than attending. The
|
|
202
|
+
// eyes rest on the user a comfortable bout (preferred mutual gaze 3.3 s,
|
|
203
|
+
// §4.1) and leave for longer, softer looks than a listener's — level or a
|
|
204
|
+
// little down, never up, because up is the "recalling" look and there is
|
|
205
|
+
// nothing to recall. This is the first face anyone sees, and it used to
|
|
206
|
+
// open looking up and to the left.
|
|
207
|
+
IDLE: {
|
|
208
|
+
every: [3.2, 6.0],
|
|
209
|
+
dur: [1.4, 2.8],
|
|
210
|
+
mag: [0.30, 0.46],
|
|
211
|
+
dirs: [[-1, 0.10], [1, 0.10], [-0.8, 0.45], [0.8, 0.40], [-1, 0.25]],
|
|
212
|
+
},
|
|
91
213
|
// While listening: 1.14 s (SD 0.27) every 7.21 s (SD 1.88), 57.5% sideways.
|
|
92
214
|
// Sideways dominates because down reads as submission and up reads as
|
|
93
215
|
// exasperation on a face this schematic.
|
|
@@ -97,13 +219,33 @@ export const AVERSION = {
|
|
|
97
219
|
mag: [0.30, 0.44],
|
|
98
220
|
dirs: [[-1, 0.06], [-1, 0.06], [1, 0.02], [1, 0.02], [-0.7, -0.5], [0.5, 0.35]],
|
|
99
221
|
},
|
|
222
|
+
// While speaking: 1.96 s every 4.75 s mid-turn, a turn-start look on 73% of
|
|
223
|
+
// turns — the planning look, away to find the words and back to deliver
|
|
224
|
+
// them — and none in the last 2.4 s, because the floor is handed back under
|
|
225
|
+
// mutual gaze (§4.2; the mixer owns that hold, since only it can see the
|
|
226
|
+
// cue track). Human speakers hold the listener's eyes for about 41% of a
|
|
227
|
+
// turn. This is set for 70-80% instead — the
|
|
228
|
+
// listener here watches a face on a screen, where 41% reads as evasive — so
|
|
229
|
+
// the looks keep about Andrist's length and come a little less often.
|
|
230
|
+
// `every` sits under Andrist's 4.75 s because the phrase gate and the
|
|
231
|
+
// end-of-turn hold both stretch it: at 3.8-5.8 a turn measured 6-8 looks a
|
|
232
|
+
// minute and 76-82% contact, the top edge of the band rather than its middle.
|
|
233
|
+
// `gated`: a mid-turn look waits for a phrase boundary, because speakers
|
|
234
|
+
// break gaze between clauses, not in the middle of a word.
|
|
235
|
+
SPEAK: {
|
|
236
|
+
every: [3.4, 5.2],
|
|
237
|
+
dur: [1.5, 2.3],
|
|
238
|
+
mag: [0.34, 0.48],
|
|
239
|
+
dirs: [[-1, 0.12], [1, 0.12], [-0.75, 0.55], [0.7, 0.5], [-0.6, -0.45]],
|
|
240
|
+
gated: true,
|
|
241
|
+
start: { p: 0.73, dur: [1.1, 1.8], window: 0.8 },
|
|
242
|
+
},
|
|
100
243
|
};
|
|
101
244
|
// There is deliberately no THINK profile. The *cognitive* aversion — 3.54 s
|
|
102
245
|
// (SD 1.26), splitting 39.3% down / 29.4% up / 31.3% side (§4.2) — is longer
|
|
103
|
-
// and deeper than the listening kind, and THINKING
|
|
104
|
-
// `
|
|
105
|
-
// mechanisms producing the same look would fight
|
|
106
|
-
// away properly, and this profile is for the state that must not.
|
|
246
|
+
// and deeper than the listening kind, and THINKING renders it with its own
|
|
247
|
+
// dwell cycle (a `glance` in STATES), which moves the whole gaze target rather
|
|
248
|
+
// than nudging off it. Two mechanisms producing the same look would fight.
|
|
107
249
|
|
|
108
250
|
export class GazeLayer {
|
|
109
251
|
constructor() {
|
|
@@ -114,6 +256,30 @@ export class GazeLayer {
|
|
|
114
256
|
this.onLargeShift = null;
|
|
115
257
|
this.jitter = { x: 0, y: 0 };
|
|
116
258
|
this._nextMicro = 0;
|
|
259
|
+
/** How far a fixational saccade travels. A pupil unit is a different
|
|
260
|
+
* angle on every rig, and one sized for a line face is invisible on a
|
|
261
|
+
* photographic one — the mixer passes the rig's `saccadeGain`. */
|
|
262
|
+
this.scanGain = 1;
|
|
263
|
+
/** The same for conversational aversions (AVERSION), which are sized for
|
|
264
|
+
* a line face too. On tara a speaking look-away at 1 is 2-3 px of iris
|
|
265
|
+
* travel, and a recorded call read the whole turn as a locked stare. */
|
|
266
|
+
this.avertGain = 1;
|
|
267
|
+
/** Where each named look goes, on this rig. The shared table is sized for
|
|
268
|
+
* a line face; a rig whose eyes are a fraction of its head's reach
|
|
269
|
+
* brings its own, where the head carries the look. */
|
|
270
|
+
this.targets = GAZE_TARGETS;
|
|
271
|
+
/** How an aversion divides: `eye` of it stays in the eyes, and `head` of
|
|
272
|
+
* it (in head units per pupil unit) goes to the neck. */
|
|
273
|
+
this.avertSplit = { eye: 1, head: HEAD_SHARE };
|
|
274
|
+
/** Degrees per pose unit, `{ eye: {x, y}, head: {x, y} }`, or null on a
|
|
275
|
+
* rig that has not said — then shifts are measured in pupil units. */
|
|
276
|
+
this.angles = null;
|
|
277
|
+
this.lidFollow = LID_FOLLOW;
|
|
278
|
+
this.headAccel = HEAD_ACCEL;
|
|
279
|
+
this.headSpeed = HEAD_SPEED;
|
|
280
|
+
/** [min, max] seconds between fixational saccades, or null for the
|
|
281
|
+
* default. Set per state by the mixer. */
|
|
282
|
+
this.scanEvery = null;
|
|
117
283
|
this._t = 0;
|
|
118
284
|
// --- aversion scheduler
|
|
119
285
|
this.aversion = null; // one of AVERSION, or null for none
|
|
@@ -126,7 +292,16 @@ export class GazeLayer {
|
|
|
126
292
|
this._avUntil = 0;
|
|
127
293
|
this._avVec = { x: 0, y: 0 };
|
|
128
294
|
this._avAmt = 0; // glided 0..1 so the return is a movement, not a cut
|
|
295
|
+
this._avWant = 0; // where _avAmt is going; the head chases this
|
|
296
|
+
this._avHead = { x: 0, y: 0 };
|
|
297
|
+
this._avVel = { x: 0, y: 0 };
|
|
129
298
|
this._avProfileRef = undefined;
|
|
299
|
+
/** Whether now is a phrase boundary, for a `gated` profile. Set by the mixer. */
|
|
300
|
+
this.gate = true;
|
|
301
|
+
this._avStartBy = 0; // a turn-start look may still fire until this time
|
|
302
|
+
// --- drift: a small held offset about the target, set by the mixer
|
|
303
|
+
this.drift = { x: 0, y: 0 };
|
|
304
|
+
this._drift = { x: 0, y: 0 };
|
|
130
305
|
}
|
|
131
306
|
|
|
132
307
|
/** Adopt an aversion profile (or null). Cheap to call every frame. */
|
|
@@ -138,6 +313,15 @@ export class GazeLayer {
|
|
|
138
313
|
// it is entered off a stale timestamp from one that didn't.
|
|
139
314
|
this._avNext = this._t + (p ? p.every[0] + Math.random() * (p.every[1] - p.every[0]) : 0);
|
|
140
315
|
this._avUntil = 0;
|
|
316
|
+
this._avStartBy = 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A turn begins. If the profile has a turn-start look and the dice take it,
|
|
320
|
+
* it fires as soon as `hold` allows inside its window, or not at all — a
|
|
321
|
+
* planning look that arrives a second into the words is just a look. */
|
|
322
|
+
startTurn() {
|
|
323
|
+
const s = this.aversion && this.aversion.start;
|
|
324
|
+
if (s && Math.random() < s.p) this._avStartBy = this._t + s.window;
|
|
141
325
|
}
|
|
142
326
|
|
|
143
327
|
_avert(t, dt) {
|
|
@@ -146,21 +330,40 @@ export class GazeLayer {
|
|
|
146
330
|
// Glide home even after the profile is gone, so a state change mid-look
|
|
147
331
|
// returns the eyes instead of snapping them.
|
|
148
332
|
this._avAmt = Math.max(0, this._avAmt - dt / 0.18);
|
|
333
|
+
this._avWant = 0;
|
|
149
334
|
return;
|
|
150
335
|
}
|
|
151
|
-
if (this._avUntil &&
|
|
336
|
+
if (this._avUntil && this.hold) {
|
|
337
|
+
// `hold` ends a look already running, and the schedule starts over
|
|
338
|
+
// behind it. Suspending the look instead sent the eyes back out the
|
|
339
|
+
// moment the hold lifted — out, back and out again across one turn
|
|
340
|
+
// edge, which is three saccades where the floor wanted none.
|
|
341
|
+
this._avUntil = 0;
|
|
342
|
+
this._avStartBy = 0;
|
|
343
|
+
this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
|
|
344
|
+
} else if (this._avUntil && t >= this._avUntil) {
|
|
152
345
|
this._avUntil = 0;
|
|
346
|
+
this._nextMicro = 0;
|
|
153
347
|
this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
|
|
154
|
-
} else if (!this._avUntil && t
|
|
155
|
-
|
|
348
|
+
} else if (!this._avUntil && !this.hold && (t < this._avStartBy || (t >= this._avNext
|
|
349
|
+
&& (this.gate || !p.gated || t >= this._avNext + GATE_WAIT)))) {
|
|
350
|
+
const dur = t < this._avStartBy ? p.start.dur : p.dur;
|
|
351
|
+
this._avStartBy = 0;
|
|
352
|
+
this._avUntil = t + dur[0] + Math.random() * (dur[1] - dur[0]);
|
|
353
|
+
this._nextMicro = 0;
|
|
156
354
|
const d = p.dirs[(Math.random() * p.dirs.length) | 0];
|
|
157
|
-
const m = p.mag[0] + Math.random() * (p.mag[1] - p.mag[0]);
|
|
355
|
+
const m = (p.mag[0] + Math.random() * (p.mag[1] - p.mag[0])) * this.avertGain;
|
|
158
356
|
this._avVec.x = d[0] * m;
|
|
159
357
|
this._avVec.y = d[1] * m;
|
|
358
|
+
// A look away is a gaze shift like any other, and blinks by its size.
|
|
359
|
+
const s = this.avertSplit;
|
|
360
|
+
const out = { px: d[0] * m * s.eye, py: d[1] * m * s.eye, hx: d[0] * m * s.head, hy: d[1] * m * s.head };
|
|
361
|
+
this._shiftBlink({ px: 0, py: 0, hx: 0, hy: 0 }, out);
|
|
160
362
|
}
|
|
161
363
|
// `hold` cancels an aversion already running, it does not merely postpone
|
|
162
364
|
// the next: the turn can end mid-look, and the eyes have to be back.
|
|
163
365
|
const want = this._avUntil && !this.hold ? 1 : 0;
|
|
366
|
+
this._avWant = want;
|
|
164
367
|
// Out fast, back slightly slower. A saccade away is ballistic; the return
|
|
165
368
|
// to a face is a fraction more deliberate, and symmetric timing here is one
|
|
166
369
|
// of the things that makes a rig read as a metronome.
|
|
@@ -179,92 +382,150 @@ export class GazeLayer {
|
|
|
179
382
|
* @param {string} name one of GAZE_NAMES
|
|
180
383
|
* @param {{x:number,y:number}} [override] normalized -1..1 escape hatch for
|
|
181
384
|
* when the server knows exact screen coordinates
|
|
385
|
+
* @param {boolean} [blink] true to ask for the evoked blink whatever the
|
|
386
|
+
* size, false to withhold it, omitted to leave it to the odds
|
|
182
387
|
*/
|
|
183
|
-
set(name, override) {
|
|
388
|
+
set(name, override, blink) {
|
|
184
389
|
const next = override
|
|
185
390
|
? { px: override.x, py: override.y, hx: override.x * 0.42, hy: override.y * 0.36 }
|
|
186
|
-
:
|
|
187
|
-
const
|
|
391
|
+
: this.targets[name] || this.targets.USER;
|
|
392
|
+
const prev = this.target;
|
|
188
393
|
this.target = next;
|
|
189
394
|
this.name = override ? 'CUSTOM' : name;
|
|
190
|
-
if (
|
|
395
|
+
if (blink !== false) this._shiftBlink(prev, next, blink);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Roll the gaze-evoked blink for a shift from one target to another. */
|
|
399
|
+
_shiftBlink(a, b, force) {
|
|
400
|
+
if (!this.onLargeShift) return;
|
|
401
|
+
const g = this.angles;
|
|
402
|
+
const [lo, hi] = g ? BLINK_RAMP_DEG : BLINK_RAMP_UNITS;
|
|
403
|
+
const size = g
|
|
404
|
+
? Math.hypot((b.px - a.px) * g.eye.x + (b.hx - a.hx) * g.head.x,
|
|
405
|
+
(b.py - a.py) * g.eye.y + ((b.hy || 0) - (a.hy || 0)) * g.head.y)
|
|
406
|
+
: Math.hypot(b.px - a.px, b.py - a.py);
|
|
407
|
+
// The listener is told which of the two it is: a beat the state named
|
|
408
|
+
// outranks the blink budget, a roll of the odds does not.
|
|
409
|
+
//
|
|
410
|
+
// A shift past `hi` is not a roll. The ramp prices those at probability 1,
|
|
411
|
+
// so the odds have already decided, and the budget was overruling the
|
|
412
|
+
// ramp's own certainty: on the call reel the three biggest hops a screen
|
|
413
|
+
// hunt makes -- 10.9°, 9.5°, 9.4° -- reached the eyelids as a coin toss
|
|
414
|
+
// that the budget then refused. A saccade that size wants every channel a
|
|
415
|
+
// head turn has (research-biomechanics §1.2: yaw, a pitch dip, a blink).
|
|
416
|
+
//
|
|
417
|
+
// Affordable because it is only the tail. Forcing the *whole* evoked
|
|
418
|
+
// population is what cost 2.12x the authored rate and is why
|
|
419
|
+
// EVOKED_EARLIEST exists; this is three shifts in a minute-long run, and
|
|
420
|
+
// over four seeds 78 blinks fired where the budget alone fired 79.
|
|
421
|
+
//
|
|
422
|
+
// The refractory is untouched and refuses half of these, because a hunt
|
|
423
|
+
// retargets every 0.8-2.0 s and that is faster than 1.5 s. That is the
|
|
424
|
+
// refractory working as written -- a glance out and back carries one
|
|
425
|
+
// blink, not two. So this masks the isolated large look and not the
|
|
426
|
+
// rapid ones inside a hunt, which stay open-eyed.
|
|
427
|
+
if (force || size >= hi) this.onLargeShift(true);
|
|
428
|
+
else if (Math.random() < (size - lo) / (hi - lo)) this.onLargeShift(false);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The lid's share of an eye at `pupilY`, added to the lid channels. */
|
|
432
|
+
lidBias(pupilY) {
|
|
433
|
+
const rest = GAZE_TARGETS.USER.py;
|
|
434
|
+
const f = this.lidFollow;
|
|
435
|
+
return rest * f.down + (pupilY - rest) * (pupilY > rest ? f.down : f.up);
|
|
191
436
|
}
|
|
192
437
|
|
|
193
438
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
439
|
+
* Fixational saccades. Eyes are never still, and they do not wobble about a
|
|
440
|
+
* point either: they jump and *hold*, a new fixation every half second or
|
|
441
|
+
* so. On the user they visit the user's eyes and mouth; anywhere else they
|
|
442
|
+
* land a little way about the target. These used to decay back to the exact
|
|
443
|
+
* centre within a second, so between jumps every state stared at one point,
|
|
444
|
+
* and at a photographic face's scale the jumps were under a pixel —
|
|
445
|
+
* §4.6's "sub-threshold jitter signals nothing", measured.
|
|
196
446
|
*/
|
|
197
|
-
_micro(t
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
447
|
+
_micro(t) {
|
|
448
|
+
// A state's `scan` is [min gap, max gap, amplitude]: a reader's own
|
|
449
|
+
// line steps carry the movement, so a small third value keeps the
|
|
450
|
+
// fixational jumps from blurring them.
|
|
451
|
+
const [a, b, k = 1] = this.scanEvery || SCAN_EVERY;
|
|
452
|
+
// And a state that scans faster gets it from the frame it starts, not once
|
|
453
|
+
// the state before it has finished waiting. The deadline is drawn when a
|
|
454
|
+
// fixation fires, under whatever cadence was in force *then*, so entering a
|
|
455
|
+
// hunt out of a slower state finds a gap longer than the hunt's own maximum
|
|
456
|
+
// already on the clock and spends its first second at the old rate. On a
|
|
457
|
+
// two-second state that is most of the state: eyes that hold dead still and
|
|
458
|
+
// then jump once, which is read as mechanical rather than as searching.
|
|
459
|
+
// Clamped, not redrawn: the wait already served still counts, and a state
|
|
460
|
+
// change must not cost a draw from the shared stream — every blink and
|
|
461
|
+
// wander in the reel comes off it, and spending one here would move them.
|
|
462
|
+
if (this._nextMicro > t + b) this._nextMicro = t + b;
|
|
463
|
+
if (t < this._nextMicro) return;
|
|
464
|
+
this._nextMicro = t + a + Math.random() * (b - a);
|
|
465
|
+
const g = this.scanGain * k * (this._avUntil ? AVERT_SCAN : 1);
|
|
466
|
+
// The face scan is for looking *at* the user. A look away is not one,
|
|
467
|
+
// and a face-point offset riding on it pulled short looks back onto the
|
|
468
|
+
// face — the look happened and did not read.
|
|
469
|
+
const face = (this.name === 'USER' || this.name === 'USER_EAR') && !this._avUntil;
|
|
470
|
+
let x = 0, y = 0;
|
|
471
|
+
for (let i = 0; i < 4; i++) {
|
|
472
|
+
if (face) {
|
|
473
|
+
const [fx, fy] = FACE_POINTS[(Math.random() * FACE_POINTS.length) | 0];
|
|
474
|
+
x = (fx + (Math.random() - 0.5) * 0.02) * g;
|
|
475
|
+
y = (fy + (Math.random() - 0.5) * 0.02) * g;
|
|
476
|
+
} else {
|
|
477
|
+
x = (Math.random() - 0.5) * 0.12 * g;
|
|
478
|
+
y = (Math.random() - 0.5) * 0.08 * g;
|
|
479
|
+
}
|
|
480
|
+
if (Math.hypot(x - this.jitter.x, y - this.jitter.y) >= MIN_STEP * g) break;
|
|
202
481
|
}
|
|
203
|
-
|
|
204
|
-
this.jitter.
|
|
205
|
-
this.jitter.y -= this.jitter.y * decay;
|
|
482
|
+
this.jitter.x = x;
|
|
483
|
+
this.jitter.y = y;
|
|
206
484
|
}
|
|
207
485
|
|
|
208
486
|
update(t, dt) {
|
|
209
487
|
this._t = t;
|
|
210
|
-
this._micro(t, dt);
|
|
211
488
|
this._avert(t, dt);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
let ax = (d ? (dx / d) * goal : 0) - this.vel.x;
|
|
228
|
-
let ay = (d ? (dy / d) * goal : 0) - this.vel.y;
|
|
229
|
-
const a = Math.hypot(ax, ay);
|
|
230
|
-
if (a > maxA) { ax *= maxA / a; ay *= maxA / a; }
|
|
231
|
-
this.vel.x += ax;
|
|
232
|
-
this.vel.y += ay;
|
|
233
|
-
// A dropped-frame dt could step past the target; land on it instead.
|
|
234
|
-
// Judged on the toward-target component so a mid-retarget frame with
|
|
235
|
-
// sideways velocity keeps flying rather than teleporting.
|
|
236
|
-
const along = d ? ((this.vel.x * dx + this.vel.y * dy) / d) * dt : 0;
|
|
237
|
-
if (along >= d && d >= 0) {
|
|
238
|
-
this.head.x = this.target.hx;
|
|
239
|
-
this.head.y = this.target.hy;
|
|
240
|
-
this.vel.x = 0;
|
|
241
|
-
this.vel.y = 0;
|
|
242
|
-
} else {
|
|
243
|
-
this.head.x += this.vel.x * dt;
|
|
244
|
-
this.head.y += this.vel.y * dt;
|
|
245
|
-
}
|
|
489
|
+
this._micro(t);
|
|
490
|
+
// Drift lands like a saccade — quick, then held — not as a slide.
|
|
491
|
+
const kd = 1 - Math.exp(-dt / 0.04);
|
|
492
|
+
this._drift.x += (this.drift.x - this._drift.x) * kd;
|
|
493
|
+
this._drift.y += (this.drift.y - this._drift.y) * kd;
|
|
494
|
+
const s = this.avertSplit;
|
|
495
|
+
const avx = this._avVec.x * this._avAmt * s.eye + this._drift.x;
|
|
496
|
+
const avy = this._avVec.y * this._avAmt * s.eye + this._drift.y;
|
|
497
|
+
// The head's part of an aversion is a head movement like any other, so it
|
|
498
|
+
// gets the same ballistic launch and brake as the gaze follow — on its own
|
|
499
|
+
// follower, so the trunk (which tracks `this.head`) sits a look out.
|
|
500
|
+
const ahx = this._avVec.x * this._avWant * s.head;
|
|
501
|
+
const ahy = this._avVec.y * this._avWant * s.head;
|
|
502
|
+
chase(this._avHead, this._avVel, ahx, ahy, dt, this.headAccel, this.headSpeed);
|
|
503
|
+
chase(this.head, this.vel, this.target.hx, this.target.hy, dt, this.headAccel, this.headSpeed);
|
|
246
504
|
|
|
247
505
|
// Roll stays on the exponential: its travels are tiny (≤0.55 and usually
|
|
248
506
|
// ~0.08), far below where the drift tail is visible.
|
|
249
507
|
const k = 1 - Math.exp(-dt / HEAD_FOLLOW_TAU);
|
|
250
508
|
this.head.roll += ((this.target.roll || 0) - this.head.roll) * k;
|
|
251
509
|
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
// it: it never touches `this.head`, so it cannot disturb the braking model
|
|
255
|
-
// and cannot trip the large-shift blink. The head takes only a fraction of
|
|
256
|
-
// what the eyes take — a brief look-away is an eye movement that the head
|
|
257
|
-
// barely joins, and a head that follows it fully reads as turning away.
|
|
258
|
-
const HEAD_SHARE = 0.22;
|
|
510
|
+
const vx = this.vel.x + this._avVel.x;
|
|
511
|
+
const dip = HEAD_DIP * vx * vx / this.headAccel;
|
|
259
512
|
return {
|
|
260
513
|
pupilX: this.target.px + this.jitter.x + avx,
|
|
261
|
-
pupilY,
|
|
262
|
-
headYaw: this.head.x +
|
|
263
|
-
headPitch: this.head.y +
|
|
514
|
+
pupilY: this.target.py + this.jitter.y + avy,
|
|
515
|
+
headYaw: this.head.x + this._avHead.x + this._drift.x * DRIFT_HEAD,
|
|
516
|
+
headPitch: this.head.y + this._avHead.y + this._drift.y * DRIFT_HEAD + dip,
|
|
264
517
|
headRoll: this.head.roll,
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
518
|
+
// Where the head is *going*, without the looks that ride on it. The
|
|
519
|
+
// mixer turns the trunk toward this and not toward headYaw: an aversion
|
|
520
|
+
// is eyes and a little head, never a body turn.
|
|
521
|
+
trunkYaw: this.head.x,
|
|
522
|
+
// Where the head will be once it lands — the pose the eyes' target was
|
|
523
|
+
// authored against. The mixer's reflex holds the eyes on that target
|
|
524
|
+
// against every difference between this and the head actually drawn:
|
|
525
|
+
// the follow still under way, the dip, and everything above the gaze
|
|
526
|
+
// layer that moves the head.
|
|
527
|
+
aimYaw: this.target.hx + ahx + this._drift.x * DRIFT_HEAD,
|
|
528
|
+
aimPitch: this.target.hy + ahy + this._drift.y * DRIFT_HEAD,
|
|
268
529
|
};
|
|
269
530
|
}
|
|
270
531
|
}
|