@voqalize/avatar 0.4.2 → 0.4.3
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-APACHE-2.0 +201 -0
- package/README.md +11 -98
- package/assets/README.md +28 -17
- package/assets/tanvi.glb +0 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tess.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/internal.ts +4 -0
- package/client/supports.ts +6 -7
- package/client/three/assets.ts +2 -0
- package/client/three/budgets.ts +1 -1
- package/client/three/{tara-rig.ts → character-rig.ts} +673 -545
- package/client/three/createCharacter.ts +109 -0
- package/client/three/holds.ts +1 -1
- package/client/three/internal.ts +4 -3
- package/client/three/motion-limits.json +5 -1
- package/client/three/tanvi-asset.ts +6 -0
- package/client/three/tanvi.ts +30 -0
- package/client/three/tanya.ts +18 -58
- package/client/three/tara.ts +19 -60
- package/client/three/tess.ts +17 -59
- package/client/three/tushar.ts +18 -55
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +5 -1
- package/dist/internal.js.map +1 -1
- package/dist/supports.d.ts +6 -7
- package/dist/supports.d.ts.map +1 -1
- package/dist/supports.js +6 -7
- package/dist/supports.js.map +1 -1
- package/dist/three/assets.d.ts +1 -0
- package/dist/three/assets.d.ts.map +1 -1
- package/dist/three/assets.js +2 -0
- package/dist/three/assets.js.map +1 -1
- package/dist/three/budgets.d.ts +1 -1
- package/dist/three/budgets.js +1 -1
- package/dist/three/character-rig.d.ts +366 -0
- package/dist/three/character-rig.d.ts.map +1 -0
- package/dist/three/{tara-rig.js → character-rig.js} +629 -523
- package/dist/three/character-rig.js.map +1 -0
- package/dist/three/createCharacter.d.ts +60 -0
- package/dist/three/createCharacter.d.ts.map +1 -0
- package/dist/three/createCharacter.js +84 -0
- package/dist/three/createCharacter.js.map +1 -0
- package/dist/three/holds.js +1 -1
- package/dist/three/holds.js.map +1 -1
- package/dist/three/internal.d.ts +3 -3
- package/dist/three/internal.d.ts.map +1 -1
- package/dist/three/internal.js +2 -2
- package/dist/three/internal.js.map +1 -1
- package/dist/three/motion-limits.json +5 -1
- package/dist/three/tanvi-asset.d.ts +7 -0
- package/dist/three/tanvi-asset.d.ts.map +1 -0
- package/dist/three/tanvi-asset.js +7 -0
- package/dist/three/tanvi-asset.js.map +1 -0
- package/dist/three/tanvi.d.ts +24 -0
- package/dist/three/tanvi.d.ts.map +1 -0
- package/dist/three/tanvi.js +24 -0
- package/dist/three/tanvi.js.map +1 -0
- package/dist/three/tanya.d.ts +16 -28
- package/dist/three/tanya.d.ts.map +1 -1
- package/dist/three/tanya.js +15 -43
- package/dist/three/tanya.js.map +1 -1
- package/dist/three/tara.d.ts +17 -22
- package/dist/three/tara.d.ts.map +1 -1
- package/dist/three/tara.js +16 -45
- package/dist/three/tara.js.map +1 -1
- package/dist/three/tess.d.ts +15 -29
- package/dist/three/tess.d.ts.map +1 -1
- package/dist/three/tess.js +14 -44
- package/dist/three/tess.js.map +1 -1
- package/dist/three/tushar.d.ts +16 -25
- package/dist/three/tushar.d.ts.map +1 -1
- package/dist/three/tushar.js +15 -40
- package/dist/three/tushar.js.map +1 -1
- package/package.json +7 -50
- package/src/avatar.d.ts +2 -0
- package/src/avatar.js +87 -216
- package/src/gaze.js +1 -3
- package/src/idle.js +10 -2
- package/src/prosody.js +7 -3
- package/src/speech-timing.js +9 -0
- package/src/visemes.js +107 -13
- package/client/arjun.ts +0 -26
- package/client/createCanvasAvatar.ts +0 -72
- package/client/interviewer-female.ts +0 -4
- package/client/interviewer-male.ts +0 -4
- package/client/ishita.ts +0 -26
- package/client/kabir.ts +0 -26
- package/client/meera.ts +0 -26
- package/client/naina.ts +0 -26
- package/client/professional-female-a.ts +0 -4
- package/client/professional-female-b.ts +0 -4
- package/client/professional-male-a.ts +0 -4
- package/client/professional-male-b.ts +0 -4
- package/client/vikram.ts +0 -26
- package/dist/arjun.d.ts +0 -7
- package/dist/arjun.d.ts.map +0 -1
- package/dist/arjun.js +0 -20
- package/dist/arjun.js.map +0 -1
- package/dist/createCanvasAvatar.d.ts +0 -22
- package/dist/createCanvasAvatar.d.ts.map +0 -1
- package/dist/createCanvasAvatar.js +0 -47
- package/dist/createCanvasAvatar.js.map +0 -1
- package/dist/interviewer-female.d.ts +0 -4
- package/dist/interviewer-female.d.ts.map +0 -1
- package/dist/interviewer-female.js +0 -3
- package/dist/interviewer-female.js.map +0 -1
- package/dist/interviewer-male.d.ts +0 -4
- package/dist/interviewer-male.d.ts.map +0 -1
- package/dist/interviewer-male.js +0 -3
- package/dist/interviewer-male.js.map +0 -1
- package/dist/ishita.d.ts +0 -7
- package/dist/ishita.d.ts.map +0 -1
- package/dist/ishita.js +0 -20
- package/dist/ishita.js.map +0 -1
- package/dist/kabir.d.ts +0 -7
- package/dist/kabir.d.ts.map +0 -1
- package/dist/kabir.js +0 -20
- package/dist/kabir.js.map +0 -1
- package/dist/meera.d.ts +0 -7
- package/dist/meera.d.ts.map +0 -1
- package/dist/meera.js +0 -20
- package/dist/meera.js.map +0 -1
- package/dist/naina.d.ts +0 -7
- package/dist/naina.d.ts.map +0 -1
- package/dist/naina.js +0 -20
- package/dist/naina.js.map +0 -1
- package/dist/professional-female-a.d.ts +0 -4
- package/dist/professional-female-a.d.ts.map +0 -1
- package/dist/professional-female-a.js +0 -3
- package/dist/professional-female-a.js.map +0 -1
- package/dist/professional-female-b.d.ts +0 -4
- package/dist/professional-female-b.d.ts.map +0 -1
- package/dist/professional-female-b.js +0 -3
- package/dist/professional-female-b.js.map +0 -1
- package/dist/professional-male-a.d.ts +0 -4
- package/dist/professional-male-a.d.ts.map +0 -1
- package/dist/professional-male-a.js +0 -3
- package/dist/professional-male-a.js.map +0 -1
- package/dist/professional-male-b.d.ts +0 -4
- package/dist/professional-male-b.d.ts.map +0 -1
- package/dist/professional-male-b.js +0 -3
- package/dist/professional-male-b.js.map +0 -1
- package/dist/three/tara-rig.d.ts +0 -423
- package/dist/three/tara-rig.d.ts.map +0 -1
- package/dist/three/tara-rig.js.map +0 -1
- package/dist/vikram.d.ts +0 -7
- package/dist/vikram.d.ts.map +0 -1
- package/dist/vikram.js +0 -20
- package/dist/vikram.js.map +0 -1
- package/src/canvas/author/parts/eye.mjs +0 -722
- package/src/canvas/author/parts/hand.mjs +0 -1156
- package/src/canvas/author/parts/mouth.mjs +0 -741
- package/src/canvas/author/parts/nose.mjs +0 -100
- package/src/canvas/author/parts/skin-detail.mjs +0 -67
- package/src/canvas/author/path.mjs +0 -283
- package/src/canvas/author/rig.mjs +0 -405
- package/src/canvas/avatars/round/face.d.mts +0 -3
- package/src/canvas/avatars/round/face.mjs +0 -1307
- package/src/canvas/create-rig.d.ts +0 -15
- package/src/canvas/create-rig.js +0 -100
- 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 +0 -1
- package/src/canvas/data/interviewer-male.rig.json +0 -1
- package/src/canvas/data/professional-female-a.rig.json +0 -1
- package/src/canvas/data/professional-female-b.rig.json +0 -1
- package/src/canvas/data/professional-male-a.rig.json +0 -1
- package/src/canvas/data/professional-male-b.rig.json +0 -1
- package/src/canvas/src/live.js +0 -508
- package/src/canvas/src/render2d.js +0 -218
- package/src/canvas/src/rig.js +0 -297
- package/src/canvas/src/vocab.js +0 -96
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* over
|
|
2
|
+
* The Blender characters' renderer: the `AvatarRig` contract (`apply(frame)` /
|
|
3
|
+
* `destroy()`) over a Blender-authored GLB. One of these drives every compiled
|
|
4
|
+
* character, and `createCharacter.ts` is what hands it one.
|
|
4
5
|
*
|
|
5
6
|
* The whole file is one idea — **the pose channel is the interface, and every
|
|
6
7
|
* mapping here is a translation of one channel into the one control that
|
|
7
8
|
* renders it.** `scripts/morphs.py` authored the shape keys under the library's
|
|
8
9
|
* own channel names precisely so this file never has to interpret a viseme, a
|
|
9
10
|
* state or an emotion; it receives a fully mixed pose and moves geometry.
|
|
10
|
-
*
|
|
11
|
-
* Three kinds of control, in the order they appear below:
|
|
12
|
-
*
|
|
13
|
-
* morph targets the face itself — lips, jaw, lids, brows, and the mouth
|
|
14
|
-
* interior that has to choreograph with them
|
|
15
|
-
* head group `headYaw` / `headPitch` / `headRoll`, as a rotation of the
|
|
16
|
-
* parts that ride the skull about the jaw-angle pivot
|
|
17
|
-
* eye globes `pupilX` / `pupilY`, as a rotation of the eyeball, because
|
|
18
|
-
* the iris is painted onto a sphere and cannot slide
|
|
19
|
-
*
|
|
20
|
-
* and a fourth, for the body: `shoulderL/R` are morph targets on the torso
|
|
21
|
-
* shell like any face channel, and `breath`, `torsoLean` and `torsoTurn` are
|
|
22
|
-
* each one transform of a group — a swell, a scale, a sway (see `BODY`).
|
|
23
|
-
*
|
|
24
|
-
* An asset may add a fifth: expression maps, which change the face's *light*
|
|
25
|
-
* where a smile or a raised brow would, because moving the geometry cannot
|
|
26
|
-
* (see `expressive`).
|
|
27
11
|
*/
|
|
28
12
|
import { REST } from "../internal.js";
|
|
29
13
|
import * as THREE from "three";
|
|
@@ -36,94 +20,45 @@ import { HARD_BUDGET, pixelRatioFor } from "./budgets.js";
|
|
|
36
20
|
* depth the triangle carrying it has. A perspective camera disagrees by
|
|
37
21
|
* (offset from the axis) x (depth / distance) — which put the hair rim 3 px
|
|
38
22
|
* above the hairline it is textured to and opened a black band across the
|
|
39
|
-
* forehead. `
|
|
23
|
+
* forehead. `build_character.setup_scene` has the measurement.
|
|
40
24
|
*/
|
|
41
25
|
const FRAME = { bottom: -0.52, top: 1.46 };
|
|
42
26
|
const FRAME_HEIGHT = FRAME.top - FRAME.bottom;
|
|
43
27
|
const FRAME_CENTRE = (FRAME.top + FRAME.bottom) / 2;
|
|
44
28
|
/**
|
|
45
|
-
* Head motion, in degrees at the channel's own clamp
|
|
46
|
-
*
|
|
47
|
-
* These are the program's *working* envelope, mapped so that a channel pinned to
|
|
48
|
-
* its limit lands exactly on it. That is the point of scaling by the clamp
|
|
49
|
-
* rather than by 1: the mixer cannot ask for more than the envelope allows, and
|
|
50
|
-
* the hard ceiling stays unreachable by construction instead of by a second
|
|
51
|
-
* clamp nobody runs.
|
|
29
|
+
* Head motion, in degrees at the channel's own clamp, so the envelope is
|
|
30
|
+
* unreachable by construction rather than by a second clamp nobody runs.
|
|
52
31
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* (`NECK_QUAD` below) pitch could open to 24 and roll to 8.
|
|
32
|
+
* Yaw is the tight axis: the albedo is a front-orthographic projection of a
|
|
33
|
+
* shallow shell, and a large turn is where that reads as a cardboard cutout
|
|
34
|
+
* rather than a head. A ladder rendered at 9/12/15/18/21/25/30 and read at crop
|
|
35
|
+
* is clean to 21° on tara and to 18° on the tightest of the others, so 15°
|
|
36
|
+
* keeps headroom; pitch and roll opened once `NECK_QUAD` made the neck's follow
|
|
37
|
+
* exact. How far a pose may be *held* is a stricter question, measured per
|
|
38
|
+
* character (`motion-limits.json`, applied through `holds.ts`).
|
|
61
39
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* a head. **It opened from 9° to 15° on 2026-09-18.** The cutout was measured
|
|
66
|
-
* rather than assumed — a ladder rendered at 9/12/15/18/21/25/30 and read at
|
|
67
|
-
* crop on the characters that existed then is clean to 21° on tara and to 18°
|
|
68
|
-
* on tanya and tushar. 9° was therefore set at half of where the artefact
|
|
69
|
-
* actually begins, and the stiffness the owner reported on tanya's turns was
|
|
70
|
-
* that margin, not her asset. 15° keeps 3° of headroom under the 18° the
|
|
71
|
-
* tightest of them read.
|
|
40
|
+
* The yaw twist's two fields are an expansion in the angle, so their error
|
|
41
|
+
* grows as θ²/6 — 0.85 % at 15°, on a displacement that is itself a fraction of
|
|
42
|
+
* the neck's radius (`morphs.neck_twist`).
|
|
72
43
|
*
|
|
73
|
-
*
|
|
74
|
-
* always did. It is for a head that turns to *look* at something, which is what
|
|
75
|
-
* mocap drives and what pegged the channel — a real 25° turn still saturates at
|
|
76
|
-
* 15°, so this widens the envelope without making it generous.
|
|
77
|
-
*
|
|
78
|
-
* And it is not the angle a pose may be *held* at, which is a stricter question
|
|
79
|
-
* with its own measurement per character (`motion-limits.json`, applied through
|
|
80
|
-
* `holds.ts`): a turn that returns is forgiven what a sustained one is not. The
|
|
81
|
-
* two numbers differ by about 3x on yaw and neither is a correction of the
|
|
82
|
-
* other.
|
|
83
|
-
*
|
|
84
|
-
* **Editing these needs no rebuild, but it is not free.** The neck's fields
|
|
85
|
-
* carry no angle, so `tara.glb` cannot go stale against them. What a number
|
|
86
|
-
* here does move:
|
|
87
|
-
*
|
|
88
|
-
* - Every clip is authored in channel units, so a degree here re-sizes every
|
|
89
|
-
* clip driving that axis. `test/nods.test.ts` bands *pitch* only — `down`,
|
|
90
|
-
* `up`, `upFirst` — and computes `yawPP` without ever asserting it. A yaw
|
|
91
|
-
* change moves nothing there; a pitch change moves four tests.
|
|
92
|
-
* - `head_parallax.py` and `validate_morphs.py` quote their gates at this
|
|
93
|
-
* envelope. `validate_morphs` reads `morphs.head_envelope()`, but
|
|
94
|
-
* `head_parallax.POSES` hardcoded `yaw 9` until 2026-09-18 and would have
|
|
95
|
-
* gone on grading 9° while the rig shipped 15° — a gate defending a number
|
|
96
|
-
* nothing used. It derives both angles from here now.
|
|
97
|
-
* - The yaw twist's two fields are an expansion in the angle, so their error
|
|
98
|
-
* grows as θ²/6. Against the exact rotation at the maximum ramp
|
|
99
|
-
* (`NECK_TWIST` = 0.5) that is 0.31 % at 9°, 0.85 % at 15°, 1.23 % at 18°
|
|
100
|
-
* (`morphs.neck_twist`). The note here used to read as a wall at 9°; it is
|
|
101
|
-
* not one — 15° costs under a percent of a displacement that is itself a
|
|
102
|
-
* fraction of the neck's radius.
|
|
103
|
-
*
|
|
104
|
-
* TARA-SPECIFIC: each number is her reach before an artefact shows — yaw by
|
|
105
|
-
* the cutout, pitch by the neck fold that starts to crease at 24° chin-up.
|
|
106
|
-
* Both were measured on the shipping surface, one axis at a time. This is still
|
|
107
|
-
* one shared pair of
|
|
108
|
-
* constants for all three characters, which holds only because 15° is inside
|
|
109
|
-
* every one of them; the first character that wants more than its neighbours
|
|
110
|
-
* forces the envelope onto `TaraRigOptions` as a per-character fact. A second
|
|
111
|
-
* avatar measures its own with the audit.
|
|
44
|
+
* TARA-SPECIFIC: reach before an artefact; see 3d-avatar-tara-specific.md.
|
|
112
45
|
*/
|
|
113
46
|
// Exported through `internal.ts` for the instruments that need to put a real
|
|
114
47
|
// angle *into* a channel, which is this scaling run backwards. The mocap
|
|
115
48
|
// instrument kept its own copy for want of that export and said in a comment
|
|
116
|
-
// that the copy would lie the day the envelope moved; it moved
|
|
49
|
+
// that the copy would lie the day the envelope moved; it moved.
|
|
117
50
|
export const HEAD_CLAMP = 1.4;
|
|
118
51
|
export const HEAD_DEG = { yaw: 15, pitch: 24, roll: 8 };
|
|
119
52
|
/**
|
|
120
|
-
* Where the head turns about
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
53
|
+
* Where the head turns about. v 0.36 is the jaw angle and the earlobe, and it
|
|
54
|
+
* sits a fifth of a face height *behind* the face plane — a pivot on the
|
|
55
|
+
* surface spins the face in place, where a real yaw swings the chin across as
|
|
56
|
+
* well as around, which is most of what makes a small turn read.
|
|
57
|
+
*
|
|
58
|
+
* The asset carries this (`stamp_abi`, as `head_pivot`); this is the fallback
|
|
59
|
+
* for a GLB built before the stamp.
|
|
125
60
|
*/
|
|
126
|
-
const PIVOT = new THREE.Vector3(0.0, 0.36, -0.22);
|
|
61
|
+
export const PIVOT = new THREE.Vector3(0.0, 0.36, -0.22);
|
|
127
62
|
/**
|
|
128
63
|
* Where the head *tilts* about, from `morphs.ROLL_PIVOT`: the midline just
|
|
129
64
|
* above the chin. A roll is a bend of the whole neck, so its centre is far
|
|
@@ -133,54 +68,48 @@ const PIVOT = new THREE.Vector3(0.0, 0.36, -0.22);
|
|
|
133
68
|
* pendulum hung from the ears.
|
|
134
69
|
*
|
|
135
70
|
* It sits inside the yaw and pitch, so a turned head still tilts about its own
|
|
136
|
-
* chin.
|
|
137
|
-
*
|
|
71
|
+
* chin. The asset carries it (`stamp_abi`, as `roll_pivot`); this is the
|
|
72
|
+
* fallback for a GLB built before the stamp, and `morphs.ROLL_PIVOT` has what
|
|
73
|
+
* sets the height.
|
|
138
74
|
*/
|
|
139
|
-
const ROLL_PIVOT = new THREE.Vector3(0.0, 0.05, -0.22);
|
|
75
|
+
export const ROLL_PIVOT = new THREE.Vector3(0.0, 0.05, -0.22);
|
|
140
76
|
/**
|
|
141
|
-
* What the head takes with it, from `
|
|
142
|
-
* behind, and so does `Neck` — but the neck is not *static*: it
|
|
143
|
-
*
|
|
144
|
-
* full under the jaw to nothing at the collar (`morphs.neck_targets`). Pitch and
|
|
145
|
-
* roll are the same rotation this group gets; yaw is a twist about the neck's
|
|
146
|
-
* own axis at half the angle, which keeps the neck's outline where it is
|
|
147
|
-
* (`morphs.neck_twist`). They need no code here at all, which is the
|
|
148
|
-
* whole reason they are morphs: they are named for pose channels that rest at
|
|
149
|
-
* 0, so the loop below drives them like any other channel and the influence law
|
|
150
|
-
* hands them the raw pose value.
|
|
77
|
+
* What the head takes with it, from `build_character.HEAD_PARTS`. `Body` stays
|
|
78
|
+
* behind, and so does `Neck` — but the neck is not *static*: it follows the
|
|
79
|
+
* skull through morph targets of its own (`NECK_QUAD` below).
|
|
151
80
|
*
|
|
152
|
-
* Without
|
|
153
|
-
* moved, and the rim landed mid-neck as a second jawline — invisible at
|
|
154
|
-
* 400 × 300 tile, obvious at a 3× crop.
|
|
81
|
+
* Without that follow a turn dragged the skull's jaw rim across a throat that
|
|
82
|
+
* had not moved, and the rim landed mid-neck as a second jawline — invisible at
|
|
83
|
+
* the 400 × 300 tile, obvious at a 3× crop.
|
|
84
|
+
*
|
|
85
|
+
* The asset carries its own list (`stamp_abi`, as `head_parts`); this is the
|
|
86
|
+
* fallback for a GLB built before the stamp.
|
|
155
87
|
*/
|
|
156
|
-
const HEAD_PARTS = ["Head", "Ears", "Hair", "Eye_L", "Eye_R", "Cavity",
|
|
157
|
-
"Teeth_Upper", "Teeth_Lower", "Tongue"];
|
|
88
|
+
export const HEAD_PARTS = ["Head", "Ears", "Hair", "Eye_L", "Eye_R", "Cavity",
|
|
89
|
+
"Teeth_Upper", "Teeth_Lower", "Tongue", "HairLayer"];
|
|
90
|
+
/** One stamped vector, in glTF's frame already, or the rig's own fallback. */
|
|
91
|
+
function stampedVec(extras, key, fallback) {
|
|
92
|
+
const v = extras[key];
|
|
93
|
+
return Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number")
|
|
94
|
+
? new THREE.Vector3(v[0], v[1], v[2]) : fallback.clone();
|
|
95
|
+
}
|
|
158
96
|
/**
|
|
159
97
|
* The body, in face-space units (glTF y is face-space v) and degrees.
|
|
160
98
|
*
|
|
161
|
-
* Every mechanism is the SVG faces' (`face-core.poseTransforms`)
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* drawing cannot. Measured in a 30-second listening hold it moved the
|
|
167
|
-
* shoulders 2 px, under 1 % of the tile, and read as a still with a tremor:
|
|
168
|
-
* the head was moving more than the body carrying it. Anatomy is the floor,
|
|
169
|
-
* not a fraction of a cartoon — these now put a listening hold at 4-5 px at
|
|
170
|
-
* the shoulders, still slow, and still well under the 1.5 Hz ceiling.
|
|
99
|
+
* Every mechanism is the SVG faces' (`face-core.poseTransforms`), but anatomy
|
|
100
|
+
* is the floor here, not a fraction of a cartoon: at 0.6 of peep's travel a
|
|
101
|
+
* 30-second listening hold moved the shoulders 2 px and read as a still with a
|
|
102
|
+
* tremor — the head moving more than the body carrying it. These put that hold
|
|
103
|
+
* at 4-5 px, still slow, and still well under the 1.5 Hz ceiling.
|
|
171
104
|
*
|
|
172
105
|
* breath A swell, not a slide (`docs/research-biomechanics.md` §6.1): the
|
|
173
|
-
* torso scales about a point
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* ribs and clavicles lifting as much as the rib cage widening. The
|
|
181
|
-
* neck and head ride the lift at the collar, derived rather than tuned
|
|
182
|
-
* (peep's `neckLift`), so the neck cannot telescope: ~2.4 px, the
|
|
183
|
-
* 2-3 mm a seated head really moves with a breath.
|
|
106
|
+
* torso scales about a point below the frame, as peep's does about its
|
|
107
|
+
* hem. The rise is a little more than the widening because what a
|
|
108
|
+
* breath shows in this crop is the upper ribs and clavicles lifting as
|
|
109
|
+
* much as the rib cage widening. The neck and head ride the lift at the
|
|
110
|
+
* collar, derived rather than tuned (peep's `neckLift`), so the neck
|
|
111
|
+
* cannot telescope — the 2-3 mm a seated head really moves with a
|
|
112
|
+
* breath.
|
|
184
113
|
* lean `torsoLean` as a deformation of the trunk, on the shell itself
|
|
185
114
|
* (`morphs.torso_targets`) — hem pinned at the frame's lower edge, the
|
|
186
115
|
* shoulders spreading and tipping as they come nearer. Only the head's
|
|
@@ -188,18 +117,7 @@ const HEAD_PARTS = ["Head", "Ears", "Hair", "Eye_L", "Eye_R", "Cavity",
|
|
|
188
117
|
* head take a pure translation of `leanRide` and nothing else. That is
|
|
189
118
|
* Live2D's measured behaviour rather than a simplification — body angle
|
|
190
119
|
* moves every head part by 1.00 ± 0.02 and adds no differential motion
|
|
191
|
-
* inside the head (`docs/research-
|
|
192
|
-
*
|
|
193
|
-
* It replaced a uniform `figure.scale.setScalar()`, which was peep's
|
|
194
|
-
* `LEAN_SCALE` carried onto photographic geometry and, with the
|
|
195
|
-
* orthographic camera outside the group it scaled, was arithmetically a
|
|
196
|
-
* zoom: fit the displacement as a linear map and its singular values
|
|
197
|
-
* came back equal to three decimals with no residual, at every lean the
|
|
198
|
-
* mixer produces. What it looked like was the owner's report — the
|
|
199
|
-
* shoulders swelling and dropping in half a second. The crown travelled
|
|
200
|
-
* 4.56× what the eyes did, which is a head being scaled, not carried.
|
|
201
|
-
* A headless audit of what the crown travels against the eyes is that
|
|
202
|
-
* measurement, and its gates are what this change had to turn green.
|
|
120
|
+
* inside the head (`docs/research-head-rotation.md` § 3.1).
|
|
203
121
|
* sway `torsoTurn` as the seated body's inverted pendulum: the whole figure
|
|
204
122
|
* rolls about the hips, ~45 cm below the collar, so the trunk shifts
|
|
205
123
|
* sideways and tips by a fraction of a degree together. peep slides
|
|
@@ -223,6 +141,9 @@ const BODY = {
|
|
|
223
141
|
* plateaus `torsoLean`'s field at it above the collar, so the body's
|
|
224
142
|
* deformation and the head's transform are one number and meet without a
|
|
225
143
|
* seam — the same arrangement `lift.position.y` already has with the breath.
|
|
144
|
+
* A uniform scale of the whole figure in its place is a zoom and not a lean:
|
|
145
|
+
* the crown travels 4.56× what the eyes do, which is a head being scaled
|
|
146
|
+
* rather than carried.
|
|
226
147
|
*/
|
|
227
148
|
leanRide: 0.021,
|
|
228
149
|
hip: -2.9,
|
|
@@ -249,33 +170,27 @@ const EYE_TILE = 0.3;
|
|
|
249
170
|
/** Degrees per pose unit: the globe turns `pupil * GAZE_TRAVEL / GLOBE_RADIUS`
|
|
250
171
|
* radians, and the head reaches `HEAD_DEG` at the clamp.
|
|
251
172
|
*
|
|
252
|
-
* Exported through `internal.ts` for the same reason as the head envelope
|
|
253
|
-
* instrument that asks for "eyes on the camera through a head turn" is running
|
|
254
|
-
* this conversion backwards, and a second copy of it would be a second thing to
|
|
255
|
-
* update when the eye tile or the globe changes. */
|
|
173
|
+
* Exported through `internal.ts` for the same reason as the head envelope. */
|
|
256
174
|
export const EYE_DEG = { x: (GAZE_TRAVEL.x / GLOBE_RADIUS) * 180 / Math.PI, y: (GAZE_TRAVEL.y / GLOBE_RADIUS) * 180 / Math.PI };
|
|
257
175
|
const HEAD_UNIT_DEG = { x: HEAD_DEG.yaw / HEAD_CLAMP, y: HEAD_DEG.pitch / HEAD_CLAMP };
|
|
258
176
|
/**
|
|
259
|
-
* The mixer's per-rig calibration
|
|
260
|
-
* audit so both measure the same face. A pose unit is an angle here
|
|
261
|
-
* pixel count on an SVG face, so the speech layer's amplitudes are tuned
|
|
262
|
-
* rig rather than in the library
|
|
263
|
-
* motion against this face's own motion envelope.
|
|
177
|
+
* The mixer's per-rig calibration, passed by the character's entry point and by
|
|
178
|
+
* the motion audit so both measure the same face. A pose unit is an angle here
|
|
179
|
+
* and a pixel count on an SVG face, so the speech layer's amplitudes are tuned
|
|
180
|
+
* per rig rather than in the library.
|
|
264
181
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* Sparks; Pejsa & Andrist). The comment on each target is its world angle,
|
|
273
|
-
* x right and y down.
|
|
182
|
+
* The shared look table is drawn for a line face, whose pupils cross most of an
|
|
183
|
+
* eye, so it put every look in the eyes: a thinking look away was the iris
|
|
184
|
+
* parked in the corner of the socket for two thirds of the state, which is
|
|
185
|
+
* side-eye, not thought. Here the head carries about 60 % of a look and the
|
|
186
|
+
* eyes land a third of the way off centre, where a real eye-head shift leaves
|
|
187
|
+
* them (Freedman & Sparks; Pejsa & Andrist). The comment on each target is its
|
|
188
|
+
* world angle, x right and y down.
|
|
274
189
|
*
|
|
275
190
|
* vor Real gain in the light is close to 1. A little under leaves the
|
|
276
191
|
* head some say, so a nod carries the eyes a touch with it rather
|
|
277
|
-
* than pinning them to the lens. Vertically it is well under
|
|
278
|
-
*
|
|
192
|
+
* than pinning them to the lens. Vertically it is well under:
|
|
193
|
+
* her pitch is a shell tipping on a photograph and
|
|
279
194
|
* reads as a fraction of what it is, so the eyes' full answer to
|
|
280
195
|
* it read as the eyes moving on their own — at 0.8, THINKING's
|
|
281
196
|
* up-look rolled the iris to the lid with white beneath it, and
|
|
@@ -309,10 +224,9 @@ const HEAD_UNIT_DEG = { x: HEAD_DEG.yaw / HEAD_CLAMP, y: HEAD_DEG.pitch / HEAD_C
|
|
|
309
224
|
* does not have: the neck's outline holds under a twist by construction
|
|
310
225
|
* (`morphs.neck_twist`), so the trunk's sway is what is left moving it — a
|
|
311
226
|
* quarter to a third of it at the yaw peak in the recorded call, read as the
|
|
312
|
-
* neck sliding.
|
|
313
|
-
* starts from 0.3 and checks its own outline at crop.
|
|
227
|
+
* neck sliding.
|
|
314
228
|
*/
|
|
315
|
-
export const
|
|
229
|
+
export const CHARACTER_TUNING = {
|
|
316
230
|
prosodyHeadGain: 1.0, prosodyFaceGain: 1, saccadeGain: 2.4, aversionGain: 1.8,
|
|
317
231
|
trunkFollow: 0.3,
|
|
318
232
|
// **The speaking face's upper half, sized for a photograph.** A reviewer read
|
|
@@ -338,9 +252,6 @@ export const TARA_TUNING = {
|
|
|
338
252
|
// is being relaxed for holds `browInner` at 0.22 for the whole of `CANT_HEAR`
|
|
339
253
|
// and was praised for it. Still a transient on a 0.55 s envelope, never a
|
|
340
254
|
// held shape: the prohibition is on the hold, not the event.
|
|
341
|
-
//
|
|
342
|
-
// TARA-SPECIFIC, and fork debt: on a driver of her own these are three
|
|
343
|
-
// constants beside the research comment, not an option on a shared mixer.
|
|
344
255
|
brows: {
|
|
345
256
|
range: [-0.28, 0.24],
|
|
346
257
|
floor: 0.11,
|
|
@@ -357,8 +268,6 @@ export const TARA_TUNING = {
|
|
|
357
268
|
// closes them to a squint the owner read as straining at the screen, not
|
|
358
269
|
// working. Her reading scan carries the state instead: eyes off the user,
|
|
359
270
|
// stepping along a line, the way a person at their own display looks.
|
|
360
|
-
// TARA-SPECIFIC: a photographic face with a deeper lid crease may want
|
|
361
|
-
// some of the knit back; judge it at crop against LISTENING.
|
|
362
271
|
WORKING: {
|
|
363
272
|
pose: { headPitch: 0.04, lidL: -0.08, lidR: -0.08, shoulderL: 0.06, shoulderR: 0.06 },
|
|
364
273
|
},
|
|
@@ -370,23 +279,11 @@ export const TARA_TUNING = {
|
|
|
370
279
|
// which the shared comment already says of them. The brows keep a small
|
|
371
280
|
// knit with the inner ends up: effort that is also asking. The mouth is
|
|
372
281
|
// pressed at a photograph's scale; the shared -0.22 corners clear peep's
|
|
373
|
-
// drawn smile, and
|
|
374
|
-
// cheek push is what darkens, so a face built without one might keep a
|
|
375
|
-
// little squint — check the band under the eye at crop.
|
|
376
|
-
//
|
|
377
|
-
// The lean is attentive-sized, not the shared 0.70. Until 2026-09-16 her
|
|
378
|
-
// lean scaled the whole figure about mid-face (`BODY`), so 0.70 plus the
|
|
379
|
-
// engage add was a 4.4% zoom arriving on torsoLean's 0.24 s tau: the
|
|
380
|
-
// shoulders swelled and dropped in half a second, read by the owner as a
|
|
381
|
-
// lurch nothing like a lean. 0.22 sits in the research's sustained band
|
|
382
|
-
// (+0.15–0.25, research-biomechanics.md §6.3) and the ear and chin carry
|
|
383
|
-
// the rest.
|
|
282
|
+
// drawn smile, and a compiled face rests neutral.
|
|
384
283
|
//
|
|
385
|
-
// The lean
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
// judgement rather than a consequence of the field changing. TARA-SPECIFIC,
|
|
389
|
-
// and the thing to re-judge first if she reads as under-committed.
|
|
284
|
+
// The lean is attentive-sized, not the shared 0.70: 0.22 sits in the
|
|
285
|
+
// sustained band (+0.15–0.25, research-biomechanics.md §6.3), and the ear
|
|
286
|
+
// and chin carry the rest.
|
|
390
287
|
CANT_HEAR: {
|
|
391
288
|
pose: {
|
|
392
289
|
torsoLean: 0.22, headPitch: 0.10,
|
|
@@ -398,7 +295,7 @@ export const TARA_TUNING = {
|
|
|
398
295
|
// at 0.40 left the eyes half-lidded from below over a dark band. The hunt
|
|
399
296
|
// is the wander and the flick; the face only has to be not smiling, and
|
|
400
297
|
// on a mouth that rests neutral that is a small press, not peep's -0.25
|
|
401
|
-
// corners.
|
|
298
|
+
// corners.
|
|
402
299
|
SEARCHING_SCREEN: {
|
|
403
300
|
pose: { mouthPress: 0.40, mouthCornerL: -0.08, mouthCornerR: -0.08,
|
|
404
301
|
browRaiseL: -0.10, browRaiseR: -0.06 },
|
|
@@ -418,7 +315,6 @@ export const TARA_TUNING = {
|
|
|
418
315
|
// sat — hooded to a lid of 0.25-0.43 against listening's 0.14, read as
|
|
419
316
|
// a squint rather than reading. Here it holds 0.12-0.18 through the
|
|
420
317
|
// scan, and being off the user to the side is what says "busy".
|
|
421
|
-
// TARA-SPECIFIC: the depth that hoods is her lid crease's.
|
|
422
318
|
OWN_SCREEN: { px: 0.14, py: 0.24, hx: 0.10, hy: 0.06 },
|
|
423
319
|
// 2.6° of head turn toward the user's side and 3.1° of roll, the eyes
|
|
424
320
|
// countered 2.0° back onto them: the ear offered, contact held from
|
|
@@ -458,6 +354,14 @@ export const TARA_TUNING = {
|
|
|
458
354
|
},
|
|
459
355
|
},
|
|
460
356
|
};
|
|
357
|
+
/** How far a closing lid darkens the eye it covers, from where it starts to.
|
|
358
|
+
* The lid's margin and lashes throw the strip of white still showing into
|
|
359
|
+
* shadow; the atlas's socket shade was measured with the eye open and cannot
|
|
360
|
+
* know that, so without this the last frames before a blink closes show a
|
|
361
|
+
* bright line under a dark lash band — what a reviewer called the sclera
|
|
362
|
+
* tearing across the lid. */
|
|
363
|
+
const LID_SHADE = 0.55;
|
|
364
|
+
const LID_SHADE_FROM = 0.3;
|
|
461
365
|
/**
|
|
462
366
|
* The eye material, taught to hold its socket still while the globe turns.
|
|
463
367
|
*
|
|
@@ -471,7 +375,7 @@ export const TARA_TUNING = {
|
|
|
471
375
|
* plus the rotation's displacement through the UV's own gradient. One extra
|
|
472
376
|
* texture read on two small meshes; no pass, no draw call.
|
|
473
377
|
*/
|
|
474
|
-
function socketed(base, side, gaze, deep) {
|
|
378
|
+
function socketed(base, side, gaze, deep, lid) {
|
|
475
379
|
const material = base.clone();
|
|
476
380
|
// d(u)/dx and d(v)/dy of `head_mesh.eye_uvs`: the globe is half the atlas
|
|
477
381
|
// wide, the right eye reads it mirrored, and glTF flips v.
|
|
@@ -479,6 +383,7 @@ function socketed(base, side, gaze, deep) {
|
|
|
479
383
|
material.onBeforeCompile = (shader) => {
|
|
480
384
|
shader.uniforms.uGaze = gaze;
|
|
481
385
|
shader.uniforms.uSocket = socket;
|
|
386
|
+
shader.uniforms.uLidShade = lid;
|
|
482
387
|
// The globe's hidden skirt turns at the frame's motion depth like the skin
|
|
483
388
|
// that hides it; its visible cap carries a field of exactly zero, so the
|
|
484
389
|
// socket, the iris and gaze below are unaffected by this. It turns at the
|
|
@@ -491,151 +396,205 @@ function socketed(base, side, gaze, deep) {
|
|
|
491
396
|
.replace("#include <uv_vertex>", "#include <uv_vertex>\nvSocketUv = vMapUv + vec2(0.5, 0.0)"
|
|
492
397
|
+ " + uSocket * ((uGaze * position).xy - position.xy);");
|
|
493
398
|
shader.fragmentShader = shader.fragmentShader
|
|
494
|
-
.replace("#include <uv_pars_fragment>", "#include <uv_pars_fragment>\nvarying vec2 vSocketUv;")
|
|
495
|
-
.replace("#include <map_fragment>", "vec3 socket = 2.0 * texture2D( map, vSocketUv ).rgb;\n#include <map_fragment>\ndiffuseColor.rgb *= socket;")
|
|
399
|
+
.replace("#include <uv_pars_fragment>", "#include <uv_pars_fragment>\nvarying vec2 vSocketUv;\nuniform float uLidShade;")
|
|
400
|
+
.replace("#include <map_fragment>", "vec3 socket = uLidShade * 2.0 * texture2D( map, vSocketUv ).rgb;\n#include <map_fragment>\ndiffuseColor.rgb *= socket;")
|
|
496
401
|
.replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= socket;");
|
|
497
402
|
};
|
|
498
403
|
material.customProgramCacheKey = () => (deep ? "tara-eye-socket-deep" : "tara-eye-socket");
|
|
499
404
|
return material;
|
|
500
405
|
}
|
|
501
|
-
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
* Both were read off a ruler build that painted the normalised height into the
|
|
507
|
-
* emissive term, where the output bypasses the lamps and decodes straight back
|
|
508
|
-
* to the height that produced it. It found two things worth keeping.
|
|
509
|
-
*
|
|
510
|
-
* `seam` is the height a *closed* mouth shows: pixel-weighted 0.805 on tara and
|
|
511
|
-
* 0.808 on tushar, near enough identical to be one constant rather than a
|
|
512
|
-
* per-character tuning. It is deliberately the pivot — see `cavityShade`.
|
|
513
|
-
*
|
|
514
|
-
* `falloff` is sized against the band an *open* mouth exposes, and that band is
|
|
515
|
-
* why this is measured rather than guessed: it is 0.70..0.89, the top fifth of
|
|
516
|
-
* the patch, with nothing below it even at jaw 1 and mouthOpen 1 — the patch
|
|
517
|
-
* runs far past the aperture on purpose, so that its lower edge can chase the
|
|
518
|
-
* lip without ever reaching the chin (`build_tara`, the cavity's lower edge). A
|
|
519
|
-
* ramp laid across the whole patch would put a fifth of its range in the only
|
|
520
|
-
* part anyone sees, which is the mistake the lower arch's `floor` made one
|
|
521
|
-
* commit ago by being sized against its tile instead of its visible band.
|
|
406
|
+
/**
|
|
407
|
+
* tess's "ah" photograph, down the midline of her open mouth: the colour at
|
|
408
|
+
* each depth into the lip opening, 0 at the upper lip's inner rim and 1 at the
|
|
409
|
+
* lower's, in sRGB as the photograph has it. Read at MediaPipe's inner-lip
|
|
410
|
+
* points 13 and 14, which is the same rim `build_character.lip_aperture` stamps.
|
|
522
411
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
412
|
+
* The shape is the thing to keep, and every reviewer's "pink and flat" is its
|
|
413
|
+
* absence: dark in the shadow of the upper arch, rising to the tongue's front
|
|
414
|
+
* two-thirds of the way down, and falling again into the floor of the mouth
|
|
415
|
+
* above the lower arch. Where either arch covers a stretch of this, what the
|
|
416
|
+
* photograph measured there is enamel and was left out — the arches draw
|
|
417
|
+
* themselves.
|
|
526
418
|
*/
|
|
527
|
-
const
|
|
419
|
+
const INTERIOR = [
|
|
420
|
+
[0.15, [60, 22, 26]],
|
|
421
|
+
[0.30, [84, 35, 41]],
|
|
422
|
+
[0.45, [122, 62, 68]],
|
|
423
|
+
[0.60, [159, 87, 93]],
|
|
424
|
+
[0.70, [144, 60, 61]],
|
|
425
|
+
[0.80, [52, 17, 15]],
|
|
426
|
+
[0.90, [40, 12, 12]],
|
|
427
|
+
];
|
|
528
428
|
/**
|
|
529
|
-
* The
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
|
|
552
|
-
|
|
429
|
+
* The same photograph across the opening: the middle two-fifths hold the
|
|
430
|
+
* midline's colour and the rest falls to a fifth of it at the commissure — her
|
|
431
|
+
* 0.65 of enamel at the widest row is 0.44 halfway out and 0.1–0.2 at the
|
|
432
|
+
* corner. `from` is where the fall starts and `to` what is left at the corner,
|
|
433
|
+
* both as a share of the opening's half-width.
|
|
434
|
+
*/
|
|
435
|
+
const INTERIOR_RIM = { from: 0.4, to: 0.2 };
|
|
436
|
+
/**
|
|
437
|
+
* The photograph's enamel is 158 of 255 and the rendered upper arch's is about
|
|
438
|
+
* 232, so the profile is carried over at the ratio of the two: the interior is
|
|
439
|
+
* as dark *against the teeth* as hers is, which is the only comparison anyone
|
|
440
|
+
* makes looking into a mouth.
|
|
441
|
+
*/
|
|
442
|
+
const INTERIOR_GAIN = 232 / 158;
|
|
443
|
+
/**
|
|
444
|
+
* How open her mouth is in that photograph: 185 px between the inner rims at the
|
|
445
|
+
* midline over 229 between the inner corners (MediaPipe 13/14 and 78/308). A
|
|
446
|
+
* speaking mouth is rarely a third of that, and the light reaching into it
|
|
447
|
+
* falls with the opening, so the profile is dimmed by the square root of the
|
|
448
|
+
* ratio — between the solid angle's own square law, which turned every
|
|
449
|
+
* conversational viseme into a hole, and none at all, which left each one the
|
|
450
|
+
* lit pink band reviewers called flat.
|
|
451
|
+
*/
|
|
452
|
+
const INTERIOR_OPEN = 185 / 229;
|
|
453
|
+
/** That dimming, in GLSL, over the `uAperture` the mouth's shaders share. */
|
|
454
|
+
const OPENNESS = `sqrt(clamp(uAperture.w / (2.0 * uAperture.z * ${INTERIOR_OPEN.toFixed(3)}), 0.0, 1.0))`;
|
|
455
|
+
/**
|
|
456
|
+
* Where a raised tongue is read from: `tongue` = 1 paints the surface it lifts
|
|
457
|
+
* into the opening as if it were at this depth — the photograph's brightest
|
|
458
|
+
* row — rather than in the shadow of the upper arch it has moved into.
|
|
459
|
+
*/
|
|
460
|
+
const TONGUE_LIFT_AT = 0.60;
|
|
461
|
+
/** An `INTERIOR` knot as linear light, at `INTERIOR_GAIN`. */
|
|
462
|
+
function interiorKnot(rgb) {
|
|
463
|
+
return new THREE.Color().setRGB(...rgb.map((v) => Math.min(1, (v * INTERIOR_GAIN) / 255)), THREE.SRGBColorSpace);
|
|
464
|
+
}
|
|
465
|
+
const glslColor = (c) => `vec3(${c.r.toFixed(5)}, ${c.g.toFixed(5)}, ${c.b.toFixed(5)})`;
|
|
466
|
+
/** GLSL: `vec3 interior`, the profile at depth `mouthA` in the opening. */
|
|
467
|
+
function interiorProfile() {
|
|
468
|
+
let profile = `vec3 interior = ${glslColor(interiorKnot(INTERIOR[0][1]))};\n`;
|
|
469
|
+
for (let i = 1; i < INTERIOR.length; i++) {
|
|
470
|
+
const [a0] = INTERIOR[i - 1];
|
|
471
|
+
const [a1, rgb] = INTERIOR[i];
|
|
472
|
+
profile += `interior = mix(interior, ${glslColor(interiorKnot(rgb))}, clamp((mouthA - ${a0.toFixed(3)}) / ${(a1 - a0).toFixed(3)}, 0.0, 1.0));\n`;
|
|
473
|
+
}
|
|
474
|
+
return profile;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The inside of the mouth — the tongue and the cavity behind it — painted from
|
|
478
|
+
* a photograph of one, at where each point sits in the lip opening the pose
|
|
479
|
+
* has made.
|
|
553
480
|
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
481
|
+
* The opening, and not the surface, because that is how the photograph's
|
|
482
|
+
* profile arises: the light reaching into a mouth is gated by the lips and
|
|
483
|
+
* shadowed by the upper arch, so the same point of tongue is bright when the
|
|
484
|
+
* jaw drops and dark when it closes. A shade stuck to the surface — what this
|
|
485
|
+
* replaced — reads as a lit object behind a hole, which at viseme D was a flat
|
|
486
|
+
* mauve plate, and at C and H put the tongue's bright crest directly under the
|
|
487
|
+
* upper teeth with dark below it: the order of the photograph, inverted.
|
|
561
488
|
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
|
|
489
|
+
* Emitted rather than lit, and entirely: this is sampled light, like the 75%
|
|
490
|
+
* of the face that is the photograph verbatim, and a key from above lights the
|
|
491
|
+
* dorsum brightest at its back, which is the one thing a mouth never looks
|
|
492
|
+
* like. Both surfaces take the same profile, so her mouth's absence of any
|
|
493
|
+
* tongue-to-cavity edge carries over; only a raised tongue is told apart, by
|
|
494
|
+
* `lift`, because a tongue tip at the teeth is what viseme H is.
|
|
495
|
+
*/
|
|
496
|
+
function mouthInterior(base, aperture, lift) {
|
|
497
|
+
const material = base.clone();
|
|
498
|
+
const profile = interiorProfile();
|
|
499
|
+
material.onBeforeCompile = (shader) => {
|
|
500
|
+
shader.uniforms.uAperture = aperture;
|
|
501
|
+
shader.uniforms.uMouthLift = lift;
|
|
502
|
+
shader.vertexShader = shader.vertexShader
|
|
503
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;")
|
|
504
|
+
// `transformed` after the morphs, unlike every other shade in this file:
|
|
505
|
+
// where the surface *is* in the opening is the whole question.
|
|
506
|
+
.replace("#include <morphtarget_vertex>", "#include <morphtarget_vertex>\nvMouthAt = transformed.xy;");
|
|
507
|
+
shader.fragmentShader = shader.fragmentShader
|
|
508
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;\nuniform vec4 uAperture;\nuniform float uMouthLift;")
|
|
509
|
+
.replace("#include <map_fragment>", "#include <map_fragment>\ndiffuseColor.rgb = vec3(0.0);")
|
|
510
|
+
.replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\n"
|
|
511
|
+
+ "float mouthA = (uAperture.y - vMouthAt.y) / uAperture.w;\n"
|
|
512
|
+
+ `mouthA = mouthA < ${TONGUE_LIFT_AT.toFixed(2)} ? mix(mouthA, ${TONGUE_LIFT_AT.toFixed(2)}, uMouthLift) : mouthA;\n`
|
|
513
|
+
+ "float mouthS = abs(vMouthAt.x - uAperture.x) / uAperture.z;\n"
|
|
514
|
+
+ profile
|
|
515
|
+
+ `interior *= ${OPENNESS};\n`
|
|
516
|
+
+ `totalEmissiveRadiance = interior * mix(1.0, ${INTERIOR_RIM.to.toFixed(2)}, smoothstep(${INTERIOR_RIM.from.toFixed(2)}, 1.0, mouthS));`);
|
|
517
|
+
};
|
|
518
|
+
material.customProgramCacheKey = () => "mouth-interior";
|
|
519
|
+
return material;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The lower arch, lit by the light the tongue beside it is lit by.
|
|
569
523
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
* two lobes flanking the arch, where the aperture runs wider than the teeth do
|
|
578
|
-
* and so exposes cavity above the seam: −4 levels on tara, −5 on tushar. Those
|
|
579
|
-
* lobes are the commissures, and their being the deepest part of the mouth is
|
|
580
|
-
* right for a reason this shader did not plan — it falls out of a vertical ramp
|
|
581
|
-
* meeting a curved arch.
|
|
524
|
+
* Its tile is toned against the upper arch on a smile (`project_albedo`), a
|
|
525
|
+
* mouth open wide and pulled back, where the lower teeth take nearly the upper
|
|
526
|
+
* ones' light. Speaking, they are the deepest thing the opening shows, and
|
|
527
|
+
* tess's "ah" — open wider than any viseme — shows no lower crown at all: the
|
|
528
|
+
* row above where they would be is the darkest in the photograph. Left at the
|
|
529
|
+
* smile's tone, the sliver a C or D uncovers was a lit grey rule between that
|
|
530
|
+
* dark and the lip, which reads as a wire and not as teeth.
|
|
582
531
|
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* per-character fact, just not one of its own.
|
|
532
|
+
* So the arch takes the interior's light where it stands: the profile at its
|
|
533
|
+
* depth in the opening over the profile's brightest row, which is the tongue
|
|
534
|
+
* lit as well as anything in a mouth is, and the same openness dimming. The
|
|
535
|
+
* ratio is in linear light and per channel, so the enamel goes as dark and as
|
|
536
|
+
* warm as the mouth around it, and its top edge — higher in the opening —
|
|
537
|
+
* keeps the most. The upper arch, at the lip and in the light, keeps its own.
|
|
590
538
|
*/
|
|
591
|
-
function
|
|
539
|
+
function lowerArch(base, aperture) {
|
|
592
540
|
const material = base.clone();
|
|
593
|
-
const
|
|
594
|
-
const shade = {
|
|
595
|
-
value: new THREE.Vector4(CAVITY_SHADE.seam, CAVITY_SHADE.falloff, CAVITY_SHADE.min, CAVITY_SHADE.max),
|
|
596
|
-
};
|
|
541
|
+
const peak = interiorKnot(INTERIOR.reduce((a, b) => (b[1][0] + b[1][1] + b[1][2] > a[1][0] + a[1][1] + a[1][2] ? b : a))[1]);
|
|
597
542
|
material.onBeforeCompile = (shader) => {
|
|
598
|
-
shader.uniforms.
|
|
599
|
-
shader.uniforms.uCavityShade = shade;
|
|
543
|
+
shader.uniforms.uAperture = aperture;
|
|
600
544
|
shader.vertexShader = shader.vertexShader
|
|
601
|
-
.replace("#include <common>", "#include <common>\nvarying
|
|
602
|
-
|
|
603
|
-
// patch back and drops its lower edge as the jaw opens, and this shading
|
|
604
|
-
// is painted *on* the surface — so it has to ride that, not be swept
|
|
605
|
-
// across it. The raw attribute is the rest frame, before the morphs.
|
|
606
|
-
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvCavityAt = (position.y - uCavitySpan.x) / uCavitySpan.y;");
|
|
545
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;")
|
|
546
|
+
.replace("#include <morphtarget_vertex>", "#include <morphtarget_vertex>\nvMouthAt = transformed.xy;");
|
|
607
547
|
shader.fragmentShader = shader.fragmentShader
|
|
608
|
-
.replace("#include <common>", "#include <common>\nvarying
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
548
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;\nuniform vec4 uAperture;")
|
|
549
|
+
// Both terms: the teeth are three-quarters sampled light (`emissive`
|
|
550
|
+
// in the build's `flat_material`), so dimming the lit part alone does
|
|
551
|
+
// almost nothing.
|
|
612
552
|
.replace("#include <map_fragment>", "#include <map_fragment>\n"
|
|
613
|
-
+ "float
|
|
614
|
-
+
|
|
553
|
+
+ "float mouthA = (uAperture.y - vMouthAt.y) / uAperture.w;\n"
|
|
554
|
+
+ interiorProfile()
|
|
555
|
+
+ `vec3 archLight = min(interior / ${glslColor(peak)}, vec3(1.0)) * ${OPENNESS};\n`
|
|
556
|
+
+ "diffuseColor.rgb *= archLight;")
|
|
557
|
+
.replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= archLight;");
|
|
615
558
|
};
|
|
616
|
-
material.customProgramCacheKey = () => "
|
|
559
|
+
material.customProgramCacheKey = () => "lower-arch";
|
|
617
560
|
return material;
|
|
618
561
|
}
|
|
619
|
-
/** `scripts/head_mesh.MOTION_DEPTH_ATTR`, as GLTFLoader names it: lowercased. */
|
|
620
|
-
const MOTION_DEPTH = "_motion_depth";
|
|
621
562
|
/**
|
|
622
|
-
* The
|
|
623
|
-
*
|
|
624
|
-
* Rotated at their real depth, the ears, crown, side hair and outline move
|
|
625
|
-
* nearly as far as the nose, and a nod reads as the whole head dropping
|
|
626
|
-
* (docs/research-head-rotation.md § 1). A real head's frame sits near the axis
|
|
627
|
-
* and barely moves, and that differential is what reads as rotation. Each
|
|
628
|
-
* vertex of the skin, hair and ears carries a Δz toward the viewer
|
|
629
|
-
* (`head_mesh.motion_depth`, ≤ 0 and zero across the features), and this moves
|
|
630
|
-
* it on screen by the rotated Δz: the view-space xy of modelView · (0, 0, Δz).
|
|
631
|
-
* Depth, draw order and lighting keep the real position. At rest that xy is
|
|
632
|
-
* exactly zero through the orthographic camera, so the drawing is untouched.
|
|
563
|
+
* The upper arch, with the gaps between its teeth showing the mouth.
|
|
633
564
|
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
565
|
+
* `project_albedo.project_teeth` paints everything under the incisal edge
|
|
566
|
+
* that is not a tooth — the notches between the tips — in `notch`, a neutral
|
|
567
|
+
* near-black, because the tile cannot know what the mouth behind it will be.
|
|
568
|
+
* Rendered, that strip came out a hard grey saw along the bottom of the arch:
|
|
569
|
+
* neutral against a warm interior, and at the arch's light rather than the
|
|
570
|
+
* mouth's. A gap in a row of teeth is a window onto the cavity, so it takes
|
|
571
|
+
* the cavity's own light at that height in the opening, and only the gap does
|
|
572
|
+
* — `notch` is darker than any enamel the tile carries, shaded overhang
|
|
573
|
+
* included, so the test is the texel's own brightness, and a filtered texel
|
|
574
|
+
* on a tip's edge takes a share of each.
|
|
638
575
|
*/
|
|
576
|
+
function upperArch(base, aperture) {
|
|
577
|
+
const material = base.clone();
|
|
578
|
+
material.onBeforeCompile = (shader) => {
|
|
579
|
+
shader.uniforms.uAperture = aperture;
|
|
580
|
+
shader.vertexShader = shader.vertexShader
|
|
581
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;")
|
|
582
|
+
.replace("#include <morphtarget_vertex>", "#include <morphtarget_vertex>\nvMouthAt = transformed.xy;");
|
|
583
|
+
shader.fragmentShader = shader.fragmentShader
|
|
584
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;\nuniform vec4 uAperture;")
|
|
585
|
+
.replace("#include <map_fragment>", "#include <map_fragment>\n"
|
|
586
|
+
+ "float archGap = 1.0 - smoothstep(0.01, 0.30, dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)));\n"
|
|
587
|
+
+ "diffuseColor.rgb *= 1.0 - archGap;")
|
|
588
|
+
.replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\n"
|
|
589
|
+
+ "float mouthA = (uAperture.y - vMouthAt.y) / uAperture.w;\n"
|
|
590
|
+
+ interiorProfile()
|
|
591
|
+
+ `totalEmissiveRadiance = mix(totalEmissiveRadiance, interior * ${OPENNESS}, archGap);`);
|
|
592
|
+
};
|
|
593
|
+
material.customProgramCacheKey = () => "upper-arch";
|
|
594
|
+
return material;
|
|
595
|
+
}
|
|
596
|
+
/** `scripts/head_mesh.MOTION_DEPTH_ATTR`, as GLTFLoader names it: lowercased. */
|
|
597
|
+
const MOTION_DEPTH = "_motion_depth";
|
|
639
598
|
/**
|
|
640
599
|
* The vertex half of `motionDepth`, which the eye's socket shader needs too.
|
|
641
600
|
*
|
|
@@ -651,9 +610,8 @@ function turnDeep(shader, undoGaze = false) {
|
|
|
651
610
|
// (0, 0, Δz) into the screen plane and slides the hidden skirt out past the
|
|
652
611
|
// temple on a look alone, head square on, where the skin it hides behind has
|
|
653
612
|
// not moved at all. The frame's rotation is the one the skirt must follow, and
|
|
654
|
-
// `uGaze` is exactly the extra rotation to take back out
|
|
655
|
-
//
|
|
656
|
-
// multiply, so this needs no second uniform and no `transpose()`.
|
|
613
|
+
// `uGaze` is exactly the extra rotation to take back out — multiplying the
|
|
614
|
+
// vector from the left is its inverse, so this needs no second uniform.
|
|
657
615
|
const depth = `vec3(0.0, 0.0, ${MOTION_DEPTH})`;
|
|
658
616
|
shader.vertexShader = shader.vertexShader
|
|
659
617
|
.replace("#include <common>", `#include <common>\nattribute float ${MOTION_DEPTH};`)
|
|
@@ -661,10 +619,39 @@ function turnDeep(shader, undoGaze = false) {
|
|
|
661
619
|
+ `mvPosition.xy += (modelViewMatrix * vec4(${undoGaze ? `${depth} * uGaze` : depth}, 0.0)).xy;\n`
|
|
662
620
|
+ "gl_Position = projectionMatrix * mvPosition;");
|
|
663
621
|
}
|
|
664
|
-
|
|
622
|
+
/**
|
|
623
|
+
* The head's frame, taught to turn as if it sat deeper than it does.
|
|
624
|
+
*
|
|
625
|
+
* Rotated at their real depth, the ears, crown, side hair and outline move
|
|
626
|
+
* nearly as far as the nose, and a nod reads as the whole head dropping
|
|
627
|
+
* (docs/research-head-rotation.md § 1). A real head's frame sits near the axis
|
|
628
|
+
* and barely moves, and that differential is what reads as rotation. Each
|
|
629
|
+
* vertex of the skin, hair and ears carries a Δz toward the viewer
|
|
630
|
+
* (`head_mesh.motion_depth`, ≤ 0 and zero across the features), and this moves
|
|
631
|
+
* it on screen by the rotated Δz: the view-space xy of modelView · (0, 0, Δz).
|
|
632
|
+
* Depth, draw order and lighting keep the real position. At rest that xy is
|
|
633
|
+
* exactly zero through the orthographic camera, so the drawing is untouched.
|
|
634
|
+
*
|
|
635
|
+
* The attribute decides, not the mesh name: the neck shares the skin material
|
|
636
|
+
* and has no field, which is why each head shell gets a clone. The same clone
|
|
637
|
+
* wears the expression maps, when the asset has them: the shells textured from
|
|
638
|
+
* the face atlas are exactly the ones those maps are registered to.
|
|
639
|
+
*/
|
|
640
|
+
function motionDepth(base, expression, shut) {
|
|
665
641
|
const material = base.clone();
|
|
666
642
|
material.onBeforeCompile = (shader) => {
|
|
667
643
|
turnDeep(shader);
|
|
644
|
+
if (shut) {
|
|
645
|
+
Object.assign(shader.uniforms, shut.uniforms);
|
|
646
|
+
shader.vertexShader = shader.vertexShader
|
|
647
|
+
.replace("#include <common>", `#include <common>\n${SHUT_VERTEX_HEAD}`)
|
|
648
|
+
.replace("#include <uv_vertex>", `#include <uv_vertex>\n${SHUT_VERTEX}`);
|
|
649
|
+
shader.fragmentShader = shader.fragmentShader
|
|
650
|
+
.replace("#include <common>", `#include <common>\n${SHUT_UNIFORMS}`)
|
|
651
|
+
.replace("#include <map_fragment>", `#include <map_fragment>\n${SHUT_FRAGMENT}`)
|
|
652
|
+
.replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\n"
|
|
653
|
+
+ "totalEmissiveRadiance = mix(totalEmissiveRadiance, emissive * shutColour, shutWeight);");
|
|
654
|
+
}
|
|
668
655
|
if (!expression)
|
|
669
656
|
return;
|
|
670
657
|
const n = expression.names.length;
|
|
@@ -674,38 +661,27 @@ function motionDepth(base, expression) {
|
|
|
674
661
|
.replace("#include <map_fragment>", `#include <map_fragment>\n${EXPRESSION_FRAGMENT(n)}`)
|
|
675
662
|
.replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= expression;");
|
|
676
663
|
};
|
|
677
|
-
material.customProgramCacheKey = () =>
|
|
664
|
+
material.customProgramCacheKey = () => ["tara-motion-depth", expression && `expression-${expression.names.length}`, shut && "shut"]
|
|
665
|
+
.filter(Boolean).join("-");
|
|
678
666
|
return material;
|
|
679
667
|
}
|
|
680
668
|
/**
|
|
681
669
|
* Expression maps: the light a smile or a raised brow changes, which the
|
|
682
670
|
* geometry cannot.
|
|
683
671
|
*
|
|
684
|
-
* A pixel of this face is 75% photograph, emitted,
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
* is raised to the power of its weight and multiplies the albedo's diffuse and
|
|
693
|
-
* emitted halves alike, as the jaw's shadow does — so at weight 0 it is exactly
|
|
694
|
-
* 1 and the face is the photograph, and at 1 it is the expression's light.
|
|
695
|
-
*
|
|
696
|
-
* Read at the texel's *rest* position, which is where the build registered it:
|
|
697
|
-
* the morph that lifts the cheek carries the lifted cheek's light up with its
|
|
698
|
-
* texture, so light and shape arrive together without the shader knowing
|
|
699
|
-
* where anything went.
|
|
672
|
+
* A pixel of this face is 75% photograph, emitted, so the most a morph can
|
|
673
|
+
* change by moving skin is a quarter of its shading — and that shading is the
|
|
674
|
+
* neutral photograph's, which has no nasolabial fold to deepen and no forehead
|
|
675
|
+
* line to show. A cheek morph measured 0.00% of the tile changed. So an asset
|
|
676
|
+
* can carry, per expression, a grey ratio taken from a photograph of the same
|
|
677
|
+
* face making it (`scripts/expression_maps.py`), read at the texel's *rest*
|
|
678
|
+
* position: the morph that lifts the cheek carries the lifted cheek's light up
|
|
679
|
+
* with its texture, so light and shape arrive together.
|
|
700
680
|
*
|
|
701
681
|
* Per side, because the channels are. The weights cross over at the midline
|
|
702
682
|
* rather than switching there, so a one-sided smile does not cut its fold's
|
|
703
|
-
* light off in a line down the philtrum.
|
|
704
|
-
*
|
|
705
|
-
* The maps are the one thing in the asset nothing draws: they ride on a
|
|
706
|
-
* carrier mesh (`build_tara.py`, "Expression") that exists to get the texture
|
|
707
|
-
* into the GLB, and is taken out of the scene on load. An asset without one is
|
|
708
|
-
* a face whose light never changes, which is every asset built before these.
|
|
683
|
+
* light off in a line down the philtrum. An asset with no maps is a face whose
|
|
684
|
+
* light never changes, which is a correct face.
|
|
709
685
|
*/
|
|
710
686
|
const EXPRESSION_SPLIT = 0.03;
|
|
711
687
|
/**
|
|
@@ -720,8 +696,6 @@ const EXPRESSION_SPLIT = 0.03;
|
|
|
720
696
|
* A map named here that the asset lacks is simply not read, and a map the
|
|
721
697
|
* asset has that is not named here stays at weight 0: an asset newer than its
|
|
722
698
|
* rig loses the light the rig cannot place, not the face.
|
|
723
|
-
* TARA-SPECIFIC: judged on tushar's maps at the 400 × 300 tile, the only ones
|
|
724
|
-
* that exist.
|
|
725
699
|
*/
|
|
726
700
|
const EXPRESSION_WEIGHT = {
|
|
727
701
|
smile: (at) => at("mouthCorner") / 0.8,
|
|
@@ -781,29 +755,100 @@ function expressive(carrier) {
|
|
|
781
755
|
},
|
|
782
756
|
};
|
|
783
757
|
}
|
|
758
|
+
/** `scripts/build_character.LID_SHUT_ATTR`'s fields, as GLTFLoader names
|
|
759
|
+
* them: the displacement's u and v, and the weight. */
|
|
760
|
+
const LID_SHUT_ATTRS = ["_lid_shut_u", "_lid_shut_v", "_lid_shut_w"];
|
|
761
|
+
/**
|
|
762
|
+
* The shut lids: a closing lid shows the photograph of the eyes closed.
|
|
763
|
+
*
|
|
764
|
+
* A 2.5-D shell has no hidden skin, so the lid that closes is the open eye's
|
|
765
|
+
* lid, stretched: the lash band and the fold drawn down over the eyeball in
|
|
766
|
+
* pale streaks, and a white edge where the stretched margin met the sclera. A
|
|
767
|
+
* reviewer watching a real call called it the eye tearing mid-blink, and at
|
|
768
|
+
* any distance it read as an eye that did not shut at all.
|
|
769
|
+
*
|
|
770
|
+
* So the build carries the neutral edited to close the eyes
|
|
771
|
+
* (`scripts/shut_lids.py`), and each lid vertex says where its texel lands
|
|
772
|
+
* when the lid is shut. A lid fragment reads that photograph *there*: shut,
|
|
773
|
+
* every pixel the lid covers is the closed photograph's own; half-shut, the
|
|
774
|
+
* margin already wears the closed lash line and the band above it closed lid
|
|
775
|
+
* skin, because that is what those texels become. The blend follows the lid's
|
|
776
|
+
* own influence, so a lid held part-way on purpose — a downward glance, a
|
|
777
|
+
* degraded link — changes a little of its content, and a blink all of it.
|
|
778
|
+
*
|
|
779
|
+
* How much each vertex may show is the build's to say (`_lid_shut_w`,
|
|
780
|
+
* `morphs.lid_shut_weight`): all of the upper lid, fading out above the crease
|
|
781
|
+
* with the lid's own pull, so the rest of the face is the photograph to the
|
|
782
|
+
* byte at any lid value, and not at rest at all.
|
|
783
|
+
*/
|
|
784
|
+
// The lid's influence over which the closed photograph arrives. Not from 0, so
|
|
785
|
+
// the lid a glance lowers keeps nearly all of its own texture; full before the
|
|
786
|
+
// lid is, so the frames a blink is seen on are the closed photograph's.
|
|
787
|
+
const SHUT_FROM = 0.1;
|
|
788
|
+
const SHUT_FULL = 0.55;
|
|
789
|
+
// ...and over which the lower lid's lash fringe does (`_lid_shut_w` below 0).
|
|
790
|
+
// The lower lid hardly moves, so its fringe can only arrive with the upper
|
|
791
|
+
// margin: any earlier and a half-open eye wears it as a heavy lower liner.
|
|
792
|
+
const SHUT_FRINGE_FROM = 0.7;
|
|
793
|
+
const SHUT_FRINGE_FULL = 0.95;
|
|
794
|
+
const SHUT_VERTEX_HEAD = [
|
|
795
|
+
...LID_SHUT_ATTRS.map((name) => `attribute float ${name};`),
|
|
796
|
+
"uniform vec2 uShutScale;", "varying vec2 vLidShut;", "varying float vLidShare;",
|
|
797
|
+
].join("\n");
|
|
798
|
+
const SHUT_VERTEX = [
|
|
799
|
+
`vLidShut = vec2(${LID_SHUT_ATTRS[0]}, ${LID_SHUT_ATTRS[1]}) * uShutScale;`,
|
|
800
|
+
`vLidShare = ${LID_SHUT_ATTRS[2]};`,
|
|
801
|
+
].join("\n");
|
|
802
|
+
const SHUT_UNIFORMS = [
|
|
803
|
+
"uniform sampler2D uShut;", "uniform vec4 uShutTile;", "uniform vec2 uShutU;",
|
|
804
|
+
"uniform vec2 uShutLid;", "uniform vec2 uShutFringe;", "varying vec2 vLidShut;", "varying float vLidShare;",
|
|
805
|
+
].join("\n");
|
|
806
|
+
const SHUT_FRAGMENT = [
|
|
807
|
+
"vec2 shutAt = clamp(uShutTile.xz + uShutTile.yw * (vMapUv + vLidShut), 0.0, 1.0);",
|
|
808
|
+
"vec3 shutColour = texture2D(uShut, shutAt).rgb;",
|
|
809
|
+
`float shutSide = smoothstep(-${EXPRESSION_SPLIT}, ${EXPRESSION_SPLIT}, uShutU.x + uShutU.y * vMapUv.x);`,
|
|
810
|
+
"float shutWeight = max(vLidShare, 0.0) * mix(uShutLid.x, uShutLid.y, shutSide)",
|
|
811
|
+
" + max(-vLidShare, 0.0) * mix(uShutFringe.x, uShutFringe.y, shutSide);",
|
|
812
|
+
"diffuseColor.rgb = mix(diffuseColor.rgb, diffuse * shutColour, shutWeight);",
|
|
813
|
+
].join("\n");
|
|
814
|
+
/** The closed photograph from its carrier's node, or `null` for an asset
|
|
815
|
+
* without one, whose lids shut on their own texels as they always have. */
|
|
816
|
+
function shutLids(carrier) {
|
|
817
|
+
const { shut_tile: tile, shut_u: u, shut_scale: scale } = carrier.userData;
|
|
818
|
+
const map = carrier.material.map;
|
|
819
|
+
if (!map || !Array.isArray(tile) || !Array.isArray(u) || !Array.isArray(scale))
|
|
820
|
+
return null;
|
|
821
|
+
const lid = new THREE.Vector2();
|
|
822
|
+
const fringe = new THREE.Vector2();
|
|
823
|
+
return {
|
|
824
|
+
map, lid, fringe,
|
|
825
|
+
uniforms: {
|
|
826
|
+
uShut: { value: map },
|
|
827
|
+
uShutTile: { value: new THREE.Vector4(tile[0], tile[1], tile[2], tile[3]) },
|
|
828
|
+
uShutU: { value: new THREE.Vector2(u[0], u[1]) },
|
|
829
|
+
uShutScale: { value: new THREE.Vector2(scale[0], scale[1]) },
|
|
830
|
+
uShutLid: { value: lid },
|
|
831
|
+
uShutFringe: { value: fringe },
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
}
|
|
784
835
|
/**
|
|
785
836
|
* The neck, taught to wear the jaw's shadow where the jaw is.
|
|
786
837
|
*
|
|
787
838
|
* The photograph paints the shadow the chin casts on the throat, and a painted
|
|
788
839
|
* shadow stays where it was painted: under a 9° turn the jaw crossed the top of
|
|
789
|
-
* the neck by 8 px and its shadow did not
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
* own, so the drawing is the photograph; under a turn the shadow's edge rides
|
|
797
|
-
* the rim, whatever the neck's own follow is doing.
|
|
840
|
+
* the neck by 8 px and its shadow did not, which read as the neck sliding out
|
|
841
|
+
* from under the head. So the build lifts it into a ratio tile
|
|
842
|
+
* (`project_albedo.lift_jaw_shadow`) and each neck fragment reads it at the
|
|
843
|
+
* *turned* head's rim, offset by how far the jaw has dropped — a rigid
|
|
844
|
+
* transform does not carry a morph, which was the other half of the same
|
|
845
|
+
* defect: an open mouth left the shadow banded across the throat at the closed
|
|
846
|
+
* rim with nothing casting it.
|
|
798
847
|
*
|
|
799
|
-
* The tile
|
|
800
|
-
*
|
|
801
|
-
* a texture already bound and no draw call. Its edges are white — no shadow —
|
|
802
|
-
* and the lookup is clamped to it, so a ray that lands past the tile (the
|
|
803
|
-
* throat's far side under a hard turn) reads "no shadow" rather than the hair
|
|
804
|
-
* or the iris the atlas keeps beside it.
|
|
848
|
+
* The tile's edges are white and the lookup clamped to it, so a ray landing
|
|
849
|
+
* past it reads "no shadow" rather than the hair or the iris beside it.
|
|
805
850
|
*/
|
|
806
|
-
function jawShadow(base, uv, extent, rimZ, headInverse) {
|
|
851
|
+
function jawShadow(base, uv, extent, rimZ, headInverse, jawDrop) {
|
|
807
852
|
const material = base.clone();
|
|
808
853
|
const tile = { value: new THREE.Vector4(uv[0], uv[1], uv[2], uv[3]) };
|
|
809
854
|
const bounds = { value: new THREE.Vector4(extent[0], extent[1], extent[2], extent[3]) };
|
|
@@ -813,16 +858,19 @@ function jawShadow(base, uv, extent, rimZ, headInverse) {
|
|
|
813
858
|
shader.uniforms.uJawTile = tile;
|
|
814
859
|
shader.uniforms.uJawBounds = bounds;
|
|
815
860
|
shader.uniforms.uJawRim = rim;
|
|
861
|
+
shader.uniforms.uJawDrop = jawDrop;
|
|
816
862
|
shader.vertexShader = shader.vertexShader
|
|
817
863
|
.replace("#include <common>", "#include <common>\nvarying vec3 vJawView;")
|
|
818
864
|
.replace("#include <project_vertex>", "#include <project_vertex>\nvJawView = mvPosition.xyz;");
|
|
819
865
|
shader.fragmentShader = shader.fragmentShader
|
|
820
866
|
.replace("#include <common>", "#include <common>\nvarying vec3 vJawView;\nuniform mat4 uHeadInverse;\n"
|
|
821
|
-
+ "uniform vec4 uJawTile;\nuniform vec4 uJawBounds;\nuniform float uJawRim
|
|
867
|
+
+ "uniform vec4 uJawTile;\nuniform vec4 uJawBounds;\nuniform float uJawRim;\n"
|
|
868
|
+
+ "uniform float uJawDrop;")
|
|
822
869
|
.replace("#include <map_fragment>", "#include <map_fragment>\n"
|
|
823
870
|
+ "vec3 jawFrom = (uHeadInverse * vec4(vJawView, 1.0)).xyz;\n"
|
|
824
871
|
+ "vec3 jawRay = (uHeadInverse * vec4(0.0, 0.0, 1.0, 0.0)).xyz;\n"
|
|
825
872
|
+ "vec2 jawAt = jawFrom.xy + jawRay.xy * ((uJawRim - jawFrom.z) / jawRay.z);\n"
|
|
873
|
+
+ "jawAt.y += uJawDrop;\n"
|
|
826
874
|
+ "jawAt = clamp(jawAt, uJawBounds.xz, uJawBounds.yw);\n"
|
|
827
875
|
+ "vec3 jawShadow = texture2D(map, uJawTile.xz + uJawTile.yw * jawAt).rgb;\n"
|
|
828
876
|
+ "diffuseColor.rgb *= jawShadow;")
|
|
@@ -831,7 +879,7 @@ function jawShadow(base, uv, extent, rimZ, headInverse) {
|
|
|
831
879
|
material.customProgramCacheKey = () => "tara-jaw-shadow";
|
|
832
880
|
return material;
|
|
833
881
|
}
|
|
834
|
-
/** Lamps, standing in for the four area lights `
|
|
882
|
+
/** Lamps, standing in for the four area lights `build_character.setup_scene` uses.
|
|
835
883
|
*
|
|
836
884
|
* The albedo is a photograph and already holds this face's light, so 75% of it
|
|
837
885
|
* is emitted verbatim (`emissiveFactor` in the GLB) and only the remaining 25%
|
|
@@ -845,15 +893,6 @@ const AMBIENT = 0.62 * Math.PI;
|
|
|
845
893
|
const KEY = 0.26 * Math.PI;
|
|
846
894
|
const WRAP = 0.10 * Math.PI;
|
|
847
895
|
/** Frames drawn per second, capped rather than left at the display's rate.
|
|
848
|
-
*
|
|
849
|
-
* `setAnimationLoop` is `requestAnimationFrame`, so uncapped this face is drawn
|
|
850
|
-
* as fast as the viewer's hardware refreshes — 60 on most panels, 120 on a
|
|
851
|
-
* ProMotion Mac or a current flagship phone. That is the wrong way round: the
|
|
852
|
-
* device most likely to care about the battery is the one that would draw the
|
|
853
|
-
* most, and it buys nothing, because idle motion here is deliberately held
|
|
854
|
-
* under ~1.5 Hz (CLAUDE.md) and the head's travel is a few degrees, slowly, in
|
|
855
|
-
* a 400 × 300 tile. 30 samples that twenty times a cycle. Character animation
|
|
856
|
-
* ships lipsync at 24 for a living.
|
|
857
896
|
*
|
|
858
897
|
* Measured on an M1 over four paired reps against `peep`, which is the SVG
|
|
859
898
|
* avatar that already ships: uncapped at 60 the 3-D face cost 13.8 points of
|
|
@@ -881,13 +920,26 @@ function webglRenderer(readback = false) {
|
|
|
881
920
|
return null;
|
|
882
921
|
}
|
|
883
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* The lower lid follows the eye down. Its retractor is tied to the inferior
|
|
925
|
+
* rectus, so a look down pulls the lower margin down with it by a millimetre
|
|
926
|
+
* or two — the upper lid's half of this is the mixer's `lidBias`, and a face
|
|
927
|
+
* whose upper lid follows while the lower one stays reads as a drowsy droop
|
|
928
|
+
* rather than a glance. Driven through the squint target run backwards,
|
|
929
|
+
* which is the lower lid and nothing else: at the notes gaze (`pupilY` 0.72)
|
|
930
|
+
* the margin drops ~0.006, about 1.7 px at the 400 px tile.
|
|
931
|
+
*/
|
|
932
|
+
const LOWER_LID_FOLLOW = 0.35;
|
|
884
933
|
/**
|
|
885
934
|
* Where a lid channel's influence reaches a shut eye. Not at 1, because the
|
|
886
|
-
* mixer never asks for 1: a blink is a
|
|
887
|
-
* 18 ms smoothing rounds its peak off, and this rig draws at 30 fps
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
935
|
+
* mixer never asks for 1: a blink is a triangle (`idle.BLINK_DUR`), the lid
|
|
936
|
+
* channel's 18 ms smoothing rounds its peak off, and this rig draws at 30 fps.
|
|
937
|
+
* `LID_SHUT` was set when blinks ran 0.11–0.15 s, at the fifth percentile of
|
|
938
|
+
* the peak a viewer actually saw (median 0.74). At the present 0.19–0.23 s
|
|
939
|
+
* that peak is 0.85 median and 0.81 at the fifth percentile — simulated over
|
|
940
|
+
* the same smoothing and frame phase — so every blink still lands shut, with
|
|
941
|
+
* margin. A lid morph that shut only at 1 left every blink a quarter open — the
|
|
942
|
+
* lid came down and the iris was still there.
|
|
891
943
|
*
|
|
892
944
|
* So above `LID_KNEE` the influence is eased up to meet 1 at `LID_SHUT`, and
|
|
893
945
|
* held there: past that point the lid has landed on the lower one. Below the
|
|
@@ -898,40 +950,28 @@ function webglRenderer(readback = false) {
|
|
|
898
950
|
*
|
|
899
951
|
* `scripts/morphs.py:influence` parses both numbers out of this file.
|
|
900
952
|
*/
|
|
901
|
-
/**
|
|
902
|
-
* The lower lid follows the eye down. Its retractor is tied to the inferior
|
|
903
|
-
* rectus, so a look down pulls the lower margin down with it by a millimetre
|
|
904
|
-
* or two — the upper lid's half of this is the mixer's `lidBias`, and a face
|
|
905
|
-
* whose upper lid follows while the lower one stays reads as a drowsy droop
|
|
906
|
-
* rather than a glance. Driven through the squint target run backwards,
|
|
907
|
-
* which is the lower lid and nothing else: at the notes gaze (`pupilY` 0.72)
|
|
908
|
-
* the margin drops ~0.006, about 1.7 px at the 400 px tile.
|
|
909
|
-
*/
|
|
910
|
-
const LOWER_LID_FOLLOW = 0.35;
|
|
911
953
|
const LID_KNEE = 0.35;
|
|
912
954
|
const LID_SHUT = 0.66;
|
|
913
955
|
/**
|
|
914
956
|
* A squint's influence rises faster than its channel. The mixer's values are
|
|
915
957
|
* set where a line face's lower lid reads — a smile's squint is 0.30 — and a
|
|
916
958
|
* photographic lower lid rising 0.30 of its travel is a pixel or two, so the
|
|
917
|
-
* squint that makes a smile real was not there.
|
|
918
|
-
*
|
|
919
|
-
* is added after it and stays linear.
|
|
959
|
+
* squint that makes a smile real was not there. The lower-lid follow, which
|
|
960
|
+
* runs this target backwards, is added after the curve and stays linear.
|
|
920
961
|
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* meet and the lower lid never visibly sticks.
|
|
962
|
+
* The curve needs a ceiling as well as a lift, because the squint stacks and
|
|
963
|
+
* the mouth does not. A *silent* smile takes squint from three layers at once —
|
|
964
|
+
* an approval clip, the encouraging emotion and prosody's warmth — which reach
|
|
965
|
+
* 0.51 together, while the smile map saturates at a mouth corner of 0.8 and no
|
|
966
|
+
* layer drives the jaw, so the last third of a smile arrives as narrowing eyes
|
|
967
|
+
* over lips that cannot part any further. On a line face that reads as warmth.
|
|
968
|
+
* On a photograph it reads as sedation: a reviewer watching a recorded call
|
|
969
|
+
* read those two moments as the avatar falling asleep or heavily medicated, and
|
|
970
|
+
* named the eyes, not the mouth. The ceiling is where the face stops reading
|
|
971
|
+
* drugged, judged at crop. The knee is low enough that an ordinary one-layer
|
|
972
|
+
* smile is untouched (0.22 renders 0.402 against 0.403 with no ceiling), and
|
|
973
|
+
* the approach is exponential rather than a clamp so the slope is continuous
|
|
974
|
+
* where the two meet and the lower lid never visibly sticks.
|
|
935
975
|
*/
|
|
936
976
|
const SQUINT_CURVE = 0.6;
|
|
937
977
|
const SQUINT_KNEE = 0.2;
|
|
@@ -954,17 +994,6 @@ const lidClosure = (i) => {
|
|
|
954
994
|
const t = (i - LID_KNEE) / (LID_SHUT - LID_KNEE);
|
|
955
995
|
return i + (1 - LID_SHUT) * t * t;
|
|
956
996
|
};
|
|
957
|
-
/**
|
|
958
|
-
* A channel's morph influence. One line — and the lid and squint curves above — and the
|
|
959
|
-
* same one `scripts/morphs.py:influence` uses, so a Blender preview and the
|
|
960
|
-
* browser pose the face identically.
|
|
961
|
-
*
|
|
962
|
-
* It is allowed to go negative, which is what lets one target serve a
|
|
963
|
-
* bidirectional channel: `mouthCornerL` at −1.4 is the smile target run
|
|
964
|
-
* backwards into a frown, and `lidL` below its 0.12 rest opens the eye wider
|
|
965
|
-
* than neutral. A rig that clamped this at 0 would silently delete the negative
|
|
966
|
-
* half of six channels.
|
|
967
|
-
*/
|
|
968
997
|
/**
|
|
969
998
|
* The neck's follow targets, and the one place a morph is not driven by
|
|
970
999
|
* `influence`.
|
|
@@ -978,8 +1007,8 @@ const lidClosure = (i) => {
|
|
|
978
1007
|
*
|
|
979
1008
|
* So under pitch and roll the throat tracks the skull exactly at any angle,
|
|
980
1009
|
* and — the part worth having — the asset stops depending on the envelope.
|
|
981
|
-
* These fields carry no angle, so `HEAD_DEG`
|
|
982
|
-
* move without leaving
|
|
1010
|
+
* These fields carry no angle, so `HEAD_DEG` above is a runtime number that can
|
|
1011
|
+
* move without leaving a built GLB stale. Yaw's pair is a partial twist rather
|
|
983
1012
|
* than the skull's own rotation, and the build weights its two fields so these
|
|
984
1013
|
* same two influences drive it (`morphs.neck_twist`).
|
|
985
1014
|
*/
|
|
@@ -998,40 +1027,24 @@ const neckInfluence = (channel, pose) => {
|
|
|
998
1027
|
};
|
|
999
1028
|
/**
|
|
1000
1029
|
* The hair's roll, which is the one thing in this rig that is not a function of
|
|
1001
|
-
* the pose alone.
|
|
1030
|
+
* the pose alone. `hold` is the share of the head's roll the hanging hair
|
|
1031
|
+
* declines to take, and `hz`/`damping` are how it gets there.
|
|
1002
1032
|
*
|
|
1003
|
-
* A hank that hangs past the jaw is lying on a shoulder, and a shoulder does
|
|
1004
|
-
* tilt when the head does
|
|
1005
|
-
* and the page shows through behind it, so the shell gives up `hold` of
|
|
1006
|
-
* roll at its lowest rows and none at the crown
|
|
1007
|
-
* That is the static half and it is what fixes the gap.
|
|
1033
|
+
* A hank that hangs past the jaw is lying on a shoulder, and a shoulder does
|
|
1034
|
+
* not tilt when the head does: rolled rigidly with the skull it lifts off the
|
|
1035
|
+
* collar and the page shows through behind it, so the shell gives up `hold` of
|
|
1036
|
+
* the roll at its lowest rows and none at the crown (`morphs.hair_hold`).
|
|
1008
1037
|
*
|
|
1009
1038
|
* The other half is why roll read as a hinge at all. A rigid rotation about a
|
|
1010
|
-
* fixed point is a hinge
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
* the hair's own angle chasing the share of the roll it agrees to take.
|
|
1015
|
-
*
|
|
1016
|
-
* It is on the hair and not on the head's channels on purpose. The mixer's
|
|
1017
|
-
* per-channel time constants are shared with the SVG faces and every clip in the
|
|
1018
|
-
* library is authored pre-compensated for them, so a spring on `headRoll` would
|
|
1019
|
-
* silently re-time every nod ever authored. Secondary motion on a shell that
|
|
1020
|
-
* only this renderer has costs nothing outside it.
|
|
1021
|
-
*
|
|
1022
|
-
* 1.5 Hz is the band the library already keeps gesture under, and a hank of hair
|
|
1023
|
-
* on a real head swings near it (a 7 cm pendulum is 1.9 Hz); the damping is a
|
|
1024
|
-
* single visible overshoot, settling inside 0.8 s. Faster reads as a flick and
|
|
1039
|
+
* fixed point *is* a hinge, and what a real tilt has that this lacked is hair
|
|
1040
|
+
* that arrives late and settles (`docs/research-head-rotation.md` § 3.1:
|
|
1041
|
+
* mobility ~0.95, delay 0.8-0.9, one clear overshoot). At 0.65 that is a single
|
|
1042
|
+
* visible overshoot, inside 5% of the hold in 450 ms — faster reads as a flick,
|
|
1025
1043
|
* slower as wet hair.
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
* constraint sets on *driven* oscillation; a settle is a one-shot and could
|
|
1031
|
-
* defensibly go faster, but there is no reason to spend the exemption: what
|
|
1032
|
-
* unhinges the roll is the hair arriving late, not the ring. At 0.65 it trails
|
|
1033
|
-
* by 93% of its travel a frame in, overshoots 5% and is inside 5% of the hold in
|
|
1034
|
-
* 450 ms — well within a phrase's hold.
|
|
1044
|
+
*
|
|
1045
|
+
* It is on the hair and not on `headRoll` on purpose: every clip in the library
|
|
1046
|
+
* is authored pre-compensated for the mixer's per-channel time constants, so a
|
|
1047
|
+
* spring on the channel would silently re-time every nod ever authored.
|
|
1035
1048
|
*/
|
|
1036
1049
|
export const HAIR_ROLL = { hold: 0.85, hz: 1.5, damping: 0.65 };
|
|
1037
1050
|
/**
|
|
@@ -1040,9 +1053,8 @@ export const HAIR_ROLL = { hold: 0.85, hz: 1.5, damping: 0.65 };
|
|
|
1040
1053
|
* stiffness and 30 fps; this does not, which is the only reason the order of
|
|
1041
1054
|
* those two lines is worth a sentence.
|
|
1042
1055
|
*
|
|
1043
|
-
* Exported for `test/nods.test.ts
|
|
1044
|
-
*
|
|
1045
|
-
* package's surface — `packages/avatar/client/tara.ts` is.
|
|
1056
|
+
* Exported for `test/nods.test.ts`: settle time and overshoot are numbers, not
|
|
1057
|
+
* something a still frame can show.
|
|
1046
1058
|
*/
|
|
1047
1059
|
export const hairRollStep = (angle, rate, target, dt) => {
|
|
1048
1060
|
const w = 2 * Math.PI * HAIR_ROLL.hz;
|
|
@@ -1058,6 +1070,16 @@ const hairInfluence = (channel, extra) => {
|
|
|
1058
1070
|
return 1 - Math.cos(extra);
|
|
1059
1071
|
return null;
|
|
1060
1072
|
};
|
|
1073
|
+
/**
|
|
1074
|
+
* A channel's morph influence — the same law `scripts/morphs.py:influence`
|
|
1075
|
+
* uses, so a Blender preview and the browser pose the face identically.
|
|
1076
|
+
*
|
|
1077
|
+
* It is allowed to go negative, which is what lets one target serve a
|
|
1078
|
+
* bidirectional channel: `mouthCornerL` at −1.4 is the smile target run
|
|
1079
|
+
* backwards into a frown, and `lidL` below its rest opens the eye wider than
|
|
1080
|
+
* neutral. A rig that clamped this at 0 would silently delete the negative half
|
|
1081
|
+
* of every channel that has one.
|
|
1082
|
+
*/
|
|
1061
1083
|
const influence = (channel, value) => {
|
|
1062
1084
|
const rest = REST[channel] ?? 0;
|
|
1063
1085
|
const i = (value - rest) / (1 - rest);
|
|
@@ -1078,26 +1100,21 @@ const expressionWeights = (pose, side, expression, into) => {
|
|
|
1078
1100
|
into[i] = Math.min(Math.max(weight, 0), 1);
|
|
1079
1101
|
});
|
|
1080
1102
|
};
|
|
1081
|
-
|
|
1082
|
-
// passes `rigOptions` through verbatim and has no way to know any rig's shape.
|
|
1083
|
-
export function createTaraRig(mount, options) {
|
|
1103
|
+
export function createCharacterRig(mount, options) {
|
|
1084
1104
|
const { onReady, url, expression: readExpression = true, readback = false } = (options ?? {});
|
|
1085
1105
|
// A missing `url` is a caller's defect, not a browser condition — the WebGL
|
|
1086
1106
|
// path below degrades because a driver is nobody's fault, whereas this would
|
|
1087
1107
|
// otherwise be a 404 on a path spelled `undefined`.
|
|
1088
1108
|
if (!url)
|
|
1089
|
-
throw new TypeError("
|
|
1109
|
+
throw new TypeError("createCharacterRig: `url` is required");
|
|
1090
1110
|
const scene = new THREE.Scene();
|
|
1091
1111
|
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
|
|
1092
1112
|
camera.position.set(0, FRAME_CENTRE, 6);
|
|
1093
1113
|
camera.lookAt(0, FRAME_CENTRE, 0);
|
|
1094
1114
|
const renderer = webglRenderer(readback);
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
// it, over a browser condition that is nobody's defect. WebGL is unavailable
|
|
1099
|
-
// more often than it looks — a driver on a blocklist, a hardened profile, a
|
|
1100
|
-
// remote desktop — and the right outcome is a call that still has audio,
|
|
1115
|
+
// `createAvatar` is synchronous, so anything thrown here lands in the
|
|
1116
|
+
// consumer's window and takes the call page with it over a browser condition
|
|
1117
|
+
// that is nobody's defect. The right outcome is a call that still has audio,
|
|
1101
1118
|
// captions and states, with an empty tile where the head would be.
|
|
1102
1119
|
//
|
|
1103
1120
|
// `warn` rather than `error` on purpose: `[avatar]` console errors mean a
|
|
@@ -1132,20 +1149,18 @@ export function createTaraRig(mount, options) {
|
|
|
1132
1149
|
// `trunk` sways everything, and `lift` carries the neck and head on the
|
|
1133
1150
|
// breath the torso takes inside `trunk` — and on the lean's rigid share,
|
|
1134
1151
|
// which reaches the head the same way for the same reason.
|
|
1135
|
-
//
|
|
1136
|
-
// There were three. The outermost was `figure`, and it existed only to scale
|
|
1137
|
-
// the whole character for `torsoLean`; with the camera a sibling rather than
|
|
1138
|
-
// a child, that was a zoom and not a lean. The trunk deforms on the shell now
|
|
1139
|
-
// (`morphs.torso_targets`), so the group has no work left and is gone rather
|
|
1140
|
-
// than left behind as an identity transform for someone to wonder about.
|
|
1141
1152
|
const trunk = new THREE.Group();
|
|
1142
1153
|
const lift = new THREE.Group();
|
|
1143
1154
|
const head = new THREE.Group();
|
|
1144
|
-
|
|
1155
|
+
// The asset's own, once it has loaded; these hold the shape of the hierarchy
|
|
1156
|
+
// until then, and nothing draws before that.
|
|
1157
|
+
let pivot = PIVOT.clone();
|
|
1158
|
+
let rollPivot = ROLL_PIVOT.clone();
|
|
1159
|
+
head.position.copy(pivot);
|
|
1145
1160
|
// Yaw and pitch turn `head`; roll turns `tilt`, which rides inside them at
|
|
1146
|
-
// the chin (
|
|
1161
|
+
// the chin (`rollPivot`), so the parts hang from `tilt`.
|
|
1147
1162
|
const tilt = new THREE.Group();
|
|
1148
|
-
tilt.position.subVectors(
|
|
1163
|
+
tilt.position.subVectors(rollPivot, pivot);
|
|
1149
1164
|
scene.add(trunk);
|
|
1150
1165
|
trunk.add(lift);
|
|
1151
1166
|
lift.add(head);
|
|
@@ -1162,33 +1177,48 @@ export function createTaraRig(mount, options) {
|
|
|
1162
1177
|
const eyes = [];
|
|
1163
1178
|
// Both globes turn together, so one rotation serves both sockets.
|
|
1164
1179
|
const gaze = { value: new THREE.Matrix3() };
|
|
1180
|
+
const lidShade = { L: { value: 1 }, R: { value: 1 } };
|
|
1165
1181
|
const turn = new THREE.Matrix4();
|
|
1166
1182
|
let expression = null;
|
|
1183
|
+
let shut = null;
|
|
1167
1184
|
// The `Hair` shell, on a character whose hair hangs low enough to have the
|
|
1168
|
-
// roll pair
|
|
1169
|
-
|
|
1185
|
+
// roll pair, and the `HairLayer` over the body, on one whose hair is a layer
|
|
1186
|
+
// of its own; empty where the hair stops beside the temple (`HAIR_ROLL`).
|
|
1187
|
+
const hairMeshes = [];
|
|
1170
1188
|
// The head's roll in the asset's own frame — what both the neck's follow and
|
|
1171
1189
|
// the hair's are authored about — and the hair's own, which chases it.
|
|
1172
1190
|
let headRollRad = 0;
|
|
1173
1191
|
let hairRollRad = 0;
|
|
1174
1192
|
let hairRate = 0;
|
|
1175
1193
|
let hairSeeded = false;
|
|
1194
|
+
// How far the jaw's rim travels at jaw = 1 on this asset, and the uniform the
|
|
1195
|
+
// neck's shadow reads it through at the pose's influence (`jawShadow`).
|
|
1196
|
+
let jawRimTravel = 0;
|
|
1197
|
+
const jawDrop = { value: 0 };
|
|
1198
|
+
// The lip opening at rest and each channel's move of it, off the shell
|
|
1199
|
+
// (`build_character.lip_aperture`), as [top, bottom, left, right]; and what
|
|
1200
|
+
// `mouthInterior` reads it through, as (centre, top, half-width, height).
|
|
1201
|
+
let aperture = null;
|
|
1202
|
+
const mouthOpening = { value: new THREE.Vector4(0, 0, 1, 1) };
|
|
1203
|
+
const tongueLift = { value: 0 };
|
|
1176
1204
|
/** The share of the head's roll the hair settles at. */
|
|
1177
1205
|
const hairTarget = () => headRollRad * (1 - HAIR_ROLL.hold);
|
|
1178
1206
|
const writeHair = () => {
|
|
1179
|
-
const dictionary = hairMesh?.morphTargetDictionary;
|
|
1180
|
-
const influences = hairMesh?.morphTargetInfluences;
|
|
1181
|
-
if (!dictionary || !influences)
|
|
1182
|
-
return;
|
|
1183
1207
|
const extra = hairRollRad - headRollRad;
|
|
1184
|
-
for (const
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1208
|
+
for (const mesh of hairMeshes) {
|
|
1209
|
+
const dictionary = mesh.morphTargetDictionary;
|
|
1210
|
+
const influences = mesh.morphTargetInfluences;
|
|
1211
|
+
if (!dictionary || !influences)
|
|
1212
|
+
continue;
|
|
1213
|
+
for (const [channel, index] of Object.entries(dictionary)) {
|
|
1214
|
+
const term = hairInfluence(channel, extra);
|
|
1215
|
+
if (term !== null)
|
|
1216
|
+
influences[index] = term;
|
|
1217
|
+
}
|
|
1188
1218
|
}
|
|
1189
1219
|
};
|
|
1190
1220
|
const stepHair = (dt) => {
|
|
1191
|
-
if (!
|
|
1221
|
+
if (!hairMeshes.length || !hairSeeded)
|
|
1192
1222
|
return;
|
|
1193
1223
|
({ angle: hairRollRad, rate: hairRate } =
|
|
1194
1224
|
hairRollStep(hairRollRad, hairRate, hairTarget(), dt));
|
|
@@ -1202,9 +1232,10 @@ export function createTaraRig(mount, options) {
|
|
|
1202
1232
|
// of the wrong aspect shows more or less background rather than a face of
|
|
1203
1233
|
// the wrong shape. The atlas cannot be stretched: it is a photograph.
|
|
1204
1234
|
// Symmetric about the camera, which is *already* at the frame's centre
|
|
1205
|
-
// height
|
|
1206
|
-
// the face rendered 0.47 face heights low, at exactly the right size,
|
|
1207
|
-
// reads as a framing choice rather than as the arithmetic error it
|
|
1235
|
+
// height — offsetting the frustum by that centre as well applies it twice,
|
|
1236
|
+
// and the face rendered 0.47 face heights low, at exactly the right size,
|
|
1237
|
+
// which reads as a framing choice rather than as the arithmetic error it
|
|
1238
|
+
// was.
|
|
1208
1239
|
const halfHeight = FRAME_HEIGHT / 2;
|
|
1209
1240
|
const halfWidth = (halfHeight * width) / height;
|
|
1210
1241
|
camera.top = halfHeight;
|
|
@@ -1232,7 +1263,7 @@ export function createTaraRig(mount, options) {
|
|
|
1232
1263
|
if (!dictionary || !influences)
|
|
1233
1264
|
continue;
|
|
1234
1265
|
const neck = mesh.name === "Neck";
|
|
1235
|
-
const hair = mesh
|
|
1266
|
+
const hair = hairMeshes.includes(mesh);
|
|
1236
1267
|
for (const [channel, index] of Object.entries(dictionary)) {
|
|
1237
1268
|
// The hair carries the head's roll channel too, and it is neither a
|
|
1238
1269
|
// channel value nor the head's own angle: it is how much *further* than
|
|
@@ -1246,9 +1277,11 @@ export function createTaraRig(mount, options) {
|
|
|
1246
1277
|
}
|
|
1247
1278
|
}
|
|
1248
1279
|
// The neck's head targets are the rotation's two terms, not a channel
|
|
1249
|
-
// scaled by the influence law. Only on the neck
|
|
1250
|
-
//
|
|
1251
|
-
|
|
1280
|
+
// scaled by the influence law. Only on the neck and the hair layer's
|
|
1281
|
+
// yaw hold (`morphs.hair_layer_targets`), whose field carries the hold
|
|
1282
|
+
// and takes the skull's own angle: the same three channel names on the
|
|
1283
|
+
// head group are a rigid transform, and nowhere else.
|
|
1284
|
+
if (neck || (hair && mesh.name === "HairLayer")) {
|
|
1252
1285
|
const term = neckInfluence(channel, pose);
|
|
1253
1286
|
if (term !== null) {
|
|
1254
1287
|
influences[index] = term;
|
|
@@ -1262,6 +1295,24 @@ export function createTaraRig(mount, options) {
|
|
|
1262
1295
|
influences[index] = (value === undefined ? 0 : influence(channel, value)) - follow;
|
|
1263
1296
|
}
|
|
1264
1297
|
}
|
|
1298
|
+
// The chin's shadow on the throat rides the same channel the chin does.
|
|
1299
|
+
jawDrop.value = jawRimTravel
|
|
1300
|
+
* (pose.jaw === undefined ? 0 : influence("jaw", pose.jaw));
|
|
1301
|
+
if (aperture) {
|
|
1302
|
+
const at = aperture.rest.slice();
|
|
1303
|
+
for (const [channel, delta] of aperture.deltas) {
|
|
1304
|
+
const value = pose[channel];
|
|
1305
|
+
if (value === undefined)
|
|
1306
|
+
continue;
|
|
1307
|
+
const k = influence(channel, value);
|
|
1308
|
+
for (let i = 0; i < 4; i++)
|
|
1309
|
+
at[i] += k * delta[i];
|
|
1310
|
+
}
|
|
1311
|
+
// A floor on the height so a shut mouth divides by something: the seam
|
|
1312
|
+
// it shows is a line, and what colour a line is does not read.
|
|
1313
|
+
mouthOpening.value.set((at[2] + at[3]) / 2, at[0], Math.max((at[3] - at[2]) / 2, 1e-3), Math.max(at[0] - at[1], 2e-3));
|
|
1314
|
+
}
|
|
1315
|
+
tongueLift.value = pose.tongue === undefined ? 0 : influence("tongue", pose.tongue);
|
|
1265
1316
|
// Blender's Z is face-space v, so its yaw is about Z, its pitch about X and
|
|
1266
1317
|
// its roll about Y. The export maps Blender (x, y, z) to glTF (x, z, −y),
|
|
1267
1318
|
// so Blender +Z *is* glTF +Y and Blender +X is glTF +X: yaw and pitch carry
|
|
@@ -1269,15 +1320,9 @@ export function createTaraRig(mount, options) {
|
|
|
1269
1320
|
// is glTF −Z, and that is a statement about two axes and not about the
|
|
1270
1321
|
// channel.
|
|
1271
1322
|
//
|
|
1272
|
-
// Yaw
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
// *left* on a positive `headYaw`, against `params.js`'s stated sign. It
|
|
1276
|
-
// survived because the same negation was in `build_tara.pose_head`, so the
|
|
1277
|
-
// Blender preview and the browser agreed with each other and only disagreed
|
|
1278
|
-
// with the library. `gaze.js` is what makes it a defect rather than a
|
|
1279
|
-
// convention: it hands `pupilX` and `headYaw` the same aversion term, so
|
|
1280
|
-
// tara's eyes went one way and her head went the other.
|
|
1323
|
+
// Yaw is not negated, and a negation here survived for a long time because
|
|
1324
|
+
// `build_character.pose_head` had the same one: the Blender preview and the
|
|
1325
|
+
// browser agreed with each other and only disagreed with the library.
|
|
1281
1326
|
//
|
|
1282
1327
|
// YXZ because that is the order a neck composes in — yaw carrying the pitch
|
|
1283
1328
|
// — rather than the order three.js defaults to.
|
|
@@ -1314,14 +1359,42 @@ export function createTaraRig(mount, options) {
|
|
|
1314
1359
|
}
|
|
1315
1360
|
if (eyes.length)
|
|
1316
1361
|
gaze.value.setFromMatrix4(turn.makeRotationFromEuler(eyes[0].rotation));
|
|
1362
|
+
for (const side of ["L", "R"]) {
|
|
1363
|
+
const value = pose[`lid${side}`];
|
|
1364
|
+
const i = value === undefined ? 0 : influence(`lid${side}`, value);
|
|
1365
|
+
lidShade[side].value = 1 - LID_SHADE * THREE.MathUtils.smoothstep(i, LID_SHADE_FROM, 1);
|
|
1366
|
+
}
|
|
1317
1367
|
if (expression) {
|
|
1318
1368
|
expressionWeights(pose, "L", expression, expression.left);
|
|
1319
1369
|
expressionWeights(pose, "R", expression, expression.right);
|
|
1320
1370
|
}
|
|
1371
|
+
if (shut) {
|
|
1372
|
+
const closure = (channel) => {
|
|
1373
|
+
const value = pose[channel];
|
|
1374
|
+
return value === undefined ? 0 : influence(channel, value);
|
|
1375
|
+
};
|
|
1376
|
+
const [l, r] = [closure("lidL"), closure("lidR")];
|
|
1377
|
+
const { smoothstep } = THREE.MathUtils;
|
|
1378
|
+
shut.lid.set(smoothstep(l, SHUT_FROM, SHUT_FULL), smoothstep(r, SHUT_FROM, SHUT_FULL));
|
|
1379
|
+
shut.fringe.set(smoothstep(l, SHUT_FRINGE_FROM, SHUT_FRINGE_FULL), smoothstep(r, SHUT_FRINGE_FROM, SHUT_FRINGE_FULL));
|
|
1380
|
+
}
|
|
1321
1381
|
};
|
|
1322
1382
|
new GLTFLoader().load(url, (gltf) => {
|
|
1323
1383
|
if (destroyed)
|
|
1324
1384
|
return;
|
|
1385
|
+
// Where this head turns and tilts, and what rides its skull, are facts
|
|
1386
|
+
// about this head — measured by the build, stamped into the scene, and read
|
|
1387
|
+
// here before anything is reparented into the frames they define. An asset
|
|
1388
|
+
// that predates the stamp keeps the constants above, which are the numbers
|
|
1389
|
+
// it was built with.
|
|
1390
|
+
const stamp = (gltf.scene.userData ?? {});
|
|
1391
|
+
pivot = stampedVec(stamp, "head_pivot", PIVOT);
|
|
1392
|
+
rollPivot = stampedVec(stamp, "roll_pivot", ROLL_PIVOT);
|
|
1393
|
+
head.position.copy(pivot);
|
|
1394
|
+
tilt.position.subVectors(rollPivot, pivot);
|
|
1395
|
+
const headParts = Array.isArray(stamp.head_parts)
|
|
1396
|
+
&& stamp.head_parts.every((n) => typeof n === "string")
|
|
1397
|
+
? stamp.head_parts : HEAD_PARTS;
|
|
1325
1398
|
trunk.add(gltf.scene);
|
|
1326
1399
|
// Collect the morphed meshes *before* reparenting: every one of them is a
|
|
1327
1400
|
// head part, so a traverse of `gltf.scene` after the move finds only the
|
|
@@ -1333,8 +1406,16 @@ export function createTaraRig(mount, options) {
|
|
|
1333
1406
|
morphed.push(mesh);
|
|
1334
1407
|
// Kept aside as well: its roll pair is driven by a clock and not only by a
|
|
1335
1408
|
// pose, so the render loop has to reach it between poses (`HAIR_ROLL`).
|
|
1336
|
-
if (mesh.isMesh && mesh.name === "Hair"
|
|
1337
|
-
|
|
1409
|
+
if (mesh.isMesh && (mesh.name === "Hair" || mesh.name === "HairLayer")
|
|
1410
|
+
&& mesh.morphTargetDictionary)
|
|
1411
|
+
hairMeshes.push(mesh);
|
|
1412
|
+
// Hair that lies over the body is in front of everything the portrait
|
|
1413
|
+
// has, and it turns with the skull where the chest does not: tested
|
|
1414
|
+
// against depth, a nod swings the lower hank back through the chest.
|
|
1415
|
+
if (mesh.isMesh && mesh.name === "HairLayer") {
|
|
1416
|
+
mesh.material.depthTest = false;
|
|
1417
|
+
mesh.renderOrder = 1;
|
|
1418
|
+
}
|
|
1338
1419
|
});
|
|
1339
1420
|
// Out of the scene before anything draws it: it carries the maps, and is
|
|
1340
1421
|
// one triangle behind the body that nothing should pay a draw call for.
|
|
@@ -1347,27 +1428,42 @@ export function createTaraRig(mount, options) {
|
|
|
1347
1428
|
carrier.geometry.dispose();
|
|
1348
1429
|
carrier.material.dispose();
|
|
1349
1430
|
}
|
|
1350
|
-
|
|
1431
|
+
const shutCarrier = gltf.scene.getObjectByName("Shut");
|
|
1432
|
+
if (shutCarrier) {
|
|
1433
|
+
shutCarrier.removeFromParent();
|
|
1434
|
+
shut = shutLids(shutCarrier);
|
|
1435
|
+
if (!shut)
|
|
1436
|
+
shutCarrier.material.map?.dispose();
|
|
1437
|
+
shutCarrier.geometry.dispose();
|
|
1438
|
+
shutCarrier.material.dispose();
|
|
1439
|
+
}
|
|
1440
|
+
for (const name of headParts) {
|
|
1351
1441
|
const part = gltf.scene.getObjectByName(name);
|
|
1352
1442
|
// Reparenting moves the object into the tilt's frame, whose origin is
|
|
1353
|
-
//
|
|
1354
|
-
// `attach()` would do this from the world matrix, which has
|
|
1355
|
-
// computed yet at load.
|
|
1443
|
+
// the roll pivot, so subtract that to leave the part where it was
|
|
1444
|
+
// authored. `attach()` would do this from the world matrix, which has
|
|
1445
|
+
// not been computed yet at load.
|
|
1356
1446
|
if (part) {
|
|
1357
|
-
part.position.sub(
|
|
1447
|
+
part.position.sub(rollPivot);
|
|
1358
1448
|
tilt.add(part);
|
|
1359
1449
|
}
|
|
1360
1450
|
}
|
|
1361
1451
|
// One clone per source material, shared by every shell that carries the
|
|
1362
1452
|
// field; a mesh without it keeps the material it came with.
|
|
1453
|
+
// The shell with the lids is a clone of its own: the ears and the hair
|
|
1454
|
+
// share its material and have no lid attributes to read.
|
|
1363
1455
|
const turned = new Map();
|
|
1456
|
+
const lidded = new Map();
|
|
1364
1457
|
head.traverse((object) => {
|
|
1365
1458
|
const mesh = object;
|
|
1366
1459
|
if (!mesh.isMesh || !mesh.geometry.getAttribute(MOTION_DEPTH))
|
|
1367
1460
|
return;
|
|
1368
1461
|
const base = mesh.material;
|
|
1369
|
-
const
|
|
1370
|
-
turned
|
|
1462
|
+
const withLids = shut !== null && LID_SHUT_ATTRS.every((name) => mesh.geometry.getAttribute(name) !== undefined);
|
|
1463
|
+
const cache = withLids ? lidded : turned;
|
|
1464
|
+
const own = cache.get(base)
|
|
1465
|
+
?? motionDepth(base, expression ?? undefined, withLids ? shut ?? undefined : undefined);
|
|
1466
|
+
cache.set(base, own);
|
|
1371
1467
|
mesh.material = own;
|
|
1372
1468
|
});
|
|
1373
1469
|
// The neck rides the breath with the head; the torso *is* the breath. Both
|
|
@@ -1376,13 +1472,16 @@ export function createTaraRig(mount, options) {
|
|
|
1376
1472
|
const neck = gltf.scene.getObjectByName("Neck");
|
|
1377
1473
|
if (neck)
|
|
1378
1474
|
lift.add(neck);
|
|
1379
|
-
// The shadow's map comes from the build (`
|
|
1475
|
+
// The shadow's map comes from the build (`build_character.py`, the neck); an
|
|
1380
1476
|
// asset without it keeps the shadow painted on, as every build did before.
|
|
1381
1477
|
const skull = head.getObjectByName("Head");
|
|
1382
|
-
const { jaw_shadow_uv: jawUv, jaw_shadow_extent: jawExtent, jaw_shadow_rim_z: jawRim } = neck?.userData ?? {};
|
|
1478
|
+
const { jaw_shadow_uv: jawUv, jaw_shadow_extent: jawExtent, jaw_shadow_rim_z: jawRim, jaw_shadow_drop: jawTravel } = neck?.userData ?? {};
|
|
1383
1479
|
if (neck && skull && Array.isArray(jawUv) && Array.isArray(jawExtent) && typeof jawRim === "number") {
|
|
1384
1480
|
const headInverse = { value: new THREE.Matrix4() };
|
|
1385
|
-
|
|
1481
|
+
// A build that predates the travel keeps the shadow rotating but not
|
|
1482
|
+
// opening, which is where this started and is still better than no tile.
|
|
1483
|
+
jawRimTravel = typeof jawTravel === "number" ? jawTravel : 0;
|
|
1484
|
+
neck.material = jawShadow(neck.material, jawUv, jawExtent, jawRim, headInverse, jawDrop);
|
|
1386
1485
|
neck.onBeforeRender = (_renderer, _scene, camera) => {
|
|
1387
1486
|
headInverse.value.multiplyMatrices(camera.matrixWorldInverse, skull.matrixWorld).invert();
|
|
1388
1487
|
};
|
|
@@ -1408,20 +1507,30 @@ export function createTaraRig(mount, options) {
|
|
|
1408
1507
|
// Whether the globe turns deep is the attribute's to say, not the name's —
|
|
1409
1508
|
// the same rule the frame's shells are found by above. An asset built
|
|
1410
1509
|
// before the globes carried the field keeps the socket shader alone.
|
|
1411
|
-
mesh.material = socketed(mesh.material, name === "Eye_L" ? -1 : 1, gaze, mesh.geometry.getAttribute(MOTION_DEPTH) !== undefined);
|
|
1510
|
+
mesh.material = socketed(mesh.material, name === "Eye_L" ? -1 : 1, gaze, mesh.geometry.getAttribute(MOTION_DEPTH) !== undefined, name === "Eye_L" ? lidShade.L : lidShade.R);
|
|
1412
1511
|
eyes.push(globe);
|
|
1413
1512
|
}
|
|
1414
|
-
// The mouth's inside,
|
|
1415
|
-
//
|
|
1416
|
-
// hard-codes a `tara_` prefix, so tushar's cavity material
|
|
1417
|
-
// `tara_cavity` as well, and the mesh is what distinguishes it.
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1513
|
+
// The mouth's inside, painted against the opening the lips make
|
|
1514
|
+
// (`mouthInterior`). Found by *mesh* name rather than material name:
|
|
1515
|
+
// `flat_material` hard-codes a `tara_` prefix, so tushar's cavity material
|
|
1516
|
+
// is called `tara_cavity` as well, and the mesh is what distinguishes it.
|
|
1517
|
+
// An asset built before the shell carried its opening keeps the flat fill.
|
|
1518
|
+
const opening = skull?.userData?.aperture;
|
|
1519
|
+
if (opening && Array.isArray(opening.rest) && opening.rest.length === 4) {
|
|
1520
|
+
aperture = { rest: opening.rest,
|
|
1521
|
+
deltas: Object.entries(opening.deltas ?? {})
|
|
1522
|
+
.filter((e) => Array.isArray(e[1]) && e[1].length === 4) };
|
|
1523
|
+
for (const [name, lift] of [["Cavity", { value: 0 }], ["Tongue", tongueLift]]) {
|
|
1524
|
+
const mesh = head.getObjectByName(name);
|
|
1525
|
+
if (mesh)
|
|
1526
|
+
mesh.material = mouthInterior(mesh.material, mouthOpening, lift);
|
|
1424
1527
|
}
|
|
1528
|
+
const lower = head.getObjectByName("Teeth_Lower");
|
|
1529
|
+
if (lower)
|
|
1530
|
+
lower.material = lowerArch(lower.material, mouthOpening);
|
|
1531
|
+
const upper = head.getObjectByName("Teeth_Upper");
|
|
1532
|
+
if (upper)
|
|
1533
|
+
upper.material = upperArch(upper.material, mouthOpening);
|
|
1425
1534
|
}
|
|
1426
1535
|
// A rig with no morph targets still renders a perfectly good rest pose, so
|
|
1427
1536
|
// the failure mode of losing them is a face that simply never moves — which
|
|
@@ -1434,15 +1543,11 @@ export function createTaraRig(mount, options) {
|
|
|
1434
1543
|
applyPose(pending);
|
|
1435
1544
|
onReady?.();
|
|
1436
1545
|
}, undefined, (error) => console.error("[avatar] could not load this character", url, error));
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
// and waits for the next one — 50 ms, i.e. 20 fps, not 30. Four milliseconds
|
|
1443
|
-
// is under half the interval of every rate worth caring about (8.3 at 120,
|
|
1444
|
-
// 11.1 at 90, 16.7 at 60), so it can never admit two frames where one belongs,
|
|
1445
|
-
// and it puts 60, 90 and 120 Hz all on 30 fps.
|
|
1546
|
+
// rAF fires on the panel's own grid, so elapsed lands *near* the frame
|
|
1547
|
+
// interval and never on it, and a bare `elapsed < MIN_FRAME_MS` rejects the
|
|
1548
|
+
// frame it wants and waits for the next — 20 fps, not 30. The tolerance is
|
|
1549
|
+
// under half the interval at 120, 90 and 60 Hz, so it can never admit two
|
|
1550
|
+
// frames where one belongs.
|
|
1446
1551
|
const GRID_TOLERANCE_MS = 4;
|
|
1447
1552
|
let lastFrameMs = 0;
|
|
1448
1553
|
renderer.setAnimationLoop(() => {
|
|
@@ -1497,9 +1602,10 @@ export function createTaraRig(mount, options) {
|
|
|
1497
1602
|
});
|
|
1498
1603
|
// A uniform, not a material's map, so the traverse above never meets it.
|
|
1499
1604
|
expression?.map.dispose();
|
|
1605
|
+
shut?.map.dispose();
|
|
1500
1606
|
renderer.dispose();
|
|
1501
1607
|
renderer.domElement.remove();
|
|
1502
1608
|
},
|
|
1503
1609
|
};
|
|
1504
1610
|
}
|
|
1505
|
-
//# sourceMappingURL=
|
|
1611
|
+
//# sourceMappingURL=character-rig.js.map
|