@voqalize/avatar 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE-APACHE-2.0 +201 -0
  2. package/README.md +11 -98
  3. package/assets/README.md +28 -17
  4. package/assets/tanvi.glb +0 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tess.glb +0 -0
  8. package/assets/tushar.glb +0 -0
  9. package/client/internal.ts +4 -0
  10. package/client/supports.ts +6 -7
  11. package/client/three/assets.ts +2 -0
  12. package/client/three/budgets.ts +1 -1
  13. package/client/three/{tara-rig.ts → character-rig.ts} +708 -547
  14. package/client/three/createCharacter.ts +109 -0
  15. package/client/three/holds.ts +1 -1
  16. package/client/three/internal.ts +4 -3
  17. package/client/three/motion-limits.json +5 -1
  18. package/client/three/tanvi-asset.ts +6 -0
  19. package/client/three/tanvi.ts +30 -0
  20. package/client/three/tanya.ts +18 -58
  21. package/client/three/tara.ts +19 -60
  22. package/client/three/tess.ts +17 -59
  23. package/client/three/tushar.ts +18 -55
  24. package/dist/internal.d.ts +1 -1
  25. package/dist/internal.d.ts.map +1 -1
  26. package/dist/internal.js +5 -1
  27. package/dist/internal.js.map +1 -1
  28. package/dist/supports.d.ts +6 -7
  29. package/dist/supports.d.ts.map +1 -1
  30. package/dist/supports.js +6 -7
  31. package/dist/supports.js.map +1 -1
  32. package/dist/three/assets.d.ts +1 -0
  33. package/dist/three/assets.d.ts.map +1 -1
  34. package/dist/three/assets.js +2 -0
  35. package/dist/three/assets.js.map +1 -1
  36. package/dist/three/budgets.d.ts +1 -1
  37. package/dist/three/budgets.js +1 -1
  38. package/dist/three/character-rig.d.ts +366 -0
  39. package/dist/three/character-rig.d.ts.map +1 -0
  40. package/dist/three/{tara-rig.js → character-rig.js} +664 -525
  41. package/dist/three/character-rig.js.map +1 -0
  42. package/dist/three/createCharacter.d.ts +60 -0
  43. package/dist/three/createCharacter.d.ts.map +1 -0
  44. package/dist/three/createCharacter.js +84 -0
  45. package/dist/three/createCharacter.js.map +1 -0
  46. package/dist/three/holds.js +1 -1
  47. package/dist/three/holds.js.map +1 -1
  48. package/dist/three/internal.d.ts +3 -3
  49. package/dist/three/internal.d.ts.map +1 -1
  50. package/dist/three/internal.js +2 -2
  51. package/dist/three/internal.js.map +1 -1
  52. package/dist/three/motion-limits.json +5 -1
  53. package/dist/three/tanvi-asset.d.ts +7 -0
  54. package/dist/three/tanvi-asset.d.ts.map +1 -0
  55. package/dist/three/tanvi-asset.js +7 -0
  56. package/dist/three/tanvi-asset.js.map +1 -0
  57. package/dist/three/tanvi.d.ts +24 -0
  58. package/dist/three/tanvi.d.ts.map +1 -0
  59. package/dist/three/tanvi.js +24 -0
  60. package/dist/three/tanvi.js.map +1 -0
  61. package/dist/three/tanya.d.ts +16 -28
  62. package/dist/three/tanya.d.ts.map +1 -1
  63. package/dist/three/tanya.js +15 -43
  64. package/dist/three/tanya.js.map +1 -1
  65. package/dist/three/tara.d.ts +17 -22
  66. package/dist/three/tara.d.ts.map +1 -1
  67. package/dist/three/tara.js +16 -45
  68. package/dist/three/tara.js.map +1 -1
  69. package/dist/three/tess.d.ts +15 -29
  70. package/dist/three/tess.d.ts.map +1 -1
  71. package/dist/three/tess.js +14 -44
  72. package/dist/three/tess.js.map +1 -1
  73. package/dist/three/tushar.d.ts +16 -25
  74. package/dist/three/tushar.d.ts.map +1 -1
  75. package/dist/three/tushar.js +15 -40
  76. package/dist/three/tushar.js.map +1 -1
  77. package/package.json +7 -50
  78. package/src/avatar.d.ts +2 -0
  79. package/src/avatar.js +99 -220
  80. package/src/gaze.js +1 -3
  81. package/src/idle.js +10 -2
  82. package/src/prosody.js +7 -3
  83. package/src/speech-timing.js +28 -0
  84. package/src/visemes.js +107 -13
  85. package/client/arjun.ts +0 -26
  86. package/client/createCanvasAvatar.ts +0 -72
  87. package/client/interviewer-female.ts +0 -4
  88. package/client/interviewer-male.ts +0 -4
  89. package/client/ishita.ts +0 -26
  90. package/client/kabir.ts +0 -26
  91. package/client/meera.ts +0 -26
  92. package/client/naina.ts +0 -26
  93. package/client/professional-female-a.ts +0 -4
  94. package/client/professional-female-b.ts +0 -4
  95. package/client/professional-male-a.ts +0 -4
  96. package/client/professional-male-b.ts +0 -4
  97. package/client/vikram.ts +0 -26
  98. package/dist/arjun.d.ts +0 -7
  99. package/dist/arjun.d.ts.map +0 -1
  100. package/dist/arjun.js +0 -20
  101. package/dist/arjun.js.map +0 -1
  102. package/dist/createCanvasAvatar.d.ts +0 -22
  103. package/dist/createCanvasAvatar.d.ts.map +0 -1
  104. package/dist/createCanvasAvatar.js +0 -47
  105. package/dist/createCanvasAvatar.js.map +0 -1
  106. package/dist/interviewer-female.d.ts +0 -4
  107. package/dist/interviewer-female.d.ts.map +0 -1
  108. package/dist/interviewer-female.js +0 -3
  109. package/dist/interviewer-female.js.map +0 -1
  110. package/dist/interviewer-male.d.ts +0 -4
  111. package/dist/interviewer-male.d.ts.map +0 -1
  112. package/dist/interviewer-male.js +0 -3
  113. package/dist/interviewer-male.js.map +0 -1
  114. package/dist/ishita.d.ts +0 -7
  115. package/dist/ishita.d.ts.map +0 -1
  116. package/dist/ishita.js +0 -20
  117. package/dist/ishita.js.map +0 -1
  118. package/dist/kabir.d.ts +0 -7
  119. package/dist/kabir.d.ts.map +0 -1
  120. package/dist/kabir.js +0 -20
  121. package/dist/kabir.js.map +0 -1
  122. package/dist/meera.d.ts +0 -7
  123. package/dist/meera.d.ts.map +0 -1
  124. package/dist/meera.js +0 -20
  125. package/dist/meera.js.map +0 -1
  126. package/dist/naina.d.ts +0 -7
  127. package/dist/naina.d.ts.map +0 -1
  128. package/dist/naina.js +0 -20
  129. package/dist/naina.js.map +0 -1
  130. package/dist/professional-female-a.d.ts +0 -4
  131. package/dist/professional-female-a.d.ts.map +0 -1
  132. package/dist/professional-female-a.js +0 -3
  133. package/dist/professional-female-a.js.map +0 -1
  134. package/dist/professional-female-b.d.ts +0 -4
  135. package/dist/professional-female-b.d.ts.map +0 -1
  136. package/dist/professional-female-b.js +0 -3
  137. package/dist/professional-female-b.js.map +0 -1
  138. package/dist/professional-male-a.d.ts +0 -4
  139. package/dist/professional-male-a.d.ts.map +0 -1
  140. package/dist/professional-male-a.js +0 -3
  141. package/dist/professional-male-a.js.map +0 -1
  142. package/dist/professional-male-b.d.ts +0 -4
  143. package/dist/professional-male-b.d.ts.map +0 -1
  144. package/dist/professional-male-b.js +0 -3
  145. package/dist/professional-male-b.js.map +0 -1
  146. package/dist/three/tara-rig.d.ts +0 -423
  147. package/dist/three/tara-rig.d.ts.map +0 -1
  148. package/dist/three/tara-rig.js.map +0 -1
  149. package/dist/vikram.d.ts +0 -7
  150. package/dist/vikram.d.ts.map +0 -1
  151. package/dist/vikram.js +0 -20
  152. package/dist/vikram.js.map +0 -1
  153. package/src/canvas/author/parts/eye.mjs +0 -722
  154. package/src/canvas/author/parts/hand.mjs +0 -1156
  155. package/src/canvas/author/parts/mouth.mjs +0 -741
  156. package/src/canvas/author/parts/nose.mjs +0 -100
  157. package/src/canvas/author/parts/skin-detail.mjs +0 -67
  158. package/src/canvas/author/path.mjs +0 -283
  159. package/src/canvas/author/rig.mjs +0 -405
  160. package/src/canvas/avatars/round/face.d.mts +0 -3
  161. package/src/canvas/avatars/round/face.mjs +0 -1307
  162. package/src/canvas/create-rig.d.ts +0 -15
  163. package/src/canvas/create-rig.js +0 -100
  164. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  165. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  166. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  167. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  168. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  169. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  170. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  171. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  172. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  173. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  174. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  175. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  176. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  177. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  178. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  179. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  180. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  181. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  182. package/src/canvas/data/interviewer-female.rig.json +0 -1
  183. package/src/canvas/data/interviewer-male.rig.json +0 -1
  184. package/src/canvas/data/professional-female-a.rig.json +0 -1
  185. package/src/canvas/data/professional-female-b.rig.json +0 -1
  186. package/src/canvas/data/professional-male-a.rig.json +0 -1
  187. package/src/canvas/data/professional-male-b.rig.json +0 -1
  188. package/src/canvas/src/live.js +0 -508
  189. package/src/canvas/src/render2d.js +0 -218
  190. package/src/canvas/src/rig.js +0 -297
  191. package/src/canvas/src/vocab.js +0 -96
@@ -1,31 +1,15 @@
1
1
  /**
2
- * tara's renderer: the `AvatarRig` contract (`apply(frame)` / `destroy()`)
3
- * over the Blender-authored GLB.
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
- import { REST } from "../internal.js";
12
+ import { JAW_OF_OPEN, REST, VISEME_SHAPES } from "../internal.js";
29
13
  import * as THREE from "three";
30
14
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
31
15
  import { HARD_BUDGET, pixelRatioFor } from "./budgets.js";
@@ -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. `build_tara.setup_scene` has the measurement.
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 of ±1.4.
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.
52
- *
53
- * **It opened on 2026-09-12, from yaw ±6°, pitch ±5°, roll ±3°.** That
54
- * envelope made Busso's neutral-speech numbers unreachable: his mean
55
- * per-sentence pitch *range* is 9.5°, which is 95 % of everything ±5° could
56
- * ever produce, so speaking alone would have swung the channel corner to corner
57
- * and a deliberate nod on top of it would have had nowhere left to go. What had
58
- * held it there was the asset, not anatomy — the neck's follow was linear and
59
- * drew a second jawline past a few degrees — and once that follow became exact
60
- * (`NECK_QUAD` below) pitch could open to 24 and roll to 8.
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.
61
31
  *
62
- * Yaw is still the smallest because it is the one axis this rig is genuinely
63
- * constrained on: the albedo is a front-orthographic projection of a 0.34-deep
64
- * shell, and a large turn is where that reads as a cardboard cutout rather than
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.
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`).
72
39
  *
73
- * This is not for speech: Busso wants ±1.15° of yaw in neutral conversation and
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.
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`).
77
43
  *
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 on 2026-09-18.
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, from `build_tara.PIVOT`, in glTF's Y-up frame:
121
- * Blender (x, y, z) exports as (x, z, −y). v 0.36 is the jaw angle and the
122
- * earlobe, and it sits a fifth of a face height *behind* the face plane —
123
- * a pivot on the surface spins the face in place, where a real yaw swings the
124
- * chin across as well as around, which is most of what makes a small turn read.
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. TARA-SPECIFIC: see `morphs.ROLL_PIVOT` for what fixed the height and
137
- * what a second avatar supplies.
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 `build_tara.HEAD_PARTS`. `Body` stays
142
- * behind, and so does `Neck` — but the neck is not *static*: it carries
143
- * `headYaw` / `headPitch` / `headRoll` morph targets of its own, ramped from
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).
80
+ *
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.
151
84
  *
152
- * Without it a turn dragged the skull's jaw rim across a throat that had not
153
- * moved, and the rim landed mid-neck as a second jawline — invisible at the
154
- * 400 × 300 tile, obvious at a 3× crop.
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`); the
162
- * amplitudes are set from the anatomy, which on this face is ~8.8 px per
163
- * centimetre at the 400 × 300 tile (crown to chin is 1.39 units of ~24 cm),
164
- * and land at about peep's travel as a share of the same tile. The first cut
165
- * was 0.6 of peep, on the theory that a photograph shows a millimetre a line
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 0.35 of a frame below the frame, as peep's
174
- * does about its hem, so the shoulder line comes up 2.4 px at full
175
- * inhale against a lower edge that moves two-thirds of that, and the
176
- * chest widens 2 px a side. Quiet breathing changes chest
177
- * circumference 2-3 %; 1.2 % wide is the calm end of that in linear
178
- * scale, and the rise is a little more because in this crop — about
179
- * 5 cm of chest below the collar — what a breath shows is the upper
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-torso-motion.md` § 8 item 4).
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: an
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 for tara, passed by `tara.ts` and the motion
260
- * audit so both measure the same face. A pose unit is an angle here and a
261
- * pixel count on an SVG face, so the speech layer's amplitudes are tuned per
262
- * rig rather than in the library: `prosodyHeadGain` sizes speech-rhythm head
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
- * `oculomotor` is the eye-head system sized for this face (`gaze.js`). Her eye
266
- * turns 10° a pupil unit and her head 6.4° of yaw a head unit, and the shared
267
- * look table — drawn for a line face, whose pupils cross most of an eye — put
268
- * every look in the eyes: a thinking look away was the iris parked in the
269
- * corner of the socket for two thirds of the state, which is side-eye, not
270
- * thought. Here the head carries about 60 % of a look and the eyes land a third
271
- * of the way off centre, where a real eye-head shift leaves them (Freedman &
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
- * (2026-09-15): her pitch is a shell tipping on a photograph and
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. TARA-SPECIFIC in its evidence only: a second Blender avatar
313
- * starts from 0.3 and checks its own outline at crop.
227
+ * neck sliding.
314
228
  */
315
- export const TARA_TUNING = {
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 hers rests neutral. TARA-SPECIFIC: the squint morph's
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.
282
+ // drawn smile, and a compiled face rests neutral.
376
283
  //
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.
384
- //
385
- // The lean deforms the trunk now, which is exactly the condition the old
386
- // note here predicted might afford more. It is left at 0.22 on purpose: the
387
- // cut was made by eye at crop, and putting it back is the same kind of
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. TARA-SPECIFIC, the same way as CANT_HEAR.
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,214 @@ 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
- /** `cavityShade`'s two measured heights, in the cavity patch's own normalised
502
- * rest height — 0 at the bottom of its bounding box, 1 at the top, morph deltas
503
- * included, because `computeBoundingBox` counts them and the shader normalises
504
- * by that same call.
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
- * At 8 the multiplier runs 0.51 at the top of that band to 2.32 at the bottom.
524
- * Neither clamp engages anywhere the aperture reaches; they are there so that a
525
- * future morph exposing more of the patch cannot blow the exponential up.
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 CAVITY_SHADE = { seam: 0.805, falloff: 8, min: 0.35, max: 2.6 };
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 inside of the mouth, shaded by how far the light has to reach into it.
530
- *
531
- * Both video reviewers called the open mouth "a dark void", and a luminance
532
- * profile says why in one number: through viseme D's aperture the cavity
533
- * renders ten consecutive rows inside a single level of 255 — 49.6 down to 48.7
534
- * on tara, 49.3 to 48.6 on tushar — between an upper arch at 232 and a lower
535
- * one at 163. Every other band in that column moves tens of levels per row. The
536
- * complaint is not that the interior is too dark, then. It is that it is the
537
- * one surface on this face with no variation in it at all, and a
538
- * constant-valued region reads as a hole cut in the head rather than as a space
539
- * behind it.
540
- *
541
- * It is flat because it is the only mouth surface that is genuinely *lit*.
542
- * `build_tara.flat_material` gives it no emissive term, where the teeth beside
543
- * it carry `emissiveFactor` 0.75 and their photograph's own light with it — so
544
- * all of the cavity comes from the lamps, and those are 0.62π of ambient
545
- * against a patch whose normal barely turns. Ambient on a constant normal is a
546
- * constant.
547
- *
548
- * This is authored rather than sampled, which is the wrong way round for this
549
- * repo and worth saying why: the reference is a *smile*, and a smile shows no
550
- * interior — the same fact that left the lower arch with no enamel to copy.
551
- * There is no photograph of this mouth's inside to project, so the choice is an
552
- * authored gradient or the flat colour, and it is kept modest for it.
429
+ * The photograph's enamel is 158 of 255 and the rendered upper arch's is about
430
+ * 232, so the profile is carried over at the ratio of the two: the interior is
431
+ * as dark *against the teeth* as hers is, which is the only comparison anyone
432
+ * makes looking into a mouth.
433
+ */
434
+ const INTERIOR_GAIN = 232 / 158;
435
+ /** The same, applied to the photograph in linear light, where its tile is read. */
436
+ const INTERIOR_GAIN_LINEAR = INTERIOR_GAIN ** 2.2;
437
+ /**
438
+ * How open her mouth is in that photograph: 185 px between the inner rims at the
439
+ * midline over 229 between the inner corners (MediaPipe 13/14 and 78/308). A
440
+ * speaking mouth is rarely a third of that, and the light reaching into it
441
+ * falls with the opening, so the profile is dimmed by the square root of the
442
+ * ratio — between the solid angle's own square law, which turned every
443
+ * conversational viseme into a hole, and none at all, which left each one the
444
+ * lit pink band reviewers called flat.
445
+ */
446
+ const INTERIOR_OPEN = 185 / 229;
447
+ /** That dimming, in GLSL, over the `uAperture` the mouth's shaders share. */
448
+ const OPENNESS = `sqrt(clamp(uAperture.w / (2.0 * uAperture.z * ${INTERIOR_OPEN.toFixed(3)}), 0.0, 1.0))`;
449
+ /**
450
+ * Where a raised tongue is read from: `tongue` = 1 paints the surface it lifts
451
+ * into the opening from `from` down to `at` — `at` the photograph's brightest
452
+ * row — rather than in the shadow of the upper arch it has moved into. A span
453
+ * and not a row: read at one row, every height of it took the same texels,
454
+ * and the photograph's tongue drew as vertical streaks down a slab. Squeezed
455
+ * into the span it keeps its crown, rolling off toward the teeth.
456
+ */
457
+ const TONGUE_LIFT = { from: 0.25, at: 0.60 };
458
+ /** An `INTERIOR` knot as linear light, at `INTERIOR_GAIN`. */
459
+ function interiorKnot(rgb) {
460
+ return new THREE.Color().setRGB(...rgb.map((v) => Math.min(1, (v * INTERIOR_GAIN) / 255)), THREE.SRGBColorSpace);
461
+ }
462
+ const glslColor = (c) => `vec3(${c.r.toFixed(5)}, ${c.g.toFixed(5)}, ${c.b.toFixed(5)})`;
463
+ /** GLSL: `vec3 interior`, the profile at depth `mouthA` in the opening. */
464
+ function interiorProfile() {
465
+ let profile = `vec3 interior = ${glslColor(interiorKnot(INTERIOR[0][1]))};\n`;
466
+ for (let i = 1; i < INTERIOR.length; i++) {
467
+ const [a0] = INTERIOR[i - 1];
468
+ const [a1, rgb] = INTERIOR[i];
469
+ profile += `interior = mix(interior, ${glslColor(interiorKnot(rgb))}, clamp((mouthA - ${a0.toFixed(3)}) / ${(a1 - a0).toFixed(3)}, 0.0, 1.0));\n`;
470
+ }
471
+ return profile;
472
+ }
473
+ /**
474
+ * The inside of the mouth — the tongue and the cavity behind it — painted from
475
+ * a photograph of one, at where each point sits in the lip opening the pose
476
+ * has made.
553
477
  *
554
- * Authored rather than derived, too. The true form factor from a flat backdrop
555
- * to the aperture in front of it is *brightest at the centre*, which is exactly
556
- * backwards: a real mouth is darkest in the middle because it is a tunnel
557
- * there, and this one is a curtain — `build_tara` parks it in front of the
558
- * teeth so a closed mouth has something dark to show, and walks it back past
559
- * them as the jaw drops. So this shades the mouth it stands for, not the
560
- * geometry it is drawn on.
478
+ * The opening, and not the surface, because that is how the photograph's
479
+ * profile arises: the light reaching into a mouth is gated by the lips and
480
+ * shadowed by the upper arch, so the same point of tongue is bright when the
481
+ * jaw drops and dark when it closes. A shade stuck to the surface — what this
482
+ * replaced — reads as a lit object behind a hole, which at viseme D was a flat
483
+ * mauve plate, and at C and H put the tongue's bright crest directly under the
484
+ * upper teeth with dark below it: the order of the photograph, inverted.
561
485
  *
562
- * The pivot is what makes that safe. `CAVITY_SHADE.seam` is the height the
563
- * closed mouth shows, so the multiplier is 1.0 there by construction and the
564
- * rest pose barely moves: measured over the whole mouth region, at most 4
565
- * levels of 255 on tara and 3 on tushar. Not nothing, and not worth claiming as
566
- * nothing — but it matters that it is small, because "a closed mouth is a dark
567
- * line" is this surface's first job, and `landmarks.PALETTE.cavity` is the
568
- * colour of that line and stays the authority on it.
486
+ * Emitted rather than lit, and entirely: this is sampled light, like the 75%
487
+ * of the face that is the photograph verbatim, and a key from above lights the
488
+ * dorsum brightest at its back, which is the one thing a mouth never looks
489
+ * like. Both surfaces read the same picture, so her mouth's absence of any
490
+ * tongue-to-cavity edge carries over; only a raised tongue is told apart, by
491
+ * `lift`, because a tongue tip at the teeth is what viseme H is.
492
+ */
493
+ function mouthInterior(base, aperture, lift, extent) {
494
+ const material = base.clone();
495
+ // The photograph itself where the asset carries it (`project_albedo.project_interior`),
496
+ // and its midline where it does not.
497
+ const tiled = material.map !== null && extent !== null;
498
+ let paint;
499
+ if (tiled) {
500
+ const [s0, s1, a0, a1] = extent;
501
+ paint = `vec2 tileAt = clamp(vec2((mouthX - ${s0.toFixed(3)}) / ${(s1 - s0).toFixed(3)},`
502
+ + ` (mouthA - ${a0.toFixed(3)}) / ${(a1 - a0).toFixed(3)}), 0.0, 1.0);\n`
503
+ + `vec3 interior = min(texture2D(map, tileAt).rgb * ${INTERIOR_GAIN_LINEAR.toFixed(4)}, vec3(1.0));\n`;
504
+ }
505
+ else {
506
+ paint = interiorProfile();
507
+ }
508
+ material.onBeforeCompile = (shader) => {
509
+ shader.uniforms.uAperture = aperture;
510
+ shader.uniforms.uMouthLift = lift;
511
+ shader.vertexShader = shader.vertexShader
512
+ .replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;")
513
+ // `transformed` after the morphs, unlike every other shade in this file:
514
+ // where the surface *is* in the opening is the whole question.
515
+ .replace("#include <morphtarget_vertex>", "#include <morphtarget_vertex>\nvMouthAt = transformed.xy;");
516
+ shader.fragmentShader = shader.fragmentShader
517
+ .replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;\nuniform vec4 uAperture;\nuniform float uMouthLift;")
518
+ .replace("#include <map_fragment>", "diffuseColor.rgb = vec3(0.0);")
519
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\n"
520
+ + "float mouthA0 = (uAperture.y - vMouthAt.y) / uAperture.w;\n"
521
+ + `float mouthA = mouthA0 < ${TONGUE_LIFT.at.toFixed(2)} ? mix(mouthA0, ${TONGUE_LIFT.from.toFixed(2)}`
522
+ + ` + max(mouthA0, 0.0) * ${((TONGUE_LIFT.at - TONGUE_LIFT.from) / TONGUE_LIFT.at).toFixed(4)}, uMouthLift) : mouthA0;\n`
523
+ + "float mouthX = (vMouthAt.x - uAperture.x) / uAperture.z;\n"
524
+ + paint
525
+ + `totalEmissiveRadiance = interior * ${OPENNESS};`);
526
+ };
527
+ material.customProgramCacheKey = () => (tiled ? "mouth-interior-tile" : "mouth-interior");
528
+ return material;
529
+ }
530
+ /**
531
+ * The lower arch, lit by the light the tongue beside it is lit by.
569
532
  *
570
- * Opening the mouth reveals the rest: darker above the seam, lighter below it.
571
- * Where those two halves actually land is not symmetric and not the same on the
572
- * two characters, because what hides the cavity is the upper arch, and the
573
- * arches differ. Down the middle of tara's mouth the arch reaches to roughly
574
- * the seam, so the centre gets the lighter half nearly alone — the ten flat
575
- * rows above become 50 at the top of the aperture rising to 83 at its bottom,
576
- * which is the floor the aperture faces. The darker half surfaces instead in
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.
533
+ * Its tile is toned against the upper arch on a smile (`project_albedo`), a
534
+ * mouth open wide and pulled back, where the lower teeth take nearly the upper
535
+ * ones' light. Speaking, they are the deepest thing the opening shows, and
536
+ * tess's "ah" — open wider than any viseme — shows no lower crown at all: the
537
+ * row above where they would be is the darkest in the photograph. Left at the
538
+ * smile's tone, the sliver a C or D uncovers was a lit grey rule between that
539
+ * dark and the lip, which reads as a wire and not as teeth.
582
540
  *
583
- * tushar gets the darker half down the centre as well, 49.3 to 43.6, because
584
- * his teeth are narrower — `TEETH.half_width` 0.110 against tara's 0.132 — so
585
- * his aperture exposes cavity above the seam in the middle too. One constant,
586
- * two characters, two different-looking mouths, and the difference between them
587
- * is the arch's width showing through. That is the argument for the constant
588
- * staying shared rather than being tuned per character: it is already reading a
589
- * per-character fact, just not one of its own.
541
+ * So the arch takes the interior's light where it stands: the profile at its
542
+ * depth in the opening over the profile's brightest row, which is the tongue
543
+ * lit as well as anything in a mouth is, and the same openness dimming. The
544
+ * ratio is in linear light and per channel, so the enamel goes as dark and as
545
+ * warm as the mouth around it, and its top edge — higher in the opening —
546
+ * keeps the most. The upper arch, at the lip and in the light, keeps its own.
590
547
  */
591
- function cavityShade(base, lo, hi) {
548
+ function lowerArch(base, aperture) {
592
549
  const material = base.clone();
593
- const span = { value: new THREE.Vector2(lo, (hi - lo) || 1) };
594
- const shade = {
595
- value: new THREE.Vector4(CAVITY_SHADE.seam, CAVITY_SHADE.falloff, CAVITY_SHADE.min, CAVITY_SHADE.max),
596
- };
550
+ 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
551
  material.onBeforeCompile = (shader) => {
598
- shader.uniforms.uCavitySpan = span;
599
- shader.uniforms.uCavityShade = shade;
552
+ shader.uniforms.uAperture = aperture;
600
553
  shader.vertexShader = shader.vertexShader
601
- .replace("#include <common>", "#include <common>\nvarying float vCavityAt;\nuniform vec2 uCavitySpan;")
602
- // `position`, not `transformed`: `morphs.cavity_targets` translates this
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;");
554
+ .replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;")
555
+ .replace("#include <morphtarget_vertex>", "#include <morphtarget_vertex>\nvMouthAt = transformed.xy;");
607
556
  shader.fragmentShader = shader.fragmentShader
608
- .replace("#include <common>", "#include <common>\nvarying float vCavityAt;\nuniform vec4 uCavityShade;")
609
- // No `emissivemap_fragment` half, unlike `jawShadow` and `socketed`:
610
- // those modulate surfaces that emit 75% of a photograph verbatim, and
611
- // this one has no emissive term at all. The diffuse is the whole output.
557
+ .replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;\nuniform vec4 uAperture;")
558
+ // Both terms: the teeth are three-quarters sampled light (`emissive`
559
+ // in the build's `flat_material`), so dimming the lit part alone does
560
+ // almost nothing.
612
561
  .replace("#include <map_fragment>", "#include <map_fragment>\n"
613
- + "float cavityK = exp(uCavityShade.y * (uCavityShade.x - vCavityAt));\n"
614
- + "diffuseColor.rgb *= clamp(cavityK, uCavityShade.z, uCavityShade.w);");
562
+ + "float mouthA = (uAperture.y - vMouthAt.y) / uAperture.w;\n"
563
+ + interiorProfile()
564
+ + `vec3 archLight = min(interior / ${glslColor(peak)}, vec3(1.0)) * ${OPENNESS};\n`
565
+ + "diffuseColor.rgb *= archLight;")
566
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= archLight;");
615
567
  };
616
- material.customProgramCacheKey = () => "tara-cavity-shade";
568
+ material.customProgramCacheKey = () => "lower-arch";
617
569
  return material;
618
570
  }
619
- /** `scripts/head_mesh.MOTION_DEPTH_ATTR`, as GLTFLoader names it: lowercased. */
620
- const MOTION_DEPTH = "_motion_depth";
621
571
  /**
622
- * The head's frame, taught to turn as if it sat deeper than it does.
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.
572
+ * The upper arch, with the gaps between its teeth showing the mouth.
633
573
  *
634
- * The attribute decides, not the mesh name: the neck shares the skin material
635
- * and has no field, which is why each head shell gets a clone. The same clone
636
- * wears the expression maps, when the asset has them: the three shells are
637
- * exactly the ones textured from the face atlas the maps are registered to.
574
+ * `project_albedo.project_teeth` paints everything under the incisal edge
575
+ * that is not a tooth — the notches between the tips — in `notch`, a neutral
576
+ * near-black, because the tile cannot know what the mouth behind it will be.
577
+ * Rendered, that strip came out a hard grey saw along the bottom of the arch:
578
+ * neutral against a warm interior, and at the arch's light rather than the
579
+ * mouth's. A gap in a row of teeth is a window onto the cavity, so it takes
580
+ * the cavity's own light at that height in the opening, and only the gap does
581
+ * — `notch` is darker than any enamel the tile carries, shaded overhang
582
+ * included, so the test is the texel's own brightness, and a filtered texel
583
+ * on a tip's edge takes a share of each.
638
584
  */
585
+ function upperArch(base, aperture) {
586
+ const material = base.clone();
587
+ material.onBeforeCompile = (shader) => {
588
+ shader.uniforms.uAperture = aperture;
589
+ shader.vertexShader = shader.vertexShader
590
+ .replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;")
591
+ .replace("#include <morphtarget_vertex>", "#include <morphtarget_vertex>\nvMouthAt = transformed.xy;");
592
+ shader.fragmentShader = shader.fragmentShader
593
+ .replace("#include <common>", "#include <common>\nvarying vec2 vMouthAt;\nuniform vec4 uAperture;")
594
+ .replace("#include <map_fragment>", "#include <map_fragment>\n"
595
+ + "float archGap = 1.0 - smoothstep(0.01, 0.30, dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)));\n"
596
+ + "diffuseColor.rgb *= 1.0 - archGap;")
597
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\n"
598
+ + "float mouthA = (uAperture.y - vMouthAt.y) / uAperture.w;\n"
599
+ + interiorProfile()
600
+ + `totalEmissiveRadiance = mix(totalEmissiveRadiance, interior * ${OPENNESS}, archGap);`);
601
+ };
602
+ material.customProgramCacheKey = () => "upper-arch";
603
+ return material;
604
+ }
605
+ /** `scripts/head_mesh.MOTION_DEPTH_ATTR`, as GLTFLoader names it: lowercased. */
606
+ const MOTION_DEPTH = "_motion_depth";
639
607
  /**
640
608
  * The vertex half of `motionDepth`, which the eye's socket shader needs too.
641
609
  *
@@ -651,9 +619,8 @@ function turnDeep(shader, undoGaze = false) {
651
619
  // (0, 0, Δz) into the screen plane and slides the hidden skirt out past the
652
620
  // temple on a look alone, head square on, where the skin it hides behind has
653
621
  // 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. A rotation's inverse
655
- // is its transpose, and a vector multiplied from the left is the transpose
656
- // multiply, so this needs no second uniform and no `transpose()`.
622
+ // `uGaze` is exactly the extra rotation to take back out — multiplying the
623
+ // vector from the left is its inverse, so this needs no second uniform.
657
624
  const depth = `vec3(0.0, 0.0, ${MOTION_DEPTH})`;
658
625
  shader.vertexShader = shader.vertexShader
659
626
  .replace("#include <common>", `#include <common>\nattribute float ${MOTION_DEPTH};`)
@@ -661,10 +628,39 @@ function turnDeep(shader, undoGaze = false) {
661
628
  + `mvPosition.xy += (modelViewMatrix * vec4(${undoGaze ? `${depth} * uGaze` : depth}, 0.0)).xy;\n`
662
629
  + "gl_Position = projectionMatrix * mvPosition;");
663
630
  }
664
- function motionDepth(base, expression) {
631
+ /**
632
+ * The head's frame, taught to turn as if it sat deeper than it does.
633
+ *
634
+ * Rotated at their real depth, the ears, crown, side hair and outline move
635
+ * nearly as far as the nose, and a nod reads as the whole head dropping
636
+ * (docs/research-head-rotation.md § 1). A real head's frame sits near the axis
637
+ * and barely moves, and that differential is what reads as rotation. Each
638
+ * vertex of the skin, hair and ears carries a Δz toward the viewer
639
+ * (`head_mesh.motion_depth`, ≤ 0 and zero across the features), and this moves
640
+ * it on screen by the rotated Δz: the view-space xy of modelView · (0, 0, Δz).
641
+ * Depth, draw order and lighting keep the real position. At rest that xy is
642
+ * exactly zero through the orthographic camera, so the drawing is untouched.
643
+ *
644
+ * The attribute decides, not the mesh name: the neck shares the skin material
645
+ * and has no field, which is why each head shell gets a clone. The same clone
646
+ * wears the expression maps, when the asset has them: the shells textured from
647
+ * the face atlas are exactly the ones those maps are registered to.
648
+ */
649
+ function motionDepth(base, expression, shut) {
665
650
  const material = base.clone();
666
651
  material.onBeforeCompile = (shader) => {
667
652
  turnDeep(shader);
653
+ if (shut) {
654
+ Object.assign(shader.uniforms, shut.uniforms);
655
+ shader.vertexShader = shader.vertexShader
656
+ .replace("#include <common>", `#include <common>\n${SHUT_VERTEX_HEAD}`)
657
+ .replace("#include <uv_vertex>", `#include <uv_vertex>\n${SHUT_VERTEX}`);
658
+ shader.fragmentShader = shader.fragmentShader
659
+ .replace("#include <common>", `#include <common>\n${SHUT_UNIFORMS}`)
660
+ .replace("#include <map_fragment>", `#include <map_fragment>\n${SHUT_FRAGMENT}`)
661
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\n"
662
+ + "totalEmissiveRadiance = mix(totalEmissiveRadiance, emissive * shutColour, shutWeight);");
663
+ }
668
664
  if (!expression)
669
665
  return;
670
666
  const n = expression.names.length;
@@ -674,38 +670,27 @@ function motionDepth(base, expression) {
674
670
  .replace("#include <map_fragment>", `#include <map_fragment>\n${EXPRESSION_FRAGMENT(n)}`)
675
671
  .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= expression;");
676
672
  };
677
- material.customProgramCacheKey = () => (expression ? `tara-motion-depth-expression-${expression.names.length}` : "tara-motion-depth");
673
+ material.customProgramCacheKey = () => ["tara-motion-depth", expression && `expression-${expression.names.length}`, shut && "shut"]
674
+ .filter(Boolean).join("-");
678
675
  return material;
679
676
  }
680
677
  /**
681
678
  * Expression maps: the light a smile or a raised brow changes, which the
682
679
  * geometry cannot.
683
680
  *
684
- * A pixel of this face is 75% photograph, emitted, and 25% lit (the lamps,
685
- * below), so the most a morph can change by moving skin is a quarter of its
686
- * shading — and the shading it moves is the neutral photograph's, which has no
687
- * nasolabial fold to deepen and no forehead line to show. A cheek morph
688
- * measured 0.00% of the tile changed. So an asset can carry, per expression, a
689
- * grey ratio taken from a photograph of the same face making it
690
- * (`scripts/expression_maps.py`): the fold's shadow, the cheek's lift into the
691
- * light, the lines of a raised brow, the two creases of a knit one. Here each
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.
681
+ * A pixel of this face is 75% photograph, emitted, so the most a morph can
682
+ * change by moving skin is a quarter of its shading — and that shading is the
683
+ * neutral photograph's, which has no nasolabial fold to deepen and no forehead
684
+ * line to show. A cheek morph measured 0.00% of the tile changed. So an asset
685
+ * can carry, per expression, a grey ratio taken from a photograph of the same
686
+ * face making it (`scripts/expression_maps.py`), read at the texel's *rest*
687
+ * position: the morph that lifts the cheek carries the lifted cheek's light up
688
+ * with its texture, so light and shape arrive together.
700
689
  *
701
690
  * Per side, because the channels are. The weights cross over at the midline
702
691
  * 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.
692
+ * light off in a line down the philtrum. An asset with no maps is a face whose
693
+ * light never changes, which is a correct face.
709
694
  */
710
695
  const EXPRESSION_SPLIT = 0.03;
711
696
  /**
@@ -720,8 +705,6 @@ const EXPRESSION_SPLIT = 0.03;
720
705
  * A map named here that the asset lacks is simply not read, and a map the
721
706
  * asset has that is not named here stays at weight 0: an asset newer than its
722
707
  * 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
708
  */
726
709
  const EXPRESSION_WEIGHT = {
727
710
  smile: (at) => at("mouthCorner") / 0.8,
@@ -781,29 +764,100 @@ function expressive(carrier) {
781
764
  },
782
765
  };
783
766
  }
767
+ /** `scripts/build_character.LID_SHUT_ATTR`'s fields, as GLTFLoader names
768
+ * them: the displacement's u and v, and the weight. */
769
+ const LID_SHUT_ATTRS = ["_lid_shut_u", "_lid_shut_v", "_lid_shut_w"];
770
+ /**
771
+ * The shut lids: a closing lid shows the photograph of the eyes closed.
772
+ *
773
+ * A 2.5-D shell has no hidden skin, so the lid that closes is the open eye's
774
+ * lid, stretched: the lash band and the fold drawn down over the eyeball in
775
+ * pale streaks, and a white edge where the stretched margin met the sclera. A
776
+ * reviewer watching a real call called it the eye tearing mid-blink, and at
777
+ * any distance it read as an eye that did not shut at all.
778
+ *
779
+ * So the build carries the neutral edited to close the eyes
780
+ * (`scripts/shut_lids.py`), and each lid vertex says where its texel lands
781
+ * when the lid is shut. A lid fragment reads that photograph *there*: shut,
782
+ * every pixel the lid covers is the closed photograph's own; half-shut, the
783
+ * margin already wears the closed lash line and the band above it closed lid
784
+ * skin, because that is what those texels become. The blend follows the lid's
785
+ * own influence, so a lid held part-way on purpose — a downward glance, a
786
+ * degraded link — changes a little of its content, and a blink all of it.
787
+ *
788
+ * How much each vertex may show is the build's to say (`_lid_shut_w`,
789
+ * `morphs.lid_shut_weight`): all of the upper lid, fading out above the crease
790
+ * with the lid's own pull, so the rest of the face is the photograph to the
791
+ * byte at any lid value, and not at rest at all.
792
+ */
793
+ // The lid's influence over which the closed photograph arrives. Not from 0, so
794
+ // the lid a glance lowers keeps nearly all of its own texture; full before the
795
+ // lid is, so the frames a blink is seen on are the closed photograph's.
796
+ const SHUT_FROM = 0.1;
797
+ const SHUT_FULL = 0.55;
798
+ // ...and over which the lower lid's lash fringe does (`_lid_shut_w` below 0).
799
+ // The lower lid hardly moves, so its fringe can only arrive with the upper
800
+ // margin: any earlier and a half-open eye wears it as a heavy lower liner.
801
+ const SHUT_FRINGE_FROM = 0.7;
802
+ const SHUT_FRINGE_FULL = 0.95;
803
+ const SHUT_VERTEX_HEAD = [
804
+ ...LID_SHUT_ATTRS.map((name) => `attribute float ${name};`),
805
+ "uniform vec2 uShutScale;", "varying vec2 vLidShut;", "varying float vLidShare;",
806
+ ].join("\n");
807
+ const SHUT_VERTEX = [
808
+ `vLidShut = vec2(${LID_SHUT_ATTRS[0]}, ${LID_SHUT_ATTRS[1]}) * uShutScale;`,
809
+ `vLidShare = ${LID_SHUT_ATTRS[2]};`,
810
+ ].join("\n");
811
+ const SHUT_UNIFORMS = [
812
+ "uniform sampler2D uShut;", "uniform vec4 uShutTile;", "uniform vec2 uShutU;",
813
+ "uniform vec2 uShutLid;", "uniform vec2 uShutFringe;", "varying vec2 vLidShut;", "varying float vLidShare;",
814
+ ].join("\n");
815
+ const SHUT_FRAGMENT = [
816
+ "vec2 shutAt = clamp(uShutTile.xz + uShutTile.yw * (vMapUv + vLidShut), 0.0, 1.0);",
817
+ "vec3 shutColour = texture2D(uShut, shutAt).rgb;",
818
+ `float shutSide = smoothstep(-${EXPRESSION_SPLIT}, ${EXPRESSION_SPLIT}, uShutU.x + uShutU.y * vMapUv.x);`,
819
+ "float shutWeight = max(vLidShare, 0.0) * mix(uShutLid.x, uShutLid.y, shutSide)",
820
+ " + max(-vLidShare, 0.0) * mix(uShutFringe.x, uShutFringe.y, shutSide);",
821
+ "diffuseColor.rgb = mix(diffuseColor.rgb, diffuse * shutColour, shutWeight);",
822
+ ].join("\n");
823
+ /** The closed photograph from its carrier's node, or `null` for an asset
824
+ * without one, whose lids shut on their own texels as they always have. */
825
+ function shutLids(carrier) {
826
+ const { shut_tile: tile, shut_u: u, shut_scale: scale } = carrier.userData;
827
+ const map = carrier.material.map;
828
+ if (!map || !Array.isArray(tile) || !Array.isArray(u) || !Array.isArray(scale))
829
+ return null;
830
+ const lid = new THREE.Vector2();
831
+ const fringe = new THREE.Vector2();
832
+ return {
833
+ map, lid, fringe,
834
+ uniforms: {
835
+ uShut: { value: map },
836
+ uShutTile: { value: new THREE.Vector4(tile[0], tile[1], tile[2], tile[3]) },
837
+ uShutU: { value: new THREE.Vector2(u[0], u[1]) },
838
+ uShutScale: { value: new THREE.Vector2(scale[0], scale[1]) },
839
+ uShutLid: { value: lid },
840
+ uShutFringe: { value: fringe },
841
+ },
842
+ };
843
+ }
784
844
  /**
785
845
  * The neck, taught to wear the jaw's shadow where the jaw is.
786
846
  *
787
847
  * The photograph paints the shadow the chin casts on the throat, and a painted
788
848
  * 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 move, which read as the neck sliding
790
- * out from under the head. So the
791
- * build lifts it out of the albedo into a ratio tile
792
- * (`project_albedo.lift_jaw_shadow`), and this puts it back from the head's
793
- * frame: each neck fragment finds the point of the *turned* head in front of it
794
- * — the view ray met with the plane the jaw's rim turns in — and reads the
795
- * ratio at that point's rest position. At rest that point is the fragment's
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.
849
+ * the neck by 8 px and its shadow did not, which read as the neck sliding out
850
+ * from under the head. So the build lifts it into a ratio tile
851
+ * (`project_albedo.lift_jaw_shadow`) and each neck fragment reads it at the
852
+ * *turned* head's rim, offset by how far the jaw has dropped — a rigid
853
+ * transform does not carry a morph, which was the other half of the same
854
+ * defect: an open mouth left the shadow banded across the throat at the closed
855
+ * rim with nothing casting it.
798
856
  *
799
- * The tile lives in the albedo atlas (`face_texture.JAW_SHADOW_AT`), the way
800
- * the eye's socket multiplier lives beside its globe, so it is one more read of
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.
857
+ * The tile's edges are white and the lookup clamped to it, so a ray landing
858
+ * past it reads "no shadow" rather than the hair or the iris beside it.
805
859
  */
806
- function jawShadow(base, uv, extent, rimZ, headInverse) {
860
+ function jawShadow(base, uv, extent, rimZ, headInverse, jawDrop) {
807
861
  const material = base.clone();
808
862
  const tile = { value: new THREE.Vector4(uv[0], uv[1], uv[2], uv[3]) };
809
863
  const bounds = { value: new THREE.Vector4(extent[0], extent[1], extent[2], extent[3]) };
@@ -813,16 +867,19 @@ function jawShadow(base, uv, extent, rimZ, headInverse) {
813
867
  shader.uniforms.uJawTile = tile;
814
868
  shader.uniforms.uJawBounds = bounds;
815
869
  shader.uniforms.uJawRim = rim;
870
+ shader.uniforms.uJawDrop = jawDrop;
816
871
  shader.vertexShader = shader.vertexShader
817
872
  .replace("#include <common>", "#include <common>\nvarying vec3 vJawView;")
818
873
  .replace("#include <project_vertex>", "#include <project_vertex>\nvJawView = mvPosition.xyz;");
819
874
  shader.fragmentShader = shader.fragmentShader
820
875
  .replace("#include <common>", "#include <common>\nvarying vec3 vJawView;\nuniform mat4 uHeadInverse;\n"
821
- + "uniform vec4 uJawTile;\nuniform vec4 uJawBounds;\nuniform float uJawRim;")
876
+ + "uniform vec4 uJawTile;\nuniform vec4 uJawBounds;\nuniform float uJawRim;\n"
877
+ + "uniform float uJawDrop;")
822
878
  .replace("#include <map_fragment>", "#include <map_fragment>\n"
823
879
  + "vec3 jawFrom = (uHeadInverse * vec4(vJawView, 1.0)).xyz;\n"
824
880
  + "vec3 jawRay = (uHeadInverse * vec4(0.0, 0.0, 1.0, 0.0)).xyz;\n"
825
881
  + "vec2 jawAt = jawFrom.xy + jawRay.xy * ((uJawRim - jawFrom.z) / jawRay.z);\n"
882
+ + "jawAt.y += uJawDrop;\n"
826
883
  + "jawAt = clamp(jawAt, uJawBounds.xz, uJawBounds.yw);\n"
827
884
  + "vec3 jawShadow = texture2D(map, uJawTile.xz + uJawTile.yw * jawAt).rgb;\n"
828
885
  + "diffuseColor.rgb *= jawShadow;")
@@ -831,7 +888,7 @@ function jawShadow(base, uv, extent, rimZ, headInverse) {
831
888
  material.customProgramCacheKey = () => "tara-jaw-shadow";
832
889
  return material;
833
890
  }
834
- /** Lamps, standing in for the four area lights `build_tara.setup_scene` uses.
891
+ /** Lamps, standing in for the four area lights `build_character.setup_scene` uses.
835
892
  *
836
893
  * The albedo is a photograph and already holds this face's light, so 75% of it
837
894
  * is emitted verbatim (`emissiveFactor` in the GLB) and only the remaining 25%
@@ -845,15 +902,6 @@ const AMBIENT = 0.62 * Math.PI;
845
902
  const KEY = 0.26 * Math.PI;
846
903
  const WRAP = 0.10 * Math.PI;
847
904
  /** 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
905
  *
858
906
  * Measured on an M1 over four paired reps against `peep`, which is the SVG
859
907
  * avatar that already ships: uncapped at 60 the 3-D face cost 13.8 points of
@@ -881,13 +929,26 @@ function webglRenderer(readback = false) {
881
929
  return null;
882
930
  }
883
931
  }
932
+ /**
933
+ * The lower lid follows the eye down. Its retractor is tied to the inferior
934
+ * rectus, so a look down pulls the lower margin down with it by a millimetre
935
+ * or two — the upper lid's half of this is the mixer's `lidBias`, and a face
936
+ * whose upper lid follows while the lower one stays reads as a drowsy droop
937
+ * rather than a glance. Driven through the squint target run backwards,
938
+ * which is the lower lid and nothing else: at the notes gaze (`pupilY` 0.72)
939
+ * the margin drops ~0.006, about 1.7 px at the 400 px tile.
940
+ */
941
+ const LOWER_LID_FOLLOW = 0.35;
884
942
  /**
885
943
  * 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 0.11–0.15 s triangle, the lid channel's
887
- * 18 ms smoothing rounds its peak off, and this rig draws at 30 fps, so the
888
- * frame a viewer actually sees peaks at influence 0.74 on the median blink and
889
- * 0.66 at the fifth percentile. A lid morph that shut only at 1 left every
890
- * blink a quarter open — the lid came down and the iris was still there.
944
+ * mixer never asks for 1: a blink is a triangle (`idle.BLINK_DUR`), the lid
945
+ * channel's 18 ms smoothing rounds its peak off, and this rig draws at 30 fps.
946
+ * `LID_SHUT` was set when blinks ran 0.11–0.15 s, at the fifth percentile of
947
+ * the peak a viewer actually saw (median 0.74). At the present 0.19–0.23 s
948
+ * that peak is 0.85 median and 0.81 at the fifth percentile — simulated over
949
+ * the same smoothing and frame phase — so every blink still lands shut, with
950
+ * margin. A lid morph that shut only at 1 left every blink a quarter open — the
951
+ * lid came down and the iris was still there.
891
952
  *
892
953
  * So above `LID_KNEE` the influence is eased up to meet 1 at `LID_SHUT`, and
893
954
  * held there: past that point the lid has landed on the lower one. Below the
@@ -898,40 +959,28 @@ function webglRenderer(readback = false) {
898
959
  *
899
960
  * `scripts/morphs.py:influence` parses both numbers out of this file.
900
961
  */
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
962
  const LID_KNEE = 0.35;
912
963
  const LID_SHUT = 0.66;
913
964
  /**
914
965
  * A squint's influence rises faster than its channel. The mixer's values are
915
966
  * set where a line face's lower lid reads — a smile's squint is 0.30 — and a
916
967
  * 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. A power curve lifts the small
918
- * values into sight. The lower-lid follow, which runs this target backwards,
919
- * is added after it and stays linear.
968
+ * squint that makes a smile real was not there. The lower-lid follow, which
969
+ * runs this target backwards, is added after the curve and stays linear.
920
970
  *
921
- * TARA-SPECIFIC. The curve needs a ceiling as well as a lift, because the
922
- * squint stacks and the mouth does not. A *silent* smile takes squint from
923
- * three layers at once — an approval clip, the encouraging emotion and
924
- * prosody's warmth — which reach 0.51 together, while the smile map saturates
925
- * at a mouth corner of 0.8 and no layer drives the jaw, so the last third of a
926
- * smile arrives as narrowing eyes over lips that cannot part any further. On a
927
- * line face that reads as warmth. On a photograph it reads as sedation: a
928
- * reviewer watching a recorded call read those two moments as the avatar
929
- * falling asleep or heavily medicated, and named the eyes, not the mouth. The
930
- * ceiling is where the face stops reading drugged, judged at crop. The knee is
931
- * low enough that an ordinary one-layer smile is untouched (0.22 renders
932
- * 0.402, against 0.403 with no ceiling at all), and the approach is
933
- * exponential rather than a clamp so the slope is continuous where the two
934
- * meet and the lower lid never visibly sticks.
971
+ * The curve needs a ceiling as well as a lift, because the squint stacks and
972
+ * the mouth does not. A *silent* smile takes squint from three layers at once —
973
+ * an approval clip, the encouraging emotion and prosody's warmth — which reach
974
+ * 0.51 together, while the smile map saturates at a mouth corner of 0.8 and no
975
+ * layer drives the jaw, so the last third of a smile arrives as narrowing eyes
976
+ * over lips that cannot part any further. On a line face that reads as warmth.
977
+ * On a photograph it reads as sedation: a reviewer watching a recorded call
978
+ * read those two moments as the avatar falling asleep or heavily medicated, and
979
+ * named the eyes, not the mouth. The ceiling is where the face stops reading
980
+ * drugged, judged at crop. The knee is low enough that an ordinary one-layer
981
+ * smile is untouched (0.22 renders 0.402 against 0.403 with no ceiling), and
982
+ * the approach is exponential rather than a clamp so the slope is continuous
983
+ * where the two meet and the lower lid never visibly sticks.
935
984
  */
936
985
  const SQUINT_CURVE = 0.6;
937
986
  const SQUINT_KNEE = 0.2;
@@ -954,17 +1003,6 @@ const lidClosure = (i) => {
954
1003
  const t = (i - LID_KNEE) / (LID_SHUT - LID_KNEE);
955
1004
  return i + (1 - LID_SHUT) * t * t;
956
1005
  };
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
1006
  /**
969
1007
  * The neck's follow targets, and the one place a morph is not driven by
970
1008
  * `influence`.
@@ -978,8 +1016,8 @@ const lidClosure = (i) => {
978
1016
  *
979
1017
  * So under pitch and roll the throat tracks the skull exactly at any angle,
980
1018
  * and — the part worth having — the asset stops depending on the envelope.
981
- * These fields carry no angle, so `HEAD_DEG` below is a runtime number that can
982
- * move without leaving `tara.glb` stale. Yaw's pair is a partial twist rather
1019
+ * These fields carry no angle, so `HEAD_DEG` above is a runtime number that can
1020
+ * move without leaving a built GLB stale. Yaw's pair is a partial twist rather
983
1021
  * than the skull's own rotation, and the build weights its two fields so these
984
1022
  * same two influences drive it (`morphs.neck_twist`).
985
1023
  */
@@ -998,40 +1036,24 @@ const neckInfluence = (channel, pose) => {
998
1036
  };
999
1037
  /**
1000
1038
  * The hair's roll, which is the one thing in this rig that is not a function of
1001
- * the pose alone.
1039
+ * the pose alone. `hold` is the share of the head's roll the hanging hair
1040
+ * declines to take, and `hz`/`damping` are how it gets there.
1002
1041
  *
1003
- * A hank that hangs past the jaw is lying on a shoulder, and a shoulder does not
1004
- * tilt when the head does. Rolled rigidly with the skull it lifts off the collar
1005
- * and the page shows through behind it, so the shell gives up `hold` of the
1006
- * roll at its lowest rows and none at the crown, graded by `morphs.hair_hold`.
1007
- * That is the static half and it is what fixes the gap.
1042
+ * A hank that hangs past the jaw is lying on a shoulder, and a shoulder does
1043
+ * not tilt when the head does: rolled rigidly with the skull it lifts off the
1044
+ * collar and the page shows through behind it, so the shell gives up `hold` of
1045
+ * the roll at its lowest rows and none at the crown (`morphs.hair_hold`).
1008
1046
  *
1009
1047
  * The other half is why roll read as a hinge at all. A rigid rotation about a
1010
- * fixed point is a hinge — there is nothing else in it — and what a real head
1011
- * tilt has that this lacked is hair that arrives late and settles. Live2D gives
1012
- * every hank a spring for exactly this (`docs/research-head-rotation.md` § 3.1:
1013
- * mobility ~0.95, delay 0.8-0.9, one clear overshoot), so this is a spring on
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
1048
+ * fixed point *is* a hinge, and what a real tilt has that this lacked is hair
1049
+ * that arrives late and settles (`docs/research-head-rotation.md` § 3.1:
1050
+ * mobility ~0.95, delay 0.8-0.9, one clear overshoot). At 0.65 that is a single
1051
+ * visible overshoot, inside 5% of the hold in 450 ms — faster reads as a flick,
1025
1052
  * slower as wet hair.
1026
- */
1027
- /**
1028
- * `hold` is the share of the head's roll the hanging hair declines to take, and
1029
- * `hz`/`damping` are how it gets there. 1.5 Hz is the ceiling the repo's idle
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.
1053
+ *
1054
+ * It is on the hair and not on `headRoll` on purpose: every clip in the library
1055
+ * is authored pre-compensated for the mixer's per-channel time constants, so a
1056
+ * spring on the channel would silently re-time every nod ever authored.
1035
1057
  */
1036
1058
  export const HAIR_ROLL = { hold: 0.85, hz: 1.5, damping: 0.65 };
1037
1059
  /**
@@ -1040,9 +1062,8 @@ export const HAIR_ROLL = { hold: 0.85, hz: 1.5, damping: 0.65 };
1040
1062
  * stiffness and 30 fps; this does not, which is the only reason the order of
1041
1063
  * those two lines is worth a sentence.
1042
1064
  *
1043
- * Exported for `test/nods.test.ts`, because settle time and overshoot are
1044
- * numbers and not something a still frame can show. It is not part of the
1045
- * package's surface — `packages/avatar/client/tara.ts` is.
1065
+ * Exported for `test/nods.test.ts`: settle time and overshoot are numbers, not
1066
+ * something a still frame can show.
1046
1067
  */
1047
1068
  export const hairRollStep = (angle, rate, target, dt) => {
1048
1069
  const w = 2 * Math.PI * HAIR_ROLL.hz;
@@ -1058,6 +1079,16 @@ const hairInfluence = (channel, extra) => {
1058
1079
  return 1 - Math.cos(extra);
1059
1080
  return null;
1060
1081
  };
1082
+ /**
1083
+ * A channel's morph influence — the same law `scripts/morphs.py:influence`
1084
+ * uses, so a Blender preview and the browser pose the face identically.
1085
+ *
1086
+ * It is allowed to go negative, which is what lets one target serve a
1087
+ * bidirectional channel: `mouthCornerL` at −1.4 is the smile target run
1088
+ * backwards into a frown, and `lidL` below its rest opens the eye wider than
1089
+ * neutral. A rig that clamped this at 0 would silently delete the negative half
1090
+ * of every channel that has one.
1091
+ */
1061
1092
  const influence = (channel, value) => {
1062
1093
  const rest = REST[channel] ?? 0;
1063
1094
  const i = (value - rest) / (1 - rest);
@@ -1065,6 +1096,27 @@ const influence = (channel, value) => {
1065
1096
  return lidClosure(i);
1066
1097
  return channel === "squintL" || channel === "squintR" ? squintCurve(i) : i;
1067
1098
  };
1099
+ /**
1100
+ * The jaw a closure lets through. This shell hangs the lower lip from the
1101
+ * mandible (`morphs._mandible`), and the jaw is the slower of the two to settle
1102
+ * (`JAW_RESPONSE_TAU_S`), so into an [m] the lips have shut while the jaw is
1103
+ * still coming up. That pulls the lower lip back off the upper, and the teeth
1104
+ * show through the seam. A real lower lip closes over a jaw that is still
1105
+ * open. A press is what a seal looks like on these channels, so in proportion
1106
+ * to the press past rest the jaw is held to what the lips' own aperture
1107
+ * implies. The cost is a chin that arrives with the lips rather than just
1108
+ * after them, and that is not what a viewer looks at during an [m].
1109
+ */
1110
+ const SEAL_FROM = VISEME_SHAPES.X.mouthPress ?? REST.mouthPress;
1111
+ const SEAL_FULL = VISEME_SHAPES.A.mouthPress ?? 1;
1112
+ function sealed(pose) {
1113
+ const { jaw, mouthOpen = 0, mouthPress } = pose;
1114
+ if (jaw === undefined || mouthPress === undefined)
1115
+ return pose;
1116
+ const seal = Math.min(1, Math.max(0, (mouthPress - SEAL_FROM) / (SEAL_FULL - SEAL_FROM)));
1117
+ const excess = jaw - JAW_OF_OPEN * mouthOpen;
1118
+ return seal > 0 && excess > 0 ? { ...pose, jaw: jaw - seal * excess } : pose;
1119
+ }
1068
1120
  /** One side's weights for the asset's expression maps, in its order. A
1069
1121
  * channel with no side (`mouthPress`) weighs the same on both. */
1070
1122
  const expressionWeights = (pose, side, expression, into) => {
@@ -1078,26 +1130,21 @@ const expressionWeights = (pose, side, expression, into) => {
1078
1130
  into[i] = Math.min(Math.max(weight, 0), 1);
1079
1131
  });
1080
1132
  };
1081
- // `options` is `unknown` in the contract, and stays `unknown` here: the mixer
1082
- // passes `rigOptions` through verbatim and has no way to know any rig's shape.
1083
- export function createTaraRig(mount, options) {
1133
+ export function createCharacterRig(mount, options) {
1084
1134
  const { onReady, url, expression: readExpression = true, readback = false } = (options ?? {});
1085
1135
  // A missing `url` is a caller's defect, not a browser condition — the WebGL
1086
1136
  // path below degrades because a driver is nobody's fault, whereas this would
1087
1137
  // otherwise be a 404 on a path spelled `undefined`.
1088
1138
  if (!url)
1089
- throw new TypeError("createTaraRig: `url` is required");
1139
+ throw new TypeError("createCharacterRig: `url` is required");
1090
1140
  const scene = new THREE.Scene();
1091
1141
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
1092
1142
  camera.position.set(0, FRAME_CENTRE, 6);
1093
1143
  camera.lookAt(0, FRAME_CENTRE, 0);
1094
1144
  const renderer = webglRenderer(readback);
1095
- // No context, no face — and that has to be the whole of it. `createAvatar` is
1096
- // synchronous and returns `{ destroy }`, so a consumer has nothing to catch:
1097
- // anything thrown here lands in *their* window and takes the call page with
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,
1145
+ // `createAvatar` is synchronous, so anything thrown here lands in the
1146
+ // consumer's window and takes the call page with it over a browser condition
1147
+ // that is nobody's defect. The right outcome is a call that still has audio,
1101
1148
  // captions and states, with an empty tile where the head would be.
1102
1149
  //
1103
1150
  // `warn` rather than `error` on purpose: `[avatar]` console errors mean a
@@ -1132,20 +1179,18 @@ export function createTaraRig(mount, options) {
1132
1179
  // `trunk` sways everything, and `lift` carries the neck and head on the
1133
1180
  // breath the torso takes inside `trunk` — and on the lean's rigid share,
1134
1181
  // 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
1182
  const trunk = new THREE.Group();
1142
1183
  const lift = new THREE.Group();
1143
1184
  const head = new THREE.Group();
1144
- head.position.copy(PIVOT);
1185
+ // The asset's own, once it has loaded; these hold the shape of the hierarchy
1186
+ // until then, and nothing draws before that.
1187
+ let pivot = PIVOT.clone();
1188
+ let rollPivot = ROLL_PIVOT.clone();
1189
+ head.position.copy(pivot);
1145
1190
  // Yaw and pitch turn `head`; roll turns `tilt`, which rides inside them at
1146
- // the chin (ROLL_PIVOT), so the parts hang from `tilt`.
1191
+ // the chin (`rollPivot`), so the parts hang from `tilt`.
1147
1192
  const tilt = new THREE.Group();
1148
- tilt.position.subVectors(ROLL_PIVOT, PIVOT);
1193
+ tilt.position.subVectors(rollPivot, pivot);
1149
1194
  scene.add(trunk);
1150
1195
  trunk.add(lift);
1151
1196
  lift.add(head);
@@ -1162,33 +1207,48 @@ export function createTaraRig(mount, options) {
1162
1207
  const eyes = [];
1163
1208
  // Both globes turn together, so one rotation serves both sockets.
1164
1209
  const gaze = { value: new THREE.Matrix3() };
1210
+ const lidShade = { L: { value: 1 }, R: { value: 1 } };
1165
1211
  const turn = new THREE.Matrix4();
1166
1212
  let expression = null;
1213
+ let shut = null;
1167
1214
  // The `Hair` shell, on a character whose hair hangs low enough to have the
1168
- // roll pair; null on one whose hair stops beside the temple (`HAIR_ROLL`).
1169
- let hairMesh = null;
1215
+ // roll pair, and the `HairLayer` over the body, on one whose hair is a layer
1216
+ // of its own; empty where the hair stops beside the temple (`HAIR_ROLL`).
1217
+ const hairMeshes = [];
1170
1218
  // The head's roll in the asset's own frame — what both the neck's follow and
1171
1219
  // the hair's are authored about — and the hair's own, which chases it.
1172
1220
  let headRollRad = 0;
1173
1221
  let hairRollRad = 0;
1174
1222
  let hairRate = 0;
1175
1223
  let hairSeeded = false;
1224
+ // How far the jaw's rim travels at jaw = 1 on this asset, and the uniform the
1225
+ // neck's shadow reads it through at the pose's influence (`jawShadow`).
1226
+ let jawRimTravel = 0;
1227
+ const jawDrop = { value: 0 };
1228
+ // The lip opening at rest and each channel's move of it, off the shell
1229
+ // (`build_character.lip_aperture`), as [top, bottom, left, right]; and what
1230
+ // `mouthInterior` reads it through, as (centre, top, half-width, height).
1231
+ let aperture = null;
1232
+ const mouthOpening = { value: new THREE.Vector4(0, 0, 1, 1) };
1233
+ const tongueLift = { value: 0 };
1176
1234
  /** The share of the head's roll the hair settles at. */
1177
1235
  const hairTarget = () => headRollRad * (1 - HAIR_ROLL.hold);
1178
1236
  const writeHair = () => {
1179
- const dictionary = hairMesh?.morphTargetDictionary;
1180
- const influences = hairMesh?.morphTargetInfluences;
1181
- if (!dictionary || !influences)
1182
- return;
1183
1237
  const extra = hairRollRad - headRollRad;
1184
- for (const [channel, index] of Object.entries(dictionary)) {
1185
- const term = hairInfluence(channel, extra);
1186
- if (term !== null)
1187
- influences[index] = term;
1238
+ for (const mesh of hairMeshes) {
1239
+ const dictionary = mesh.morphTargetDictionary;
1240
+ const influences = mesh.morphTargetInfluences;
1241
+ if (!dictionary || !influences)
1242
+ continue;
1243
+ for (const [channel, index] of Object.entries(dictionary)) {
1244
+ const term = hairInfluence(channel, extra);
1245
+ if (term !== null)
1246
+ influences[index] = term;
1247
+ }
1188
1248
  }
1189
1249
  };
1190
1250
  const stepHair = (dt) => {
1191
- if (!hairMesh || !hairSeeded)
1251
+ if (!hairMeshes.length || !hairSeeded)
1192
1252
  return;
1193
1253
  ({ angle: hairRollRad, rate: hairRate } =
1194
1254
  hairRollStep(hairRollRad, hairRate, hairTarget(), dt));
@@ -1202,9 +1262,10 @@ export function createTaraRig(mount, options) {
1202
1262
  // of the wrong aspect shows more or less background rather than a face of
1203
1263
  // the wrong shape. The atlas cannot be stretched: it is a photograph.
1204
1264
  // Symmetric about the camera, which is *already* at the frame's centre
1205
- // height. Offsetting the frustum by that centre as well applies it twice:
1206
- // the face rendered 0.47 face heights low, at exactly the right size, which
1207
- // reads as a framing choice rather than as the arithmetic error it was.
1265
+ // height — offsetting the frustum by that centre as well applies it twice,
1266
+ // and the face rendered 0.47 face heights low, at exactly the right size,
1267
+ // which reads as a framing choice rather than as the arithmetic error it
1268
+ // was.
1208
1269
  const halfHeight = FRAME_HEIGHT / 2;
1209
1270
  const halfWidth = (halfHeight * width) / height;
1210
1271
  camera.top = halfHeight;
@@ -1216,7 +1277,8 @@ export function createTaraRig(mount, options) {
1216
1277
  const observer = new ResizeObserver(resize);
1217
1278
  observer.observe(mount);
1218
1279
  resize();
1219
- const applyPose = (pose) => {
1280
+ const applyPose = (given) => {
1281
+ const pose = sealed(given);
1220
1282
  headRollRad = radians(((pose.headRoll ?? 0) / HEAD_CLAMP) * HEAD_DEG.roll);
1221
1283
  // The first pose is a starting point, not a movement: seed the hair where it
1222
1284
  // would have settled, so a tool that sets one pose and screenshots it gets
@@ -1232,7 +1294,7 @@ export function createTaraRig(mount, options) {
1232
1294
  if (!dictionary || !influences)
1233
1295
  continue;
1234
1296
  const neck = mesh.name === "Neck";
1235
- const hair = mesh === hairMesh;
1297
+ const hair = hairMeshes.includes(mesh);
1236
1298
  for (const [channel, index] of Object.entries(dictionary)) {
1237
1299
  // The hair carries the head's roll channel too, and it is neither a
1238
1300
  // channel value nor the head's own angle: it is how much *further* than
@@ -1246,9 +1308,11 @@ export function createTaraRig(mount, options) {
1246
1308
  }
1247
1309
  }
1248
1310
  // The neck's head targets are the rotation's two terms, not a channel
1249
- // scaled by the influence law. Only on the neck: the same three channel
1250
- // names on the head group are a rigid transform, and nowhere else.
1251
- if (neck) {
1311
+ // scaled by the influence law. Only on the neck and the hair layer's
1312
+ // yaw hold (`morphs.hair_layer_targets`), whose field carries the hold
1313
+ // and takes the skull's own angle: the same three channel names on the
1314
+ // head group are a rigid transform, and nowhere else.
1315
+ if (neck || (hair && mesh.name === "HairLayer")) {
1252
1316
  const term = neckInfluence(channel, pose);
1253
1317
  if (term !== null) {
1254
1318
  influences[index] = term;
@@ -1262,6 +1326,24 @@ export function createTaraRig(mount, options) {
1262
1326
  influences[index] = (value === undefined ? 0 : influence(channel, value)) - follow;
1263
1327
  }
1264
1328
  }
1329
+ // The chin's shadow on the throat rides the same channel the chin does.
1330
+ jawDrop.value = jawRimTravel
1331
+ * (pose.jaw === undefined ? 0 : influence("jaw", pose.jaw));
1332
+ if (aperture) {
1333
+ const at = aperture.rest.slice();
1334
+ for (const [channel, delta] of aperture.deltas) {
1335
+ const value = pose[channel];
1336
+ if (value === undefined)
1337
+ continue;
1338
+ const k = influence(channel, value);
1339
+ for (let i = 0; i < 4; i++)
1340
+ at[i] += k * delta[i];
1341
+ }
1342
+ // A floor on the height so a shut mouth divides by something: the seam
1343
+ // it shows is a line, and what colour a line is does not read.
1344
+ 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));
1345
+ }
1346
+ tongueLift.value = pose.tongue === undefined ? 0 : influence("tongue", pose.tongue);
1265
1347
  // Blender's Z is face-space v, so its yaw is about Z, its pitch about X and
1266
1348
  // its roll about Y. The export maps Blender (x, y, z) to glTF (x, z, −y),
1267
1349
  // so Blender +Z *is* glTF +Y and Blender +X is glTF +X: yaw and pitch carry
@@ -1269,15 +1351,9 @@ export function createTaraRig(mount, options) {
1269
1351
  // is glTF −Z, and that is a statement about two axes and not about the
1270
1352
  // channel.
1271
1353
  //
1272
- // Yaw was negated here as well until 2026-09-10, on the belief that the
1273
- // export flips the handedness of a turn. It does not — both frames are
1274
- // right-handed — and the cost was a head that turned toward the viewer's
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.
1354
+ // Yaw is not negated, and a negation here survived for a long time because
1355
+ // `build_character.pose_head` had the same one: the Blender preview and the
1356
+ // browser agreed with each other and only disagreed with the library.
1281
1357
  //
1282
1358
  // YXZ because that is the order a neck composes in — yaw carrying the pitch
1283
1359
  // — rather than the order three.js defaults to.
@@ -1314,14 +1390,42 @@ export function createTaraRig(mount, options) {
1314
1390
  }
1315
1391
  if (eyes.length)
1316
1392
  gaze.value.setFromMatrix4(turn.makeRotationFromEuler(eyes[0].rotation));
1393
+ for (const side of ["L", "R"]) {
1394
+ const value = pose[`lid${side}`];
1395
+ const i = value === undefined ? 0 : influence(`lid${side}`, value);
1396
+ lidShade[side].value = 1 - LID_SHADE * THREE.MathUtils.smoothstep(i, LID_SHADE_FROM, 1);
1397
+ }
1317
1398
  if (expression) {
1318
1399
  expressionWeights(pose, "L", expression, expression.left);
1319
1400
  expressionWeights(pose, "R", expression, expression.right);
1320
1401
  }
1402
+ if (shut) {
1403
+ const closure = (channel) => {
1404
+ const value = pose[channel];
1405
+ return value === undefined ? 0 : influence(channel, value);
1406
+ };
1407
+ const [l, r] = [closure("lidL"), closure("lidR")];
1408
+ const { smoothstep } = THREE.MathUtils;
1409
+ shut.lid.set(smoothstep(l, SHUT_FROM, SHUT_FULL), smoothstep(r, SHUT_FROM, SHUT_FULL));
1410
+ shut.fringe.set(smoothstep(l, SHUT_FRINGE_FROM, SHUT_FRINGE_FULL), smoothstep(r, SHUT_FRINGE_FROM, SHUT_FRINGE_FULL));
1411
+ }
1321
1412
  };
1322
1413
  new GLTFLoader().load(url, (gltf) => {
1323
1414
  if (destroyed)
1324
1415
  return;
1416
+ // Where this head turns and tilts, and what rides its skull, are facts
1417
+ // about this head — measured by the build, stamped into the scene, and read
1418
+ // here before anything is reparented into the frames they define. An asset
1419
+ // that predates the stamp keeps the constants above, which are the numbers
1420
+ // it was built with.
1421
+ const stamp = (gltf.scene.userData ?? {});
1422
+ pivot = stampedVec(stamp, "head_pivot", PIVOT);
1423
+ rollPivot = stampedVec(stamp, "roll_pivot", ROLL_PIVOT);
1424
+ head.position.copy(pivot);
1425
+ tilt.position.subVectors(rollPivot, pivot);
1426
+ const headParts = Array.isArray(stamp.head_parts)
1427
+ && stamp.head_parts.every((n) => typeof n === "string")
1428
+ ? stamp.head_parts : HEAD_PARTS;
1325
1429
  trunk.add(gltf.scene);
1326
1430
  // Collect the morphed meshes *before* reparenting: every one of them is a
1327
1431
  // head part, so a traverse of `gltf.scene` after the move finds only the
@@ -1333,8 +1437,16 @@ export function createTaraRig(mount, options) {
1333
1437
  morphed.push(mesh);
1334
1438
  // Kept aside as well: its roll pair is driven by a clock and not only by a
1335
1439
  // pose, so the render loop has to reach it between poses (`HAIR_ROLL`).
1336
- if (mesh.isMesh && mesh.name === "Hair" && mesh.morphTargetDictionary)
1337
- hairMesh = mesh;
1440
+ if (mesh.isMesh && (mesh.name === "Hair" || mesh.name === "HairLayer")
1441
+ && mesh.morphTargetDictionary)
1442
+ hairMeshes.push(mesh);
1443
+ // Hair that lies over the body is in front of everything the portrait
1444
+ // has, and it turns with the skull where the chest does not: tested
1445
+ // against depth, a nod swings the lower hank back through the chest.
1446
+ if (mesh.isMesh && mesh.name === "HairLayer") {
1447
+ mesh.material.depthTest = false;
1448
+ mesh.renderOrder = 1;
1449
+ }
1338
1450
  });
1339
1451
  // Out of the scene before anything draws it: it carries the maps, and is
1340
1452
  // one triangle behind the body that nothing should pay a draw call for.
@@ -1347,27 +1459,42 @@ export function createTaraRig(mount, options) {
1347
1459
  carrier.geometry.dispose();
1348
1460
  carrier.material.dispose();
1349
1461
  }
1350
- for (const name of HEAD_PARTS) {
1462
+ const shutCarrier = gltf.scene.getObjectByName("Shut");
1463
+ if (shutCarrier) {
1464
+ shutCarrier.removeFromParent();
1465
+ shut = shutLids(shutCarrier);
1466
+ if (!shut)
1467
+ shutCarrier.material.map?.dispose();
1468
+ shutCarrier.geometry.dispose();
1469
+ shutCarrier.material.dispose();
1470
+ }
1471
+ for (const name of headParts) {
1351
1472
  const part = gltf.scene.getObjectByName(name);
1352
1473
  // Reparenting moves the object into the tilt's frame, whose origin is
1353
- // ROLL_PIVOT, so subtract that to leave the part where it was authored.
1354
- // `attach()` would do this from the world matrix, which has not been
1355
- // computed yet at load.
1474
+ // the roll pivot, so subtract that to leave the part where it was
1475
+ // authored. `attach()` would do this from the world matrix, which has
1476
+ // not been computed yet at load.
1356
1477
  if (part) {
1357
- part.position.sub(ROLL_PIVOT);
1478
+ part.position.sub(rollPivot);
1358
1479
  tilt.add(part);
1359
1480
  }
1360
1481
  }
1361
1482
  // One clone per source material, shared by every shell that carries the
1362
1483
  // field; a mesh without it keeps the material it came with.
1484
+ // The shell with the lids is a clone of its own: the ears and the hair
1485
+ // share its material and have no lid attributes to read.
1363
1486
  const turned = new Map();
1487
+ const lidded = new Map();
1364
1488
  head.traverse((object) => {
1365
1489
  const mesh = object;
1366
1490
  if (!mesh.isMesh || !mesh.geometry.getAttribute(MOTION_DEPTH))
1367
1491
  return;
1368
1492
  const base = mesh.material;
1369
- const own = turned.get(base) ?? motionDepth(base, expression ?? undefined);
1370
- turned.set(base, own);
1493
+ const withLids = shut !== null && LID_SHUT_ATTRS.every((name) => mesh.geometry.getAttribute(name) !== undefined);
1494
+ const cache = withLids ? lidded : turned;
1495
+ const own = cache.get(base)
1496
+ ?? motionDepth(base, expression ?? undefined, withLids ? shut ?? undefined : undefined);
1497
+ cache.set(base, own);
1371
1498
  mesh.material = own;
1372
1499
  });
1373
1500
  // The neck rides the breath with the head; the torso *is* the breath. Both
@@ -1376,13 +1503,16 @@ export function createTaraRig(mount, options) {
1376
1503
  const neck = gltf.scene.getObjectByName("Neck");
1377
1504
  if (neck)
1378
1505
  lift.add(neck);
1379
- // The shadow's map comes from the build (`build_tara.py`, the neck); an
1506
+ // The shadow's map comes from the build (`build_character.py`, the neck); an
1380
1507
  // asset without it keeps the shadow painted on, as every build did before.
1381
1508
  const skull = head.getObjectByName("Head");
1382
- const { jaw_shadow_uv: jawUv, jaw_shadow_extent: jawExtent, jaw_shadow_rim_z: jawRim } = neck?.userData ?? {};
1509
+ const { jaw_shadow_uv: jawUv, jaw_shadow_extent: jawExtent, jaw_shadow_rim_z: jawRim, jaw_shadow_drop: jawTravel } = neck?.userData ?? {};
1383
1510
  if (neck && skull && Array.isArray(jawUv) && Array.isArray(jawExtent) && typeof jawRim === "number") {
1384
1511
  const headInverse = { value: new THREE.Matrix4() };
1385
- neck.material = jawShadow(neck.material, jawUv, jawExtent, jawRim, headInverse);
1512
+ // A build that predates the travel keeps the shadow rotating but not
1513
+ // opening, which is where this started and is still better than no tile.
1514
+ jawRimTravel = typeof jawTravel === "number" ? jawTravel : 0;
1515
+ neck.material = jawShadow(neck.material, jawUv, jawExtent, jawRim, headInverse, jawDrop);
1386
1516
  neck.onBeforeRender = (_renderer, _scene, camera) => {
1387
1517
  headInverse.value.multiplyMatrices(camera.matrixWorldInverse, skull.matrixWorld).invert();
1388
1518
  };
@@ -1408,20 +1538,32 @@ export function createTaraRig(mount, options) {
1408
1538
  // Whether the globe turns deep is the attribute's to say, not the name's —
1409
1539
  // the same rule the frame's shells are found by above. An asset built
1410
1540
  // 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);
1541
+ 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
1542
  eyes.push(globe);
1413
1543
  }
1414
- // The mouth's inside, which is otherwise the one flat-lit surface on this
1415
- // face. Found by *mesh* name rather than material name: `flat_material`
1416
- // hard-codes a `tara_` prefix, so tushar's cavity material is called
1417
- // `tara_cavity` as well, and the mesh is what distinguishes it.
1418
- const cavity = head.getObjectByName("Cavity");
1419
- if (cavity) {
1420
- cavity.geometry.computeBoundingBox();
1421
- const box = cavity.geometry.boundingBox;
1422
- if (box) {
1423
- cavity.material = cavityShade(cavity.material, box.min.y, box.max.y);
1544
+ // The mouth's inside, painted against the opening the lips make
1545
+ // (`mouthInterior`). Found by *mesh* name rather than material name:
1546
+ // `flat_material` hard-codes a `tara_` prefix, so tushar's cavity material
1547
+ // is called `tara_cavity` as well, and the mesh is what distinguishes it.
1548
+ // An asset built before the shell carried its opening keeps the flat fill.
1549
+ const opening = skull?.userData?.aperture;
1550
+ if (opening && Array.isArray(opening.rest) && opening.rest.length === 4) {
1551
+ aperture = { rest: opening.rest,
1552
+ deltas: Object.entries(opening.deltas ?? {})
1553
+ .filter((e) => Array.isArray(e[1]) && e[1].length === 4) };
1554
+ for (const [name, lift] of [["Cavity", { value: 0 }], ["Tongue", tongueLift]]) {
1555
+ const mesh = head.getObjectByName(name);
1556
+ if (!mesh)
1557
+ continue;
1558
+ const extent = mesh.userData?.interior_extent;
1559
+ mesh.material = mouthInterior(mesh.material, mouthOpening, lift, Array.isArray(extent) && extent.length === 4 ? extent : null);
1424
1560
  }
1561
+ const lower = head.getObjectByName("Teeth_Lower");
1562
+ if (lower)
1563
+ lower.material = lowerArch(lower.material, mouthOpening);
1564
+ const upper = head.getObjectByName("Teeth_Upper");
1565
+ if (upper)
1566
+ upper.material = upperArch(upper.material, mouthOpening);
1425
1567
  }
1426
1568
  // A rig with no morph targets still renders a perfectly good rest pose, so
1427
1569
  // the failure mode of losing them is a face that simply never moves — which
@@ -1434,15 +1576,11 @@ export function createTaraRig(mount, options) {
1434
1576
  applyPose(pending);
1435
1577
  onReady?.();
1436
1578
  }, undefined, (error) => console.error("[avatar] could not load this character", url, error));
1437
- // The tolerance is not a fudge factor, it is the whole of what makes the cap
1438
- // land on 30. rAF fires on the panel's own grid, so the elapsed time is only
1439
- // ever a multiple of the refresh interval and lands *near* 33.3 ms rather than
1440
- // on it: 33.33 on a 60 Hz panel, and either side of it under any timestamp
1441
- // jitter. A bare `elapsed < MIN_FRAME_MS` therefore rejects the frame it wants
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.
1579
+ // rAF fires on the panel's own grid, so elapsed lands *near* the frame
1580
+ // interval and never on it, and a bare `elapsed < MIN_FRAME_MS` rejects the
1581
+ // frame it wants and waits for the next — 20 fps, not 30. The tolerance is
1582
+ // under half the interval at 120, 90 and 60 Hz, so it can never admit two
1583
+ // frames where one belongs.
1446
1584
  const GRID_TOLERANCE_MS = 4;
1447
1585
  let lastFrameMs = 0;
1448
1586
  renderer.setAnimationLoop(() => {
@@ -1497,9 +1635,10 @@ export function createTaraRig(mount, options) {
1497
1635
  });
1498
1636
  // A uniform, not a material's map, so the traverse above never meets it.
1499
1637
  expression?.map.dispose();
1638
+ shut?.map.dispose();
1500
1639
  renderer.dispose();
1501
1640
  renderer.domElement.remove();
1502
1641
  },
1503
1642
  };
1504
1643
  }
1505
- //# sourceMappingURL=tara-rig.js.map
1644
+ //# sourceMappingURL=character-rig.js.map