@voqalize/avatar 0.2.2 → 0.3.0
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 +21 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
package/src/face-wren.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Avatar: "wren".
|
|
3
3
|
*
|
|
4
4
|
* The second line-art character, and the proof run for the recipe in
|
|
5
|
-
* docs/
|
|
5
|
+
* docs/authoring-a-face.md: static art + a POSE spec + feature blocks + META,
|
|
6
6
|
* with the stroke engine imported from line-art.js rather than carried along.
|
|
7
7
|
* Character: female, thirties, a big natural-curl mass, round glasses, a
|
|
8
8
|
* crew-neck tee. Same idiom as peep — no strokes anywhere, ink and paper and
|
|
@@ -30,10 +30,9 @@ import {
|
|
|
30
30
|
f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
|
|
31
31
|
} from './face-core.js';
|
|
32
32
|
import { taper, taperRing, region } from './line-art.js';
|
|
33
|
+
import { viewBoxForHead } from './camera.js';
|
|
33
34
|
|
|
34
35
|
export const THEME = {
|
|
35
|
-
bg0: '#f6f3ee',
|
|
36
|
-
bg1: '#e8e2d8',
|
|
37
36
|
ink: '#1b1b1b',
|
|
38
37
|
paper: '#ffffff',
|
|
39
38
|
accent: '#f97415',
|
|
@@ -42,10 +41,9 @@ export const THEME = {
|
|
|
42
41
|
tongue: '#8d7f79',
|
|
43
42
|
};
|
|
44
43
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const VB = { x: 92, y: 50, w: 576, h: 800 };
|
|
44
|
+
// Wren's hair cloud is her visible crown; the skull begins well below it.
|
|
45
|
+
const FRAME = { centerX: 380, crownY: 102, chinY: 575 };
|
|
46
|
+
const VB = viewBoxForHead(FRAME);
|
|
49
47
|
|
|
50
48
|
export const META = {
|
|
51
49
|
viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
|
|
@@ -53,7 +51,7 @@ export const META = {
|
|
|
53
51
|
};
|
|
54
52
|
|
|
55
53
|
// --- landmarks --------------------------------------------------------------
|
|
56
|
-
const CX =
|
|
54
|
+
const CX = FRAME.centerX;
|
|
57
55
|
const HEAD_TOP = 148;
|
|
58
56
|
const CHIN_Y = 572;
|
|
59
57
|
|
|
@@ -224,7 +222,7 @@ const POSE = {
|
|
|
224
222
|
// peep's breath and trunk numbers; same construction, same art units, and
|
|
225
223
|
// the two characters should breathe alike. See face-peep.js for the
|
|
226
224
|
// derivation.
|
|
227
|
-
breathSwell: 0.
|
|
225
|
+
breathSwell: 0.008, swellPivot: { x: CX, y: 950 },
|
|
228
226
|
turnPx: 16,
|
|
229
227
|
layers: LAYERS, parallax: PARALLAX,
|
|
230
228
|
torsoLayers: ['body'],
|
|
@@ -254,8 +252,8 @@ function mouthGeometry(p) {
|
|
|
254
252
|
const h = open * MOUTH_APERTURE;
|
|
255
253
|
const k = clamp(open / 0.18);
|
|
256
254
|
|
|
257
|
-
const yL = cy -
|
|
258
|
-
const yR = cy -
|
|
255
|
+
const yL = cy - 1.5 - p.mouthCornerL * 22;
|
|
256
|
+
const yR = cy - 1.5 - p.mouthCornerR * 22;
|
|
259
257
|
|
|
260
258
|
const apTop = cy - h * 0.25;
|
|
261
259
|
let apBot = cy + h * 0.75;
|
|
@@ -348,14 +346,7 @@ function markup(id, t) {
|
|
|
348
346
|
return `
|
|
349
347
|
<svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
|
|
350
348
|
preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
|
|
351
|
-
<defs>
|
|
352
|
-
<radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
|
|
353
|
-
<stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
|
|
354
|
-
</radialGradient>
|
|
355
|
-
<clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
|
|
356
|
-
</defs>
|
|
357
|
-
|
|
358
|
-
<rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
|
|
349
|
+
<defs><clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath></defs>
|
|
359
350
|
|
|
360
351
|
<!-- head and neck. No ears: the hair band covers them (see the hair note).
|
|
361
352
|
Hair underlay at head parallax, same insurance as peep's — the cloud
|
|
@@ -468,3 +459,10 @@ export function createFace(mount, theme = {}) {
|
|
|
468
459
|
|
|
469
460
|
return faceApi(mount, svg, apply, t);
|
|
470
461
|
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* This face as a **Face** record — `{ create, meta }`, the shape
|
|
465
|
+
* `createAvatar({ face })` takes. Importing it costs this drawing and nothing
|
|
466
|
+
* else; `src/faces.js` is the all-three table, for tooling.
|
|
467
|
+
*/
|
|
468
|
+
export const wren = { create: createFace, meta: META };
|
package/src/faces.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Face } from "./avatar.js";
|
|
2
|
+
|
|
3
|
+
export const peep: Face;
|
|
4
|
+
export const wren: Face;
|
|
5
|
+
export const myna: Face;
|
|
6
|
+
|
|
7
|
+
/** The faces we ship. Not an open vocabulary — a third face is a code change. */
|
|
8
|
+
export type FaceName = "peep" | "wren" | "myna";
|
|
9
|
+
|
|
10
|
+
export const FACES: Readonly<Record<FaceName, Face>>;
|
|
11
|
+
export const FACE_NAMES: readonly FaceName[];
|
|
12
|
+
export const DEFAULT_FACE: FaceName;
|
package/src/faces.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The faces we ship, as a table — for tooling that wants every one of them.
|
|
3
|
+
*
|
|
4
|
+
* **Importing this costs all three drawings** (~2k lines of path data). That is
|
|
5
|
+
* the right trade for `rig-check`, the contact sheets, the sweep and Studio,
|
|
6
|
+
* which exist to compare faces against each other. It is the wrong trade for an
|
|
7
|
+
* application, which renders one: those import a single face module and hand
|
|
8
|
+
* the record to `createAvatar` directly.
|
|
9
|
+
*
|
|
10
|
+
* import { peep } from '@voqalize/avatar/faces/peep';
|
|
11
|
+
* createAvatar({ mount, client, face: peep });
|
|
12
|
+
*
|
|
13
|
+
* A **Face** is `{ create, meta }` and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* `create` is `createFace(mount, theme) -> { svg, apply, theme, destroy }`,
|
|
16
|
+
* callable standalone — the rig tooling drives faces with no mixer attached.
|
|
17
|
+
* That behavioural contract is the whole of what the *rig* needs: everything
|
|
18
|
+
* else — visemes, emotions, gaze, idle, clips, the mixer — works in parameter
|
|
19
|
+
* space and never learns which face it is driving.
|
|
20
|
+
*
|
|
21
|
+
* `meta` is the descriptor (viewBox, mouthCrop — see META in any face module):
|
|
22
|
+
* the things a HOST or a TOOL needs to frame a face without opening it. A face
|
|
23
|
+
* was once a bare factory, on the argument that a schema guessed from two of
|
|
24
|
+
* them would be wrong; the third settled it. Every rig needed exactly a framing
|
|
25
|
+
* rect and a mouth rect to stop the tooling hard-coding per-face tables, and
|
|
26
|
+
* nothing else — so that is all meta carries.
|
|
27
|
+
*
|
|
28
|
+
* The key is the face's name, not its rank. It used to be possible to read rank
|
|
29
|
+
* into it — the original rig was keyed `default`, which became a lie the moment
|
|
30
|
+
* it stopped being the one we ship. `DEFAULT_FACE` is the only place the choice
|
|
31
|
+
* is made, and `packages/avatar/client/createAvatar.ts` is the only place it is consulted.
|
|
32
|
+
*
|
|
33
|
+
* Two earlier rigs, `classic` and `blue-shirt`, were removed on 2026-08-06:
|
|
34
|
+
* stakeholders accepted the line-art pair and rejected both of the others, so
|
|
35
|
+
* carrying them was maintenance against art nobody wanted. What they taught the
|
|
36
|
+
* abstraction survives them — `face-core.js` exists because all three of the
|
|
37
|
+
* first rigs wrote the same apply(), and META exists because all three needed
|
|
38
|
+
* the same two rects. Their code is in git history if a lesson ever needs
|
|
39
|
+
* re-reading.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { peep } from './face-peep.js';
|
|
43
|
+
import { wren } from './face-wren.js';
|
|
44
|
+
import { myna } from './face-myna.js';
|
|
45
|
+
|
|
46
|
+
export { peep, wren, myna };
|
|
47
|
+
|
|
48
|
+
export const FACES = { peep, wren, myna };
|
|
49
|
+
|
|
50
|
+
export const FACE_NAMES = Object.keys(FACES);
|
|
51
|
+
|
|
52
|
+
/** The face a host gets when it does not name one. */
|
|
53
|
+
export const DEFAULT_FACE = 'peep';
|
package/src/gaze.js
CHANGED
|
@@ -59,6 +59,52 @@ const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked bli
|
|
|
59
59
|
const HEAD_ACCEL = 4.0; // units/s² — sets both launch and braking firmness
|
|
60
60
|
const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach it
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Aversion profiles: a brief, deliberate break of eye contact that returns to
|
|
64
|
+
* whatever the gaze target already was.
|
|
65
|
+
*
|
|
66
|
+
* This exists because *holding* the user's eyes is not the attentive pose it
|
|
67
|
+
* looks like. Wang & Gratch (CHI 2010, n=133) ran the condition directly: a
|
|
68
|
+
* virtual listener that simply stares rated no better than one that visibly
|
|
69
|
+
* ignored the user (rapport 3.49 vs 3.34, n.s.), was rated the most *tense* of
|
|
70
|
+
* the three conditions, and raised the speaker's own disfluency rate to
|
|
71
|
+
* 36.75/min against 22.44 for a responsive listener. All three were rated
|
|
72
|
+
* equally natural, so it is not an animation-quality artefact. Rossano supplies
|
|
73
|
+
* the mechanism — sustained mutual gaze is a *demand for more talk*, not a
|
|
74
|
+
* signal of attention (95% of sequences expanded when both parties kept looking;
|
|
75
|
+
* 84% closed when both withdrew) — and Binetti (N=498) puts preferred mutual
|
|
76
|
+
* gaze at 3295 ± 706 ms, which is well short of forever.
|
|
77
|
+
*
|
|
78
|
+
* The other wall is just as hard: sustained *aversion* is an ostracism cue, and
|
|
79
|
+
* measured on an animated character (Chotpitayasunondh & Douglas, N=128) it
|
|
80
|
+
* costs η²ₚ = .52–.56 with post-hoc d of 1.09–2.69, with partial inattention
|
|
81
|
+
* costing most of what total inattention costs. So this is not "look away
|
|
82
|
+
* sometimes" — it is a narrow band, and the numbers below are Andrist's measured
|
|
83
|
+
* ones rather than a taste call (docs/research-biomechanics.md §4.2). The spike
|
|
84
|
+
* that arrived at this band, and what it measured on peep, is
|
|
85
|
+
* docs/research-active-listening.md.
|
|
86
|
+
*
|
|
87
|
+
* `every`/`dur` are seconds. `dirs` are unit-ish directions weighted by how
|
|
88
|
+
* often each is taken; magnitude is scaled per-fire.
|
|
89
|
+
*/
|
|
90
|
+
export const AVERSION = {
|
|
91
|
+
// While listening: 1.14 s (SD 0.27) every 7.21 s (SD 1.88), 57.5% sideways.
|
|
92
|
+
// Sideways dominates because down reads as submission and up reads as
|
|
93
|
+
// exasperation on a face this schematic.
|
|
94
|
+
LISTEN: {
|
|
95
|
+
every: [5.3, 9.1],
|
|
96
|
+
dur: [0.85, 1.45],
|
|
97
|
+
mag: [0.30, 0.44],
|
|
98
|
+
dirs: [[-1, 0.06], [-1, 0.06], [1, 0.02], [1, 0.02], [-0.7, -0.5], [0.5, 0.35]],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
// There is deliberately no THINK profile. The *cognitive* aversion — 3.54 s
|
|
102
|
+
// (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 already renders it through
|
|
104
|
+
// `wander`, which moves the whole gaze target rather than nudging off it. Two
|
|
105
|
+
// mechanisms producing the same look would fight; the state that thinks looks
|
|
106
|
+
// away properly, and this profile is for the state that must not.
|
|
107
|
+
|
|
62
108
|
export class GazeLayer {
|
|
63
109
|
constructor() {
|
|
64
110
|
this.target = GAZE_TARGETS.USER;
|
|
@@ -68,8 +114,67 @@ export class GazeLayer {
|
|
|
68
114
|
this.onLargeShift = null;
|
|
69
115
|
this.jitter = { x: 0, y: 0 };
|
|
70
116
|
this._nextMicro = 0;
|
|
117
|
+
this._t = 0;
|
|
118
|
+
// --- aversion scheduler
|
|
119
|
+
this.aversion = null; // one of AVERSION, or null for none
|
|
120
|
+
/** Set true when eye contact must be held: the floor is about to change
|
|
121
|
+
* hands. Andrist prohibits intimacy-regulating aversions near utterance
|
|
122
|
+
* end for exactly this reason — the floor is passed with mutual gaze, and
|
|
123
|
+
* an avatar that looks away as the user finishes has just declined it. */
|
|
124
|
+
this.hold = false;
|
|
125
|
+
this._avNext = 0;
|
|
126
|
+
this._avUntil = 0;
|
|
127
|
+
this._avVec = { x: 0, y: 0 };
|
|
128
|
+
this._avAmt = 0; // glided 0..1 so the return is a movement, not a cut
|
|
129
|
+
this._avProfileRef = undefined;
|
|
71
130
|
}
|
|
72
131
|
|
|
132
|
+
/** Adopt an aversion profile (or null). Cheap to call every frame. */
|
|
133
|
+
setAversion(p) {
|
|
134
|
+
if (p === this._avProfileRef) return;
|
|
135
|
+
this._avProfileRef = p;
|
|
136
|
+
this.aversion = p || null;
|
|
137
|
+
// Re-arm rather than inherit: a state that averts must not fire the instant
|
|
138
|
+
// it is entered off a stale timestamp from one that didn't.
|
|
139
|
+
this._avNext = this._t + (p ? p.every[0] + Math.random() * (p.every[1] - p.every[0]) : 0);
|
|
140
|
+
this._avUntil = 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_avert(t, dt) {
|
|
144
|
+
const p = this.aversion;
|
|
145
|
+
if (!p) {
|
|
146
|
+
// Glide home even after the profile is gone, so a state change mid-look
|
|
147
|
+
// returns the eyes instead of snapping them.
|
|
148
|
+
this._avAmt = Math.max(0, this._avAmt - dt / 0.18);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (this._avUntil && t >= this._avUntil) {
|
|
152
|
+
this._avUntil = 0;
|
|
153
|
+
this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
|
|
154
|
+
} else if (!this._avUntil && t >= this._avNext && !this.hold) {
|
|
155
|
+
this._avUntil = t + p.dur[0] + Math.random() * (p.dur[1] - p.dur[0]);
|
|
156
|
+
const d = p.dirs[(Math.random() * p.dirs.length) | 0];
|
|
157
|
+
const m = p.mag[0] + Math.random() * (p.mag[1] - p.mag[0]);
|
|
158
|
+
this._avVec.x = d[0] * m;
|
|
159
|
+
this._avVec.y = d[1] * m;
|
|
160
|
+
}
|
|
161
|
+
// `hold` cancels an aversion already running, it does not merely postpone
|
|
162
|
+
// the next: the turn can end mid-look, and the eyes have to be back.
|
|
163
|
+
const want = this._avUntil && !this.hold ? 1 : 0;
|
|
164
|
+
// Out fast, back slightly slower. A saccade away is ballistic; the return
|
|
165
|
+
// to a face is a fraction more deliberate, and symmetric timing here is one
|
|
166
|
+
// of the things that makes a rig read as a metronome.
|
|
167
|
+
const rate = want ? dt / 0.055 : dt / 0.11;
|
|
168
|
+
this._avAmt = want
|
|
169
|
+
? Math.min(1, this._avAmt + rate)
|
|
170
|
+
: Math.max(0, this._avAmt - rate);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** How far off-target the eyes currently are, 0..1. The mixer reads this to
|
|
174
|
+
* keep the trunk out of it — an aversion is eyes and a little head, never a
|
|
175
|
+
* body turn. */
|
|
176
|
+
get averted() { return this._avAmt; }
|
|
177
|
+
|
|
73
178
|
/**
|
|
74
179
|
* @param {string} name one of GAZE_NAMES
|
|
75
180
|
* @param {{x:number,y:number}} [override] normalized -1..1 escape hatch for
|
|
@@ -101,7 +206,11 @@ export class GazeLayer {
|
|
|
101
206
|
}
|
|
102
207
|
|
|
103
208
|
update(t, dt) {
|
|
209
|
+
this._t = t;
|
|
104
210
|
this._micro(t, dt);
|
|
211
|
+
this._avert(t, dt);
|
|
212
|
+
const avx = this._avVec.x * this._avAmt;
|
|
213
|
+
const avy = this._avVec.y * this._avAmt;
|
|
105
214
|
|
|
106
215
|
// Ballistic head follow: steer velocity toward "full speed at the target,
|
|
107
216
|
// but never faster than can still brake to a stop within the distance
|
|
@@ -140,12 +249,18 @@ export class GazeLayer {
|
|
|
140
249
|
const k = 1 - Math.exp(-dt / HEAD_FOLLOW_TAU);
|
|
141
250
|
this.head.roll += ((this.target.roll || 0) - this.head.roll) * k;
|
|
142
251
|
|
|
143
|
-
const pupilY = this.target.py + this.jitter.y;
|
|
252
|
+
const pupilY = this.target.py + this.jitter.y + avy;
|
|
253
|
+
// The aversion rides *on top of* the ballistic follow rather than through
|
|
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;
|
|
144
259
|
return {
|
|
145
|
-
pupilX: this.target.px + this.jitter.x,
|
|
260
|
+
pupilX: this.target.px + this.jitter.x + avx,
|
|
146
261
|
pupilY,
|
|
147
|
-
headYaw: this.head.x,
|
|
148
|
-
headPitch: this.head.y,
|
|
262
|
+
headYaw: this.head.x + avx * HEAD_SHARE,
|
|
263
|
+
headPitch: this.head.y + avy * HEAD_SHARE,
|
|
149
264
|
headRoll: this.head.roll,
|
|
150
265
|
// The upper lid tracks the eye vertically. Without this, looking down
|
|
151
266
|
// exposes a band of sclera above the iris and the avatar looks startled.
|