@voqalize/avatar 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/LICENSE +21 -661
  2. package/LICENSE-CC-BY-4.0 +396 -0
  3. package/README.md +191 -662
  4. package/assets/README.md +48 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tushar.glb +0 -0
  8. package/client/Avatar.tsx +57 -0
  9. package/client/AvatarClient.ts +818 -0
  10. package/client/arjun.ts +26 -0
  11. package/client/createAvatar.ts +177 -0
  12. package/client/createCanvasAvatar.ts +72 -0
  13. package/client/index.ts +44 -0
  14. package/client/internal.ts +108 -0
  15. package/client/interviewer-female.ts +4 -0
  16. package/client/interviewer-male.ts +4 -0
  17. package/client/ishita.ts +26 -0
  18. package/client/kabir.ts +26 -0
  19. package/client/meera.ts +26 -0
  20. package/client/naina.ts +26 -0
  21. package/client/playout.ts +95 -0
  22. package/client/professional-female-a.ts +4 -0
  23. package/client/professional-female-b.ts +4 -0
  24. package/client/professional-male-a.ts +4 -0
  25. package/client/professional-male-b.ts +4 -0
  26. package/client/react.ts +13 -0
  27. package/client/supports.ts +28 -0
  28. package/client/three/assets.ts +24 -0
  29. package/client/three/budgets.ts +27 -0
  30. package/client/three/holds.ts +64 -0
  31. package/client/three/internal.ts +54 -0
  32. package/client/three/motion-limits.json +116 -0
  33. package/client/three/sequences.ts +281 -0
  34. package/client/three/tanya.ts +69 -0
  35. package/client/three/tara-rig.ts +1562 -0
  36. package/client/three/tara.ts +76 -0
  37. package/client/three/tushar.ts +65 -0
  38. package/client/types.ts +235 -0
  39. package/client/useAvatar.ts +74 -0
  40. package/client/vikram.ts +26 -0
  41. package/dist/Avatar.d.ts +44 -0
  42. package/dist/Avatar.d.ts.map +1 -0
  43. package/{client/dist → dist}/Avatar.js +2 -2
  44. package/dist/Avatar.js.map +1 -0
  45. package/dist/AvatarClient.d.ts +274 -0
  46. package/dist/AvatarClient.d.ts.map +1 -0
  47. package/dist/AvatarClient.js +712 -0
  48. package/dist/AvatarClient.js.map +1 -0
  49. package/dist/arjun.d.ts +7 -0
  50. package/dist/arjun.d.ts.map +1 -0
  51. package/dist/arjun.js +20 -0
  52. package/dist/arjun.js.map +1 -0
  53. package/dist/createAvatar.d.ts +135 -0
  54. package/dist/createAvatar.d.ts.map +1 -0
  55. package/dist/createAvatar.js +75 -0
  56. package/dist/createAvatar.js.map +1 -0
  57. package/dist/createCanvasAvatar.d.ts +22 -0
  58. package/dist/createCanvasAvatar.d.ts.map +1 -0
  59. package/dist/createCanvasAvatar.js +47 -0
  60. package/dist/createCanvasAvatar.js.map +1 -0
  61. package/dist/index.d.ts +34 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +33 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/internal.d.ts +34 -0
  66. package/dist/internal.d.ts.map +1 -0
  67. package/dist/internal.js +41 -0
  68. package/dist/internal.js.map +1 -0
  69. package/dist/interviewer-female.d.ts +4 -0
  70. package/dist/interviewer-female.d.ts.map +1 -0
  71. package/dist/interviewer-female.js +3 -0
  72. package/dist/interviewer-female.js.map +1 -0
  73. package/dist/interviewer-male.d.ts +4 -0
  74. package/dist/interviewer-male.d.ts.map +1 -0
  75. package/dist/interviewer-male.js +3 -0
  76. package/dist/interviewer-male.js.map +1 -0
  77. package/dist/ishita.d.ts +7 -0
  78. package/dist/ishita.d.ts.map +1 -0
  79. package/dist/ishita.js +20 -0
  80. package/dist/ishita.js.map +1 -0
  81. package/dist/kabir.d.ts +7 -0
  82. package/dist/kabir.d.ts.map +1 -0
  83. package/dist/kabir.js +20 -0
  84. package/dist/kabir.js.map +1 -0
  85. package/dist/meera.d.ts +7 -0
  86. package/dist/meera.d.ts.map +1 -0
  87. package/dist/meera.js +20 -0
  88. package/dist/meera.js.map +1 -0
  89. package/dist/naina.d.ts +7 -0
  90. package/dist/naina.d.ts.map +1 -0
  91. package/dist/naina.js +20 -0
  92. package/dist/naina.js.map +1 -0
  93. package/dist/playout.d.ts +42 -0
  94. package/dist/playout.d.ts.map +1 -0
  95. package/dist/playout.js +85 -0
  96. package/dist/playout.js.map +1 -0
  97. package/dist/professional-female-a.d.ts +4 -0
  98. package/dist/professional-female-a.d.ts.map +1 -0
  99. package/dist/professional-female-a.js +3 -0
  100. package/dist/professional-female-a.js.map +1 -0
  101. package/dist/professional-female-b.d.ts +4 -0
  102. package/dist/professional-female-b.d.ts.map +1 -0
  103. package/dist/professional-female-b.js +3 -0
  104. package/dist/professional-female-b.js.map +1 -0
  105. package/dist/professional-male-a.d.ts +4 -0
  106. package/dist/professional-male-a.d.ts.map +1 -0
  107. package/dist/professional-male-a.js +3 -0
  108. package/dist/professional-male-a.js.map +1 -0
  109. package/dist/professional-male-b.d.ts +4 -0
  110. package/dist/professional-male-b.d.ts.map +1 -0
  111. package/dist/professional-male-b.js +3 -0
  112. package/dist/professional-male-b.js.map +1 -0
  113. package/dist/react.d.ts +13 -0
  114. package/dist/react.d.ts.map +1 -0
  115. package/dist/react.js +13 -0
  116. package/dist/react.js.map +1 -0
  117. package/dist/supports.d.ts +20 -0
  118. package/dist/supports.d.ts.map +1 -0
  119. package/dist/supports.js +26 -0
  120. package/dist/supports.js.map +1 -0
  121. package/dist/three/assets.d.ts +24 -0
  122. package/dist/three/assets.d.ts.map +1 -0
  123. package/dist/three/assets.js +22 -0
  124. package/dist/three/assets.js.map +1 -0
  125. package/dist/three/budgets.d.ts +17 -0
  126. package/dist/three/budgets.d.ts.map +1 -0
  127. package/dist/three/budgets.js +21 -0
  128. package/dist/three/budgets.js.map +1 -0
  129. package/dist/three/holds.d.ts +33 -0
  130. package/dist/three/holds.d.ts.map +1 -0
  131. package/dist/three/holds.js +56 -0
  132. package/dist/three/holds.js.map +1 -0
  133. package/dist/three/internal.d.ts +52 -0
  134. package/dist/three/internal.d.ts.map +1 -0
  135. package/dist/three/internal.js +49 -0
  136. package/dist/three/internal.js.map +1 -0
  137. package/dist/three/motion-limits.json +116 -0
  138. package/dist/three/sequences.d.ts +71 -0
  139. package/dist/three/sequences.d.ts.map +1 -0
  140. package/dist/three/sequences.js +262 -0
  141. package/dist/three/sequences.js.map +1 -0
  142. package/dist/three/tanya.d.ts +35 -0
  143. package/dist/three/tanya.d.ts.map +1 -0
  144. package/dist/three/tanya.js +51 -0
  145. package/dist/three/tanya.js.map +1 -0
  146. package/dist/three/tara-rig.d.ts +403 -0
  147. package/dist/three/tara-rig.d.ts.map +1 -0
  148. package/dist/three/tara-rig.js +1500 -0
  149. package/dist/three/tara-rig.js.map +1 -0
  150. package/dist/three/tara.d.ts +35 -0
  151. package/dist/three/tara.d.ts.map +1 -0
  152. package/dist/three/tara.js +58 -0
  153. package/dist/three/tara.js.map +1 -0
  154. package/dist/three/tushar.d.ts +31 -0
  155. package/dist/three/tushar.d.ts.map +1 -0
  156. package/dist/three/tushar.js +47 -0
  157. package/dist/three/tushar.js.map +1 -0
  158. package/dist/types.d.ts +149 -0
  159. package/dist/types.d.ts.map +1 -0
  160. package/dist/types.js +140 -0
  161. package/dist/types.js.map +1 -0
  162. package/dist/useAvatar.d.ts +39 -0
  163. package/dist/useAvatar.d.ts.map +1 -0
  164. package/dist/useAvatar.js +35 -0
  165. package/dist/useAvatar.js.map +1 -0
  166. package/dist/vikram.d.ts +7 -0
  167. package/dist/vikram.d.ts.map +1 -0
  168. package/dist/vikram.js +20 -0
  169. package/dist/vikram.js.map +1 -0
  170. package/package.json +110 -25
  171. package/src/avatar.d.ts +218 -120
  172. package/src/avatar.js +950 -213
  173. package/src/behavior.d.ts +42 -0
  174. package/src/behavior.js +114 -0
  175. package/src/camera.js +29 -0
  176. package/src/canvas/author/parts/eye.mjs +722 -0
  177. package/src/canvas/author/parts/hand.mjs +1156 -0
  178. package/src/canvas/author/parts/mouth.mjs +741 -0
  179. package/src/canvas/author/parts/nose.mjs +100 -0
  180. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  181. package/src/canvas/author/path.mjs +283 -0
  182. package/src/canvas/author/rig.mjs +405 -0
  183. package/src/canvas/avatars/round/face.d.mts +3 -0
  184. package/src/canvas/avatars/round/face.mjs +1307 -0
  185. package/src/canvas/create-rig.d.ts +15 -0
  186. package/src/canvas/create-rig.js +100 -0
  187. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  188. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  189. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  190. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  191. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  192. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  193. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  194. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  195. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  196. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  197. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  198. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  199. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  200. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  201. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  202. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  203. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  204. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  205. package/src/canvas/data/interviewer-female.rig.json +1 -0
  206. package/src/canvas/data/interviewer-male.rig.json +1 -0
  207. package/src/canvas/data/professional-female-a.rig.json +1 -0
  208. package/src/canvas/data/professional-female-b.rig.json +1 -0
  209. package/src/canvas/data/professional-male-a.rig.json +1 -0
  210. package/src/canvas/data/professional-male-b.rig.json +1 -0
  211. package/src/canvas/src/live.js +508 -0
  212. package/src/canvas/src/render2d.js +218 -0
  213. package/src/canvas/src/rig.js +297 -0
  214. package/src/canvas/src/vocab.js +96 -0
  215. package/src/clips.js +18 -7
  216. package/src/conformance.js +119 -0
  217. package/src/emotions.js +8 -5
  218. package/src/face-core.js +27 -1
  219. package/src/face-myna.d.ts +7 -0
  220. package/src/face-myna.js +192 -131
  221. package/src/face-peep-control-plane.js +167 -0
  222. package/src/face-peep.d.ts +7 -0
  223. package/src/face-peep.js +274 -116
  224. package/src/face-wren.d.ts +7 -0
  225. package/src/face-wren.js +17 -19
  226. package/src/faces.d.ts +12 -0
  227. package/src/faces.js +53 -0
  228. package/src/gaze.js +434 -58
  229. package/src/hand.js +120 -91
  230. package/src/head.js +134 -0
  231. package/src/idle.js +227 -119
  232. package/src/interjections.js +200 -31
  233. package/src/params.js +8 -4
  234. package/src/perform.js +7 -9
  235. package/src/prosody.js +647 -0
  236. package/src/rig.d.ts +24 -0
  237. package/src/rig.js +32 -0
  238. package/src/speech-timing.js +23 -0
  239. package/src/visemes.js +110 -24
  240. package/client/dist/Avatar.d.ts +0 -27
  241. package/client/dist/Avatar.d.ts.map +0 -1
  242. package/client/dist/Avatar.js.map +0 -1
  243. package/client/dist/AvatarClient.d.ts +0 -139
  244. package/client/dist/AvatarClient.d.ts.map +0 -1
  245. package/client/dist/AvatarClient.js +0 -223
  246. package/client/dist/AvatarClient.js.map +0 -1
  247. package/client/dist/index.d.ts +0 -22
  248. package/client/dist/index.d.ts.map +0 -1
  249. package/client/dist/index.js +0 -22
  250. package/client/dist/index.js.map +0 -1
  251. package/client/dist/types.d.ts +0 -86
  252. package/client/dist/types.d.ts.map +0 -1
  253. package/client/dist/types.js +0 -31
  254. package/client/dist/types.js.map +0 -1
  255. package/client/dist/useAvatar.d.ts +0 -42
  256. package/client/dist/useAvatar.d.ts.map +0 -1
  257. package/client/dist/useAvatar.js +0 -51
  258. package/client/dist/useAvatar.js.map +0 -1
  259. package/client/src/Avatar.tsx +0 -33
  260. package/client/src/AvatarClient.ts +0 -270
  261. package/client/src/index.ts +0 -22
  262. package/client/src/types.ts +0 -104
  263. package/client/src/useAvatar.ts +0 -85
  264. package/docs/contract-avatar.md +0 -371
  265. package/docs/contract-protocol.md +0 -461
@@ -0,0 +1,1500 @@
1
+ /**
2
+ * tara's renderer: the `AvatarRig` contract (`apply(frame)` / `destroy()`)
3
+ * over the Blender-authored GLB.
4
+ *
5
+ * The whole file is one idea — **the pose channel is the interface, and every
6
+ * mapping here is a translation of one channel into the one control that
7
+ * renders it.** `scripts/morphs.py` authored the shape keys under the library's
8
+ * own channel names precisely so this file never has to interpret a viseme, a
9
+ * 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
+ */
28
+ import { REST } from "../internal.js";
29
+ import * as THREE from "three";
30
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
31
+ import { ASSETS } from "./assets.js";
32
+ import { HARD_BUDGET, pixelRatioFor } from "./budgets.js";
33
+ /**
34
+ * The camera, from `scripts/render_frame.py`. It is orthographic and that is
35
+ * forced by the albedo rather than chosen: the atlas is a front-orthographic
36
+ * projection of the shell, so an atlas texel sits at face-space (u, v) whatever
37
+ * depth the triangle carrying it has. A perspective camera disagrees by
38
+ * (offset from the axis) x (depth / distance) — which put the hair rim 3 px
39
+ * above the hairline it is textured to and opened a black band across the
40
+ * forehead. `build_tara.setup_scene` has the measurement.
41
+ */
42
+ const FRAME = { bottom: -0.52, top: 1.46 };
43
+ const FRAME_HEIGHT = FRAME.top - FRAME.bottom;
44
+ const FRAME_CENTRE = (FRAME.top + FRAME.bottom) / 2;
45
+ /**
46
+ * Head motion, in degrees at the channel's own clamp of ±1.4.
47
+ *
48
+ * These are the program's *working* envelope, mapped so that a channel pinned to
49
+ * its limit lands exactly on it. That is the point of scaling by the clamp
50
+ * rather than by 1: the mixer cannot ask for more than the envelope allows, and
51
+ * the hard ceiling stays unreachable by construction instead of by a second
52
+ * clamp nobody runs.
53
+ *
54
+ * **It opened on 2026-09-12, from yaw ±6°, pitch ±5°, roll ±3°.** That
55
+ * envelope made Busso's neutral-speech numbers unreachable: his mean
56
+ * per-sentence pitch *range* is 9.5°, which is 95 % of everything ±5° could
57
+ * ever produce, so speaking alone would have swung the channel corner to corner
58
+ * and a deliberate nod on top of it would have had nowhere left to go. What had
59
+ * held it there was the asset, not anatomy — the neck's follow was linear and
60
+ * drew a second jawline past a few degrees — and once that follow became exact
61
+ * (`NECK_QUAD` below) pitch could open to 24 and roll to 8.
62
+ *
63
+ * Yaw is still the smallest because it is the one axis this rig is genuinely
64
+ * constrained on: the albedo is a front-orthographic projection of a 0.34-deep
65
+ * shell, and a large turn is where that reads as a cardboard cutout rather than
66
+ * a head. **It opened from 9° to 15° on 2026-09-18.** The cutout was measured
67
+ * rather than assumed — a ladder rendered at 9/12/15/18/21/25/30 and read at
68
+ * crop on all three characters is clean to 21° on tara and to 18° on tanya and
69
+ * tushar. 9° was therefore set at half of where the artefact actually begins,
70
+ * and the stiffness the owner reported on tanya's turns was that margin, not
71
+ * her asset. 15° keeps 3° of headroom on the tightest of the three.
72
+ *
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.
77
+ *
78
+ * And it is not the angle a pose may be *held* at, which is a stricter question
79
+ * with its own measurement per character (`motion-limits.json`, applied through
80
+ * `holds.ts`): a turn that returns is forgiven what a sustained one is not. The
81
+ * two numbers differ by about 3x on yaw and neither is a correction of the
82
+ * other.
83
+ *
84
+ * **Editing these needs no rebuild, but it is not free.** The neck's fields
85
+ * carry no angle, so `tara.glb` cannot go stale against them. What a number
86
+ * here does move:
87
+ *
88
+ * - Every clip is authored in channel units, so a degree here re-sizes every
89
+ * clip driving that axis. `test/nods.test.ts` bands *pitch* only — `down`,
90
+ * `up`, `upFirst` — and computes `yawPP` without ever asserting it. A yaw
91
+ * change moves nothing there; a pitch change moves four tests.
92
+ * - `head_parallax.py` and `validate_morphs.py` quote their gates at this
93
+ * envelope. `validate_morphs` reads `morphs.head_envelope()`, but
94
+ * `head_parallax.POSES` hardcoded `yaw 9` until 2026-09-18 and would have
95
+ * gone on grading 9° while the rig shipped 15° — a gate defending a number
96
+ * nothing used. It derives both angles from here now.
97
+ * - The yaw twist's two fields are an expansion in the angle, so their error
98
+ * grows as θ²/6. Against the exact rotation at the maximum ramp
99
+ * (`NECK_TWIST` = 0.5) that is 0.31 % at 9°, 0.85 % at 15°, 1.23 % at 18°
100
+ * (`morphs.neck_twist`). The note here used to read as a wall at 9°; it is
101
+ * not one — 15° costs under a percent of a displacement that is itself a
102
+ * fraction of the neck's radius.
103
+ *
104
+ * TARA-SPECIFIC: each number is her reach before an artefact shows — yaw by
105
+ * the cutout, pitch by the neck fold that starts to crease at 24° chin-up.
106
+ * Both were measured on the shipping surface, one axis at a time. This is still
107
+ * one shared pair of
108
+ * constants for all three characters, which holds only because 15° is inside
109
+ * every one of them; the first character that wants more than its neighbours
110
+ * forces the envelope onto `TaraRigOptions` as a per-character fact. A second
111
+ * avatar measures its own with the audit.
112
+ */
113
+ // Exported through `internal.ts` for the instruments that need to put a real
114
+ // angle *into* a channel, which is this scaling run backwards. The mocap
115
+ // 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.
117
+ export const HEAD_CLAMP = 1.4;
118
+ export const HEAD_DEG = { yaw: 15, pitch: 24, roll: 8 };
119
+ /**
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.
125
+ */
126
+ const PIVOT = new THREE.Vector3(0.0, 0.36, -0.22);
127
+ /**
128
+ * Where the head *tilts* about, from `morphs.ROLL_PIVOT`: the midline just
129
+ * above the chin. A roll is a bend of the whole neck, so its centre is far
130
+ * below the ear, and a drawn head sells it by holding the chin and swinging
131
+ * the crown — Live2D's sample rigs tilt about this same point. About PIVOT
132
+ * instead, the chin swung 7 px the other way at 8° and the head read as a
133
+ * pendulum hung from the ears.
134
+ *
135
+ * 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.
138
+ */
139
+ const ROLL_PIVOT = new THREE.Vector3(0.0, 0.05, -0.22);
140
+ /**
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.
151
+ *
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.
155
+ */
156
+ const HEAD_PARTS = ["Head", "Ears", "Hair", "Eye_L", "Eye_R", "Cavity",
157
+ "Teeth_Upper", "Teeth_Lower", "Tongue"];
158
+ /**
159
+ * The body, in face-space units (glTF y is face-space v) and degrees.
160
+ *
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.
171
+ *
172
+ * 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.
184
+ * lean `torsoLean` as a deformation of the trunk, on the shell itself
185
+ * (`morphs.torso_targets`) — hem pinned at the frame's lower edge, the
186
+ * shoulders spreading and tipping as they come nearer. Only the head's
187
+ * *ride* is here: above the collar the field is flat, so the neck and
188
+ * head take a pure translation of `leanRide` and nothing else. That is
189
+ * Live2D's measured behaviour rather than a simplification — body angle
190
+ * 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.
203
+ * sway `torsoTurn` as the seated body's inverted pendulum: the whole figure
204
+ * rolls about the hips, ~45 cm below the collar, so the trunk shifts
205
+ * sideways and tips by a fraction of a degree together. peep slides
206
+ * its torso under a head that stays; a photograph cannot, because a
207
+ * neck joins them, so here the head rides the trunk and is rolled back
208
+ * level by exactly the trunk's tilt — its roll is `headRoll`'s alone,
209
+ * as a person shifting their weight goes on holding their eyes level on
210
+ * the person they are listening to. One degree at full `torsoTurn`: a
211
+ * weight shift (0.16-0.42) moves the collar 1.1-2.9 px, and following
212
+ * a full head turn ~7 px — 1.7 cm at the collar, what a seated body
213
+ * shifting onto one hip really does.
214
+ */
215
+ const BODY = {
216
+ swellPivot: FRAME.bottom - 0.35 * FRAME_HEIGHT,
217
+ rise: 0.016,
218
+ widen: 0.012,
219
+ /** `landmarks.SHOULDER.top`: where the neck meets the torso. */
220
+ collar: -0.24,
221
+ /**
222
+ * The head group's rigid drop at a full lean. `morphs.py` parses this and
223
+ * plateaus `torsoLean`'s field at it above the collar, so the body's
224
+ * deformation and the head's transform are one number and meet without a
225
+ * seam — the same arrangement `lift.position.y` already has with the breath.
226
+ */
227
+ leanRide: 0.021,
228
+ hip: -2.9,
229
+ swayDeg: 1.0,
230
+ };
231
+ /**
232
+ * Gaze, as iris travel in face heights at `pupilX/Y` = 1, converted to a globe
233
+ * rotation on load. Authored as travel, not as an angle, because travel is what
234
+ * anyone can measure off the render and the angle is an artefact of this
235
+ * build's globe radius (0.24, some 4x anatomical — the sphere is a curved
236
+ * backing for a painted iris, not an eyeball).
237
+ *
238
+ * 0.042 across is ~7 mm on a face 165 mm tall, the iris travel of a ~35°
239
+ * version, and 6.4 px at the 400 px tile; the first value (0.026, ~4 px) was
240
+ * under the threshold where a look to the screen edge reads as a look rather
241
+ * than a drift. Vertical is shorter because a person looking up or down
242
+ * carries most of it with the lids and the head.
243
+ */
244
+ const GAZE_TRAVEL = { x: 0.042, y: 0.026 };
245
+ /** `head_mesh.GLOBE_R`. The arc a rotation moves the iris through is r·θ. */
246
+ const GLOBE_RADIUS = 0.24;
247
+ /** `face_texture.EYE_EXTENT`'s span: one eye tile covers 0.30 face heights. */
248
+ const EYE_TILE = 0.3;
249
+ /** Degrees per pose unit: the globe turns `pupil * GAZE_TRAVEL / GLOBE_RADIUS`
250
+ * radians, and the head reaches `HEAD_DEG` at the clamp.
251
+ *
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. */
256
+ export const EYE_DEG = { x: (GAZE_TRAVEL.x / GLOBE_RADIUS) * 180 / Math.PI, y: (GAZE_TRAVEL.y / GLOBE_RADIUS) * 180 / Math.PI };
257
+ const HEAD_UNIT_DEG = { x: HEAD_DEG.yaw / HEAD_CLAMP, y: HEAD_DEG.pitch / HEAD_CLAMP };
258
+ /**
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.
264
+ *
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.
274
+ *
275
+ * vor Real gain in the light is close to 1. A little under leaves the
276
+ * 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
279
+ * reads as a fraction of what it is, so the eyes' full answer to
280
+ * it read as the eyes moving on their own — at 0.8, THINKING's
281
+ * up-look rolled the iris to the lid with white beneath it, and
282
+ * the reply that followed dropped it into a downcast look with the
283
+ * lid riding down, while the head was still coming back. At 0.35
284
+ * the eyes mostly go where the head takes them.
285
+ * range How far the reflex may carry the eye in the socket. Unlimited,
286
+ * an up-look's onset put the whole 8° in the eye before the neck
287
+ * moved — the iris pinned under the lid, white beneath it, which
288
+ * on this photograph reads as an eye-roll. Up 0.45 is 2.8° and
289
+ * down 0.5 is 3.1°; the head covers the rest, as it does once the
290
+ * eye nears its effective range. Both were looser (0.55, 0.85)
291
+ * and a gap between turns hit both ends of them every time.
292
+ * lidFollow Down 0.38 keeps the upper lid on the iris: the lid travels
293
+ * 0.068 face units per lid unit and the iris 0.026 per pupil unit.
294
+ * Up a little less, so an upward look opens the eye a hair instead
295
+ * of dragging the lid along with it.
296
+ * avert A conversational look away keeps under half its size in the
297
+ * eyes and gives the neck 0.6 of it — on this face the eyes alone
298
+ * could only make it a glance sideways.
299
+ * head The follow's launch and cruise, in head units. A real head
300
+ * reaches a 7-8° shift in about 0.4 s, its speed scaling with the
301
+ * size of the shift; the shared amble took a thinking look 1.2 s,
302
+ * and the reflex, doing its job, held the eyes in the corner of
303
+ * the socket the whole way.
304
+ *
305
+ * `trunkFollow` is 0.3, under the line faces' 0.45. Live2D's face-tracking
306
+ * sample gives the body a third of the head's yaw (BodyAngleX 10 against
307
+ * AngleX 30; docs/research-head-rotation.md § 5 item 6), and that is the
308
+ * benchmark to sit at, not above. Here it matters for a reason a line face
309
+ * does not have: the neck's outline holds under a twist by construction
310
+ * (`morphs.neck_twist`), so the trunk's sway is what is left moving it — a
311
+ * 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.
314
+ */
315
+ export const TARA_TUNING = {
316
+ prosodyHeadGain: 1.0, prosodyFaceGain: 1, saccadeGain: 2.4, aversionGain: 1.8,
317
+ trunkFollow: 0.3,
318
+ // **The speaking face's upper half, sized for a photograph.** A reviewer read
319
+ // her speech as delivered "with zero emotion or movement in her eyes or
320
+ // forehead", against the same reviewer's praise for `CANT_HEAR`. The two are
321
+ // the same channels at different values, and the gap is measurable in the
322
+ // expression maps: `CANT_HEAR` above is knit 0.14/0.45 plus inner 0.22/0.5,
323
+ // about 0.75 of map weight, where speech averaged 0.19.
324
+ //
325
+ // `floor` is the larger half of the answer. `browRaise` is split by sign into
326
+ // two maps and *both* read as nothing near zero, so the shared [-0.14, 0.18]
327
+ // range spends most phrases in a dead band recruiting no light at all. The
328
+ // band is skipped rather than the range widened, because what carries the
329
+ // read is the sign a phrase commits to, not how far it goes — and the ceiling
330
+ // stays under a beat's 0.34 for the reason `POSE.brow` gives.
331
+ //
332
+ // `forms` moves draws off the plain raise and onto the inner lift, which on
333
+ // this asset is the only brow shape besides the raise that recruits light at
334
+ // all (`EXPRESSION_WEIGHT`: there is no map for `browAngle`, and its 0.026 of
335
+ // travel is the smallest of the three). `prosody.js` makes the inner lift the
336
+ // rarest form deliberately, because a face that keeps lifting its inner brows
337
+ // reads as worried — that rule is written for the line faces, and the face it
338
+ // is being relaxed for holds `browInner` at 0.22 for the whole of `CANT_HEAR`
339
+ // and was praised for it. Still a transient on a 0.55 s envelope, never a
340
+ // 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
+ brows: {
345
+ range: [-0.28, 0.24],
346
+ floor: 0.11,
347
+ forms: [
348
+ { p: 0.38, inner: 0.00, angle: 0.00 },
349
+ { p: 0.34, inner: 0.26, angle: 0.00 },
350
+ { p: 0.28, inner: 0.00, angle: 0.30 },
351
+ ],
352
+ },
353
+ states: {
354
+ // The shared WORKING pose without its AU4 brows. On peep, brows-down is
355
+ // what makes reading read as effort rather than a blank face; on a
356
+ // photograph, over lids that are already following the eyes down, it
357
+ // closes them to a squint the owner read as straining at the screen, not
358
+ // working. Her reading scan carries the state instead: eyes off the user,
359
+ // 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
+ WORKING: {
363
+ pose: { headPitch: 0.04, lidL: -0.08, lidR: -0.08, shoulderL: 0.06, shoulderR: 0.06 },
364
+ },
365
+ // Straining to hear, without the squint. Its AU7 lifts her lower lid and
366
+ // pushes the cheek up under it: at 0.75 the eyes closed to slits over a
367
+ // dark band, and under brows at -0.45, with the eyes countered into the
368
+ // corner of the socket (`USER_EAR` below), she read as giving the user a
369
+ // suspicious side-eye. The lean in and the ear offered carry the state,
370
+ // which the shared comment already says of them. The brows keep a small
371
+ // knit with the inner ends up: effort that is also asking. The mouth is
372
+ // 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.
376
+ //
377
+ // The lean is attentive-sized, not the shared 0.70. Until 2026-09-16 her
378
+ // lean scaled the whole figure about mid-face (`BODY`), so 0.70 plus the
379
+ // engage add was a 4.4% zoom arriving on torsoLean's 0.24 s tau: the
380
+ // shoulders swelled and dropped in half a second, read by the owner as a
381
+ // lurch nothing like a lean. 0.22 sits in the research's sustained band
382
+ // (+0.15–0.25, research-biomechanics.md §6.3) and the ear and chin carry
383
+ // the rest.
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.
390
+ CANT_HEAR: {
391
+ pose: {
392
+ torsoLean: 0.22, headPitch: 0.10,
393
+ browRaiseL: -0.14, browRaiseR: -0.14, browInnerL: 0.22, browInnerR: 0.18,
394
+ mouthPress: 0.40, mouthCornerL: -0.10, mouthCornerR: -0.10,
395
+ },
396
+ },
397
+ // Hunting for a control, without the squint: the same lower lid and cheek
398
+ // at 0.40 left the eyes half-lidded from below over a dark band. The hunt
399
+ // is the wander and the flick; the face only has to be not smiling, and
400
+ // 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.
402
+ SEARCHING_SCREEN: {
403
+ pose: { mouthPress: 0.40, mouthCornerL: -0.08, mouthCornerR: -0.08,
404
+ browRaiseL: -0.10, browRaiseR: -0.06 },
405
+ },
406
+ },
407
+ oculomotor: {
408
+ angles: { eye: EYE_DEG, head: HEAD_UNIT_DEG },
409
+ vor: { x: 0.8, y: 0.35 },
410
+ range: { x: 0.8, up: 0.45, down: 0.5 },
411
+ lidFollow: { down: 0.38, up: 0.30 },
412
+ avert: { eye: 0.45, head: 0.6 },
413
+ head: { accel: 16, speed: 3.5 },
414
+ targets: {
415
+ // 2.0° right, 2.5° down: her own display, read level and a little to
416
+ // the side (WORKING, and OFFLINE's wait). The upper lid follows the eye
417
+ // down (`lidFollow`), and a photographed eye 6.8° down — where this
418
+ // sat — hooded to a lid of 0.25-0.43 against listening's 0.14, read as
419
+ // a squint rather than reading. Here it holds 0.12-0.18 through the
420
+ // 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
+ OWN_SCREEN: { px: 0.14, py: 0.24, hx: 0.10, hy: 0.06 },
423
+ // 2.6° of head turn toward the user's side and 3.1° of roll, the eyes
424
+ // countered 2.0° back onto them: the ear offered, contact held from
425
+ // inside the socket. The shared 0.42 counter held her iris against the
426
+ // corner of the socket with white on one side, which on a photograph is
427
+ // side-eye; the roll is the cue that says "ear", so it keeps its size.
428
+ USER_EAR: { px: -0.20, py: 0.05, hx: 0.40, hy: 0.02, roll: 0.55 },
429
+ // 2.8° up, nearly all of it the head — the same total the shared target
430
+ // gives on her, redistributed. The shared split puts 1.0° on the eyes,
431
+ // and the middle of a screen is the one place a hunt keeps returning to,
432
+ // so it is the worst place to sit with the iris off-centre.
433
+ SCREEN_CENTER: { px: 0.00, py: -0.06, hx: 0.00, hy: -0.14 },
434
+ // 9.3° to the side, 1.5° up, the head carrying over half. The shared
435
+ // ones put the eyes at the edge of her socket (0.78 of 0.8) — white on
436
+ // one side again, and a hunt that reads as shifty. SEARCHING_SCREEN,
437
+ // REVIEWING_SCREEN and DISTRACTED look here.
438
+ SCREEN_LEFT: { px: -0.42, py: -0.10, hx: -0.80, hy: -0.05 },
439
+ SCREEN_RIGHT: { px: 0.42, py: -0.10, hx: 0.80, hy: -0.05 },
440
+ // 6.4° up, three quarters of it the head. The shared target is 9.6° on
441
+ // her — higher than her own AWAY_THINKING, so it read as looking over
442
+ // the monitor rather than at the top of it, and it made the two longest
443
+ // hops in SEARCHING_SCREEN's set (14.6° from SCREEN_WORK, 12.4° from
444
+ // either side) on a face whose looks are all scaled to a head-and-
445
+ // shoulders crop.
446
+ SCREEN_TOP: { px: 0.00, py: -0.26, hx: 0.00, hy: -0.28 },
447
+ // 7.0° left, 3.2° down, for the same reason, split evenly.
448
+ SCREEN_WORK: { px: -0.35, py: 0.18, hx: -0.55, hy: 0.12 },
449
+ // 7.8° left, 8.1° up.
450
+ AWAY_THINKING: { px: -0.30, py: -0.40, hx: -0.75, hy: -0.33, roll: 0.08 },
451
+ // 7.6° right, 7.4° up.
452
+ AWAY_RIGHT: { px: 0.30, py: -0.36, hx: 0.72, hy: -0.30, roll: -0.06 },
453
+ // 6.8° left, 5.5° down: shallower than the up-looks, since down on a
454
+ // face this real is the one read as downcast.
455
+ AWAY_DOWN: { px: -0.28, py: 0.45, hx: -0.62, hy: 0.16, roll: 0.04 },
456
+ // 7.8° right, level.
457
+ AWAY_SIDE: { px: 0.32, py: 0.08, hx: 0.72, hy: 0.02, roll: -0.03 },
458
+ },
459
+ },
460
+ };
461
+ /**
462
+ * The eye material, taught to hold its socket still while the globe turns.
463
+ *
464
+ * The eye atlas is two tiles (`face_texture.write_eye_atlas`): the globe, and
465
+ * a linear multiplier holding what the lids and the socket put on the eye —
466
+ * the shadow under the upper lid, the caruncle, the shading into each corner.
467
+ * A real eye turns under all of that, and when it was baked into the globe a
468
+ * sideways look swung the caruncle into the middle of the white. So the
469
+ * globe's texel is read where the surface point *was* and the socket's where
470
+ * it *is*: the planar UV of the rotated position, which is the unrotated UV
471
+ * plus the rotation's displacement through the UV's own gradient. One extra
472
+ * texture read on two small meshes; no pass, no draw call.
473
+ */
474
+ function socketed(base, side, gaze, deep) {
475
+ const material = base.clone();
476
+ // d(u)/dx and d(v)/dy of `head_mesh.eye_uvs`: the globe is half the atlas
477
+ // wide, the right eye reads it mirrored, and glTF flips v.
478
+ const socket = { value: new THREE.Vector2((0.5 * -side) / EYE_TILE, -1 / EYE_TILE) };
479
+ material.onBeforeCompile = (shader) => {
480
+ shader.uniforms.uGaze = gaze;
481
+ shader.uniforms.uSocket = socket;
482
+ // The globe's hidden skirt turns at the frame's motion depth like the skin
483
+ // that hides it; its visible cap carries a field of exactly zero, so the
484
+ // socket, the iris and gaze below are unaffected by this. It turns at the
485
+ // *frame's* rotation and not its own, which is what `undoGaze` reads back
486
+ // out of `uGaze` — hence this sitting after that uniform is bound.
487
+ if (deep)
488
+ turnDeep(shader, true);
489
+ shader.vertexShader = shader.vertexShader
490
+ .replace("#include <uv_pars_vertex>", "#include <uv_pars_vertex>\nuniform mat3 uGaze;\nuniform vec2 uSocket;\nvarying vec2 vSocketUv;")
491
+ .replace("#include <uv_vertex>", "#include <uv_vertex>\nvSocketUv = vMapUv + vec2(0.5, 0.0)"
492
+ + " + uSocket * ((uGaze * position).xy - position.xy);");
493
+ 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;")
496
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= socket;");
497
+ };
498
+ material.customProgramCacheKey = () => (deep ? "tara-eye-socket-deep" : "tara-eye-socket");
499
+ return material;
500
+ }
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.
522
+ *
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.
526
+ */
527
+ const CAVITY_SHADE = { seam: 0.805, falloff: 8, min: 0.35, max: 2.6 };
528
+ /**
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.
553
+ *
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.
561
+ *
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.
569
+ *
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.
582
+ *
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.
590
+ */
591
+ function cavityShade(base, lo, hi) {
592
+ 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
+ };
597
+ material.onBeforeCompile = (shader) => {
598
+ shader.uniforms.uCavitySpan = span;
599
+ shader.uniforms.uCavityShade = shade;
600
+ 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;");
607
+ 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.
612
+ .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);");
615
+ };
616
+ material.customProgramCacheKey = () => "tara-cavity-shade";
617
+ return material;
618
+ }
619
+ /** `scripts/head_mesh.MOTION_DEPTH_ATTR`, as GLTFLoader names it: lowercased. */
620
+ const MOTION_DEPTH = "_motion_depth";
621
+ /**
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.
633
+ *
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.
638
+ */
639
+ /**
640
+ * The vertex half of `motionDepth`, which the eye's socket shader needs too.
641
+ *
642
+ * It is a free function rather than inheritance because `Material.copy` does not
643
+ * carry `onBeforeCompile`: cloning a motion-depth material and giving the clone
644
+ * a second injection drops the first one silently, with no error and a rest pose
645
+ * that looks right. The two are composed by hand instead.
646
+ */
647
+ function turnDeep(shader, undoGaze = false) {
648
+ // `undoGaze` is for the globes, and without it the cure draws a worse defect
649
+ // than the one it removes. A globe is *rotated* for gaze, so its own
650
+ // `modelViewMatrix` carries that rotation: building the offset from it tilts
651
+ // (0, 0, Δz) into the screen plane and slides the hidden skirt out past the
652
+ // temple on a look alone, head square on, where the skin it hides behind has
653
+ // 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()`.
657
+ const depth = `vec3(0.0, 0.0, ${MOTION_DEPTH})`;
658
+ shader.vertexShader = shader.vertexShader
659
+ .replace("#include <common>", `#include <common>\nattribute float ${MOTION_DEPTH};`)
660
+ .replace("#include <project_vertex>", "#include <project_vertex>\n"
661
+ + `mvPosition.xy += (modelViewMatrix * vec4(${undoGaze ? `${depth} * uGaze` : depth}, 0.0)).xy;\n`
662
+ + "gl_Position = projectionMatrix * mvPosition;");
663
+ }
664
+ function motionDepth(base, expression) {
665
+ const material = base.clone();
666
+ material.onBeforeCompile = (shader) => {
667
+ turnDeep(shader);
668
+ if (!expression)
669
+ return;
670
+ const n = expression.names.length;
671
+ Object.assign(shader.uniforms, expression.uniforms);
672
+ shader.fragmentShader = shader.fragmentShader
673
+ .replace("#include <common>", `#include <common>\n${EXPRESSION_UNIFORMS(n)}`)
674
+ .replace("#include <map_fragment>", `#include <map_fragment>\n${EXPRESSION_FRAGMENT(n)}`)
675
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= expression;");
676
+ };
677
+ material.customProgramCacheKey = () => (expression ? `tara-motion-depth-expression-${expression.names.length}` : "tara-motion-depth");
678
+ return material;
679
+ }
680
+ /**
681
+ * Expression maps: the light a smile or a raised brow changes, which the
682
+ * geometry cannot.
683
+ *
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.
700
+ *
701
+ * Per side, because the channels are. The weights cross over at the midline
702
+ * 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.
709
+ */
710
+ const EXPRESSION_SPLIT = 0.03;
711
+ /**
712
+ * Each map's weight, from one side's channels, as a fraction of the channel
713
+ * value at which it is at its photograph's full strength. The smile's is the
714
+ * mixer's broad smile (0.48-0.58 in the happy states) with room above it; a
715
+ * raise at the mixer's highest (0.42) gets most of the lines; a knit at
716
+ * `CANT_HEAR`'s −0.14 gets a third of the crease and the deepest frown the
717
+ * mixer asks for (−0.45) gets all of it; the inner brow's concern is full at
718
+ * its 0.55 and a press at the 0.40-0.55 most states hold gets most of the chin.
719
+ *
720
+ * A map named here that the asset lacks is simply not read, and a map the
721
+ * asset has that is not named here stays at weight 0: an asset newer than its
722
+ * 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
+ */
726
+ const EXPRESSION_WEIGHT = {
727
+ smile: (at) => at("mouthCorner") / 0.8,
728
+ raise: (at) => at("browRaise") / 0.6,
729
+ knit: (at) => -at("browRaise") / 0.45,
730
+ inner: (at) => at("browInner") / 0.5,
731
+ press: (at) => at("mouthPress") / 0.7,
732
+ };
733
+ const EXPRESSION_UNIFORMS = (n) => [
734
+ "uniform sampler2D uExpression;", "uniform vec4 uExpressionTile;", "uniform vec2 uExpressionU;",
735
+ "uniform vec4 uExpressionStrip;", "uniform float uExpressionStep;",
736
+ "uniform float uExpressionScale;",
737
+ `uniform float uExpressionL[${n}];`, `uniform float uExpressionR[${n}];`,
738
+ ].join("\n");
739
+ // Every tap is read whatever its weight: a texture read under a branch that
740
+ // differs across the midline has no derivatives to choose its mip from.
741
+ const EXPRESSION_FRAGMENT = (n) => [
742
+ "vec2 expressionAt = clamp(uExpressionTile.xz + uExpressionTile.yw * vMapUv, 0.0, 1.0);",
743
+ "vec2 expressionUv = uExpressionStrip.zw + expressionAt * uExpressionStrip.xy;",
744
+ "float expressionSide = smoothstep(",
745
+ ` -${EXPRESSION_SPLIT}, ${EXPRESSION_SPLIT}, uExpressionU.x + uExpressionU.y * vMapUv.x);`,
746
+ "float expressionLog = 0.0;",
747
+ `for (int i = 0; i < ${n}; i++) {`,
748
+ " float ratio = texture2D(uExpression, expressionUv + vec2(float(i) * uExpressionStep, 0.0)).r;",
749
+ " expressionLog += mix(uExpressionL[i], uExpressionR[i], expressionSide)",
750
+ " * log(max(ratio * uExpressionScale, 0.01));",
751
+ "}",
752
+ "float expression = exp(expressionLog);",
753
+ "diffuseColor.rgb *= expression;",
754
+ ].join("\n");
755
+ /** The maps from their carrier's node, or `null` for an asset whose carrier
756
+ * does not say what this rig reads — a face with no expression maps is a
757
+ * correct face, and a map read with the wrong layout is not. */
758
+ function expressive(carrier) {
759
+ const { expression_tile: tile, expression_u: u, expression_maps: names, expression_layout: layout, expression_unity: unity } = carrier.userData;
760
+ const map = carrier.material.map;
761
+ if (!map || !Array.isArray(tile) || !Array.isArray(u) || !Array.isArray(layout)
762
+ || typeof unity !== "number" || !Array.isArray(names) || !names.length)
763
+ return null;
764
+ // A ratio, not a colour. The loader tags every base colour sRGB.
765
+ map.colorSpace = THREE.NoColorSpace;
766
+ map.needsUpdate = true;
767
+ const [width, height, pad, stripWidth, stripHeight] = layout;
768
+ const left = new Float32Array(names.length);
769
+ const right = new Float32Array(names.length);
770
+ return {
771
+ map, names: names.map(String), left, right,
772
+ uniforms: {
773
+ uExpression: { value: map },
774
+ uExpressionTile: { value: new THREE.Vector4(tile[0], tile[1], tile[2], tile[3]) },
775
+ uExpressionU: { value: new THREE.Vector2(u[0], u[1]) },
776
+ uExpressionStrip: { value: new THREE.Vector4(width / stripWidth, height / stripHeight, pad / stripWidth, pad / stripHeight) },
777
+ uExpressionStep: { value: (width + pad) / stripWidth },
778
+ uExpressionScale: { value: 255 / unity },
779
+ uExpressionL: { value: left },
780
+ uExpressionR: { value: right },
781
+ },
782
+ };
783
+ }
784
+ /**
785
+ * The neck, taught to wear the jaw's shadow where the jaw is.
786
+ *
787
+ * The photograph paints the shadow the chin casts on the throat, and a painted
788
+ * 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.
798
+ *
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.
805
+ */
806
+ function jawShadow(base, uv, extent, rimZ, headInverse) {
807
+ const material = base.clone();
808
+ const tile = { value: new THREE.Vector4(uv[0], uv[1], uv[2], uv[3]) };
809
+ const bounds = { value: new THREE.Vector4(extent[0], extent[1], extent[2], extent[3]) };
810
+ const rim = { value: rimZ };
811
+ material.onBeforeCompile = (shader) => {
812
+ shader.uniforms.uHeadInverse = headInverse;
813
+ shader.uniforms.uJawTile = tile;
814
+ shader.uniforms.uJawBounds = bounds;
815
+ shader.uniforms.uJawRim = rim;
816
+ shader.vertexShader = shader.vertexShader
817
+ .replace("#include <common>", "#include <common>\nvarying vec3 vJawView;")
818
+ .replace("#include <project_vertex>", "#include <project_vertex>\nvJawView = mvPosition.xyz;");
819
+ shader.fragmentShader = shader.fragmentShader
820
+ .replace("#include <common>", "#include <common>\nvarying vec3 vJawView;\nuniform mat4 uHeadInverse;\n"
821
+ + "uniform vec4 uJawTile;\nuniform vec4 uJawBounds;\nuniform float uJawRim;")
822
+ .replace("#include <map_fragment>", "#include <map_fragment>\n"
823
+ + "vec3 jawFrom = (uHeadInverse * vec4(vJawView, 1.0)).xyz;\n"
824
+ + "vec3 jawRay = (uHeadInverse * vec4(0.0, 0.0, 1.0, 0.0)).xyz;\n"
825
+ + "vec2 jawAt = jawFrom.xy + jawRay.xy * ((uJawRim - jawFrom.z) / jawRay.z);\n"
826
+ + "jawAt = clamp(jawAt, uJawBounds.xz, uJawBounds.yw);\n"
827
+ + "vec3 jawShadow = texture2D(map, uJawTile.xz + uJawTile.yw * jawAt).rgb;\n"
828
+ + "diffuseColor.rgb *= jawShadow;")
829
+ .replace("#include <emissivemap_fragment>", "#include <emissivemap_fragment>\ntotalEmissiveRadiance *= jawShadow;");
830
+ };
831
+ material.customProgramCacheKey = () => "tara-jaw-shadow";
832
+ return material;
833
+ }
834
+ /** Lamps, standing in for the four area lights `build_tara.setup_scene` uses.
835
+ *
836
+ * The albedo is a photograph and already holds this face's light, so 75% of it
837
+ * is emitted verbatim (`emissiveFactor` in the GLB) and only the remaining 25%
838
+ * is handed to these. They exist for the quarter that makes a yaw read as a
839
+ * head turning rather than a picture sliding; their sum along the face normal
840
+ * is π, which is exactly the irradiance that renders that quarter back at full
841
+ * albedo — so the rest frame is the reference photograph, and only a turned
842
+ * head departs from it.
843
+ */
844
+ const AMBIENT = 0.62 * Math.PI;
845
+ const KEY = 0.26 * Math.PI;
846
+ const WRAP = 0.10 * Math.PI;
847
+ /** 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
+ *
858
+ * Measured on an M1 over four paired reps against `peep`, which is the SVG
859
+ * avatar that already ships: uncapped at 60 the 3-D face cost 13.8 points of
860
+ * one core more than peep; capped at 30 the difference was inside the noise
861
+ * (−0.9 points over three reps). The cap is most of the runtime cost of being
862
+ * 3-D at all.
863
+ *
864
+ * It throttles *drawing* only. The mixer runs its own rAF loop and keeps its
865
+ * own clock, so cue timing is exactly as accurate as it was — what drops is how
866
+ * often that clock is looked at, not how well it is kept.
867
+ */
868
+ const RENDER_FPS = 30;
869
+ const MIN_FRAME_MS = 1000 / RENDER_FPS;
870
+ const radians = (deg) => (deg * Math.PI) / 180;
871
+ /** The renderer, or `null` where the browser will not give a context.
872
+ *
873
+ * Three.js throws out of the constructor rather than returning anything, and it
874
+ * has already written its own line to the console by then; that line is kept
875
+ * because it names the underlying reason, which this one does not. */
876
+ function webglRenderer() {
877
+ try {
878
+ return new THREE.WebGLRenderer({ antialias: true, alpha: true });
879
+ }
880
+ catch {
881
+ return null;
882
+ }
883
+ }
884
+ /**
885
+ * 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.
891
+ *
892
+ * So above `LID_KNEE` the influence is eased up to meet 1 at `LID_SHUT`, and
893
+ * held there: past that point the lid has landed on the lower one. Below the
894
+ * knee it is untouched, and that is every lid held part-way on purpose — the
895
+ * lowered lid of a degraded link, the lid following a downward gaze — so those
896
+ * look exactly as they did. The ease is quadratic from the knee, so both the
897
+ * value and its slope are continuous there.
898
+ *
899
+ * `scripts/morphs.py:influence` parses both numbers out of this file.
900
+ */
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
+ const LID_KNEE = 0.35;
912
+ const LID_SHUT = 0.66;
913
+ /**
914
+ * A squint's influence rises faster than its channel. The mixer's values are
915
+ * set where a line face's lower lid reads — a smile's squint is 0.30 — and a
916
+ * 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.
920
+ *
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.
935
+ */
936
+ const SQUINT_CURVE = 0.6;
937
+ const SQUINT_KNEE = 0.2;
938
+ const SQUINT_CEIL = 0.54;
939
+ const squintCurve = (i) => {
940
+ if (i <= 0)
941
+ return i;
942
+ if (i <= SQUINT_KNEE)
943
+ return i ** SQUINT_CURVE;
944
+ const knee = SQUINT_KNEE ** SQUINT_CURVE;
945
+ const slope = SQUINT_CURVE * SQUINT_KNEE ** (SQUINT_CURVE - 1);
946
+ const head = SQUINT_CEIL - knee;
947
+ return SQUINT_CEIL - head * Math.exp((-slope * (i - SQUINT_KNEE)) / head);
948
+ };
949
+ const lidClosure = (i) => {
950
+ if (i <= LID_KNEE)
951
+ return i;
952
+ if (i >= LID_SHUT)
953
+ return 1;
954
+ const t = (i - LID_KNEE) / (LID_SHUT - LID_KNEE);
955
+ return i + (1 - LID_SHUT) * t * t;
956
+ };
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
+ /**
969
+ * The neck's follow targets, and the one place a morph is not driven by
970
+ * `influence`.
971
+ *
972
+ * `scripts/morphs.neck_targets` authors two fields per head axis — `A P` and
973
+ * `A^2 P` for that axis's skew matrix — because a rigid rotation is exactly
974
+ * `sin(th)` of the first plus `1 - cos(th)` of the second. A single field scaled
975
+ * by the pose is the *linear* approximation of that, and it stretches the neck
976
+ * away from the pivot by `radius x (1 - cos th)`: fine at six degrees, five
977
+ * pixels at the envelope a nod that lands actually needs.
978
+ *
979
+ * So under pitch and roll the throat tracks the skull exactly at any angle,
980
+ * 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
983
+ * than the skull's own rotation, and the build weights its two fields so these
984
+ * same two influences drive it (`morphs.neck_twist`).
985
+ */
986
+ const NECK_QUAD = "_q";
987
+ const HEAD_AXIS = {
988
+ headYaw: "yaw", headPitch: "pitch", headRoll: "roll",
989
+ };
990
+ const neckInfluence = (channel, pose) => {
991
+ const quad = channel.endsWith(NECK_QUAD);
992
+ const axis = HEAD_AXIS[quad ? channel.slice(0, -NECK_QUAD.length) : channel];
993
+ if (!axis)
994
+ return null;
995
+ const value = pose[quad ? channel.slice(0, -NECK_QUAD.length) : channel] ?? 0;
996
+ const radians = ((value / HEAD_CLAMP) * HEAD_DEG[axis] * Math.PI) / 180;
997
+ return quad ? 1 - Math.cos(radians) : Math.sin(radians);
998
+ };
999
+ /**
1000
+ * The hair's roll, which is the one thing in this rig that is not a function of
1001
+ * the pose alone.
1002
+ *
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.
1008
+ *
1009
+ * 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
1025
+ * 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.
1035
+ */
1036
+ export const HAIR_ROLL = { hold: 0.85, hz: 1.5, damping: 0.65 };
1037
+ /**
1038
+ * One step of `HAIR_ROLL`'s spring: semi-implicit, the rate taking the frame's
1039
+ * acceleration before the angle takes the rate. Explicit Euler rings at this
1040
+ * stiffness and 30 fps; this does not, which is the only reason the order of
1041
+ * those two lines is worth a sentence.
1042
+ *
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.
1046
+ */
1047
+ export const hairRollStep = (angle, rate, target, dt) => {
1048
+ const w = 2 * Math.PI * HAIR_ROLL.hz;
1049
+ const next = rate + (w * w * (target - angle) - 2 * HAIR_ROLL.damping * w * rate) * dt;
1050
+ return { angle: angle + next * dt, rate: next };
1051
+ };
1052
+ /** The hair pair's two influences, from the *extra* angle the shell is turned
1053
+ * by — the same `sin` / `1 - cos` terms the neck's fields are driven with. */
1054
+ const hairInfluence = (channel, extra) => {
1055
+ if (channel === "headRoll")
1056
+ return Math.sin(extra);
1057
+ if (channel === "headRoll" + NECK_QUAD)
1058
+ return 1 - Math.cos(extra);
1059
+ return null;
1060
+ };
1061
+ const influence = (channel, value) => {
1062
+ const rest = REST[channel] ?? 0;
1063
+ const i = (value - rest) / (1 - rest);
1064
+ if (channel === "lidL" || channel === "lidR")
1065
+ return lidClosure(i);
1066
+ return channel === "squintL" || channel === "squintR" ? squintCurve(i) : i;
1067
+ };
1068
+ /** One side's weights for the asset's expression maps, in its order. A
1069
+ * channel with no side (`mouthPress`) weighs the same on both. */
1070
+ const expressionWeights = (pose, side, expression, into) => {
1071
+ const at = (channel) => {
1072
+ const name = pose[channel + side] === undefined ? channel : channel + side;
1073
+ const value = pose[name];
1074
+ return value === undefined ? 0 : influence(name, value);
1075
+ };
1076
+ expression.names.forEach((map, i) => {
1077
+ const weight = EXPRESSION_WEIGHT[map]?.(at) ?? 0;
1078
+ into[i] = Math.min(Math.max(weight, 0), 1);
1079
+ });
1080
+ };
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) {
1084
+ const { onReady, url = ASSETS.tara, expression: readExpression = true } = (options ?? {});
1085
+ const scene = new THREE.Scene();
1086
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
1087
+ camera.position.set(0, FRAME_CENTRE, 6);
1088
+ camera.lookAt(0, FRAME_CENTRE, 0);
1089
+ const renderer = webglRenderer();
1090
+ // No context, no face — and that has to be the whole of it. `createAvatar` is
1091
+ // synchronous and returns `{ destroy }`, so a consumer has nothing to catch:
1092
+ // anything thrown here lands in *their* window and takes the call page with
1093
+ // it, over a browser condition that is nobody's defect. WebGL is unavailable
1094
+ // more often than it looks — a driver on a blocklist, a hardened profile, a
1095
+ // remote desktop — and the right outcome is a call that still has audio,
1096
+ // captions and states, with an empty tile where the head would be.
1097
+ //
1098
+ // `warn` rather than `error` on purpose: `[avatar]` console errors mean a
1099
+ // defect in this package, and the capture tools fail a run on any of them.
1100
+ // This one says the environment cannot draw, which is a different sentence.
1101
+ if (!renderer) {
1102
+ console.warn("[avatar] no WebGL context; this character will not render in this browser");
1103
+ return { apply() { }, destroy() { } };
1104
+ }
1105
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
1106
+ // `setSize(…, false)` below leaves CSS alone, so the canvas has to be told to
1107
+ // fill the mount. Without this it displays at its backing-store size, which on
1108
+ // a 2x screen is a head twice the tile, cropped to its top-left quarter —
1109
+ // invisible at devicePixelRatio 1, which is what every capture tool ran at.
1110
+ renderer.domElement.style.cssText = "display:block;width:100%;height:100%";
1111
+ mount.append(renderer.domElement);
1112
+ scene.add(new THREE.AmbientLight(0xffffff, AMBIENT));
1113
+ const key = new THREE.DirectionalLight(0xffffff, KEY);
1114
+ key.position.set(0, 1.0, 3.0);
1115
+ scene.add(key);
1116
+ for (const x of [-1.9, 1.9]) {
1117
+ const wrap = new THREE.DirectionalLight(0xffffff, WRAP);
1118
+ wrap.position.set(x, 1.0, 2.5);
1119
+ scene.add(wrap);
1120
+ }
1121
+ // The head swings as a group; the neck and torso do not (see HEAD_PARTS).
1122
+ // Parenting to a pivot object rather than rotating each part is not a
1123
+ // convenience — the parts have to rotate about a shared point, and a per-mesh
1124
+ // rotation about each mesh's own origin would slide them apart.
1125
+ //
1126
+ // The body's two transforms nest outside it, one group each (see BODY):
1127
+ // `trunk` sways everything, and `lift` carries the neck and head on the
1128
+ // breath the torso takes inside `trunk` — and on the lean's rigid share,
1129
+ // which reaches the head the same way for the same reason.
1130
+ //
1131
+ // There were three. The outermost was `figure`, and it existed only to scale
1132
+ // the whole character for `torsoLean`; with the camera a sibling rather than
1133
+ // a child, that was a zoom and not a lean. The trunk deforms on the shell now
1134
+ // (`morphs.torso_targets`), so the group has no work left and is gone rather
1135
+ // than left behind as an identity transform for someone to wonder about.
1136
+ const trunk = new THREE.Group();
1137
+ const lift = new THREE.Group();
1138
+ const head = new THREE.Group();
1139
+ head.position.copy(PIVOT);
1140
+ // Yaw and pitch turn `head`; roll turns `tilt`, which rides inside them at
1141
+ // the chin (ROLL_PIVOT), so the parts hang from `tilt`.
1142
+ const tilt = new THREE.Group();
1143
+ tilt.position.subVectors(ROLL_PIVOT, PIVOT);
1144
+ scene.add(trunk);
1145
+ trunk.add(lift);
1146
+ lift.add(head);
1147
+ head.add(tilt);
1148
+ let torso = null;
1149
+ let destroyed = false;
1150
+ let pending = null;
1151
+ let loaded = false;
1152
+ let warnedAboutRuntimeBudget = false;
1153
+ // Every mesh that owns morph targets, paired with the dictionary three.js
1154
+ // built from the GLB's `extras.targetNames` — i.e. the pose channel names
1155
+ // `scripts/morphs.py` authored them under.
1156
+ const morphed = [];
1157
+ const eyes = [];
1158
+ // Both globes turn together, so one rotation serves both sockets.
1159
+ const gaze = { value: new THREE.Matrix3() };
1160
+ const turn = new THREE.Matrix4();
1161
+ let expression = null;
1162
+ // The `Hair` shell, on a character whose hair hangs low enough to have the
1163
+ // roll pair; null on one whose hair stops beside the temple (`HAIR_ROLL`).
1164
+ let hairMesh = null;
1165
+ // The head's roll in the asset's own frame — what both the neck's follow and
1166
+ // the hair's are authored about — and the hair's own, which chases it.
1167
+ let headRollRad = 0;
1168
+ let hairRollRad = 0;
1169
+ let hairRate = 0;
1170
+ let hairSeeded = false;
1171
+ /** The share of the head's roll the hair settles at. */
1172
+ const hairTarget = () => headRollRad * (1 - HAIR_ROLL.hold);
1173
+ const writeHair = () => {
1174
+ const dictionary = hairMesh?.morphTargetDictionary;
1175
+ const influences = hairMesh?.morphTargetInfluences;
1176
+ if (!dictionary || !influences)
1177
+ return;
1178
+ const extra = hairRollRad - headRollRad;
1179
+ for (const [channel, index] of Object.entries(dictionary)) {
1180
+ const term = hairInfluence(channel, extra);
1181
+ if (term !== null)
1182
+ influences[index] = term;
1183
+ }
1184
+ };
1185
+ const stepHair = (dt) => {
1186
+ if (!hairMesh || !hairSeeded)
1187
+ return;
1188
+ ({ angle: hairRollRad, rate: hairRate } =
1189
+ hairRollStep(hairRollRad, hairRate, hairTarget(), dt));
1190
+ };
1191
+ const resize = () => {
1192
+ const width = Math.max(1, mount.clientWidth);
1193
+ const height = Math.max(1, mount.clientHeight || Math.round((width * 3) / 4));
1194
+ renderer.setPixelRatio(pixelRatioFor(width, height, window.devicePixelRatio));
1195
+ renderer.setSize(width, height, false);
1196
+ // The framed *height* is fixed and the width follows the mount, so a tile
1197
+ // of the wrong aspect shows more or less background rather than a face of
1198
+ // the wrong shape. The atlas cannot be stretched: it is a photograph.
1199
+ // Symmetric about the camera, which is *already* at the frame's centre
1200
+ // height. Offsetting the frustum by that centre as well applies it twice:
1201
+ // the face rendered 0.47 face heights low, at exactly the right size, which
1202
+ // reads as a framing choice rather than as the arithmetic error it was.
1203
+ const halfHeight = FRAME_HEIGHT / 2;
1204
+ const halfWidth = (halfHeight * width) / height;
1205
+ camera.top = halfHeight;
1206
+ camera.bottom = -halfHeight;
1207
+ camera.left = -halfWidth;
1208
+ camera.right = halfWidth;
1209
+ camera.updateProjectionMatrix();
1210
+ };
1211
+ const observer = new ResizeObserver(resize);
1212
+ observer.observe(mount);
1213
+ resize();
1214
+ const applyPose = (pose) => {
1215
+ headRollRad = radians(((pose.headRoll ?? 0) / HEAD_CLAMP) * HEAD_DEG.roll);
1216
+ // The first pose is a starting point, not a movement: seed the hair where it
1217
+ // would have settled, so a tool that sets one pose and screenshots it gets
1218
+ // the hold with no transient, and only a *change* of roll swings the hank.
1219
+ if (!hairSeeded) {
1220
+ hairSeeded = true;
1221
+ hairRollRad = hairTarget();
1222
+ hairRate = 0;
1223
+ }
1224
+ for (const mesh of morphed) {
1225
+ const dictionary = mesh.morphTargetDictionary;
1226
+ const influences = mesh.morphTargetInfluences;
1227
+ if (!dictionary || !influences)
1228
+ continue;
1229
+ const neck = mesh.name === "Neck";
1230
+ const hair = mesh === hairMesh;
1231
+ for (const [channel, index] of Object.entries(dictionary)) {
1232
+ // The hair carries the head's roll channel too, and it is neither a
1233
+ // channel value nor the head's own angle: it is how much *further* than
1234
+ // the skull this shell is turned, which is negative while it holds and
1235
+ // swings either side of that while it settles (`HAIR_ROLL`).
1236
+ if (hair) {
1237
+ const term = hairInfluence(channel, hairRollRad - headRollRad);
1238
+ if (term !== null) {
1239
+ influences[index] = term;
1240
+ continue;
1241
+ }
1242
+ }
1243
+ // The neck's head targets are the rotation's two terms, not a channel
1244
+ // scaled by the influence law. Only on the neck: the same three channel
1245
+ // names on the head group are a rigid transform, and nowhere else.
1246
+ if (neck) {
1247
+ const term = neckInfluence(channel, pose);
1248
+ if (term !== null) {
1249
+ influences[index] = term;
1250
+ continue;
1251
+ }
1252
+ }
1253
+ const value = pose[channel];
1254
+ const follow = channel.startsWith("squint") ? LOWER_LID_FOLLOW * Math.max(pose.pupilY ?? 0, 0) : 0;
1255
+ if (value === undefined && !follow)
1256
+ continue;
1257
+ influences[index] = (value === undefined ? 0 : influence(channel, value)) - follow;
1258
+ }
1259
+ }
1260
+ // Blender's Z is face-space v, so its yaw is about Z, its pitch about X and
1261
+ // its roll about Y. The export maps Blender (x, y, z) to glTF (x, z, −y),
1262
+ // so Blender +Z *is* glTF +Y and Blender +X is glTF +X: yaw and pitch carry
1263
+ // across with their sign intact. Only roll changes sign, because Blender +Y
1264
+ // is glTF −Z, and that is a statement about two axes and not about the
1265
+ // channel.
1266
+ //
1267
+ // Yaw was negated here as well until 2026-09-10, on the belief that the
1268
+ // export flips the handedness of a turn. It does not — both frames are
1269
+ // right-handed — and the cost was a head that turned toward the viewer's
1270
+ // *left* on a positive `headYaw`, against `params.js`'s stated sign. It
1271
+ // survived because the same negation was in `build_tara.pose_head`, so the
1272
+ // Blender preview and the browser agreed with each other and only disagreed
1273
+ // with the library. `gaze.js` is what makes it a defect rather than a
1274
+ // convention: it hands `pupilX` and `headYaw` the same aversion term, so
1275
+ // tara's eyes went one way and her head went the other.
1276
+ //
1277
+ // YXZ because that is the order a neck composes in — yaw carrying the pitch
1278
+ // — rather than the order three.js defaults to.
1279
+ // The body first, because the head's roll is stated against it.
1280
+ //
1281
+ // Positive `torsoTurn` takes the trunk to the viewer's right, which about a
1282
+ // pivot below it is a clockwise roll as the camera sees it: negative about
1283
+ // glTF +Z, the axis pointing at the camera.
1284
+ const sway = -radians((pose.torsoTurn ?? 0) * BODY.swayDeg);
1285
+ trunk.rotation.z = sway;
1286
+ trunk.position.set(BODY.hip * Math.sin(sway), BODY.hip * (1 - Math.cos(sway)), 0);
1287
+ // `torsoLean` itself is not applied here at all — it is two morph targets on
1288
+ // the shell, driven by the generic loop above like any other channel that
1289
+ // rests at 0. What is left for the rig is the head's ride: above the collar
1290
+ // the field is flat, so the neck and head take a pure translation and the
1291
+ // head does not deform under a body lean (research § 8 item 4). `morphs.py`
1292
+ // parses `leanRide`, so this is the same number the field plateaus at.
1293
+ const breath = pose.breath ?? 0;
1294
+ if (torso) {
1295
+ torso.scale.set(1 + breath * BODY.widen, 1 + breath * BODY.rise, 1);
1296
+ torso.position.y = -BODY.swellPivot * breath * BODY.rise;
1297
+ }
1298
+ lift.position.y = breath * BODY.rise * (BODY.collar - BODY.swellPivot)
1299
+ - (pose.torsoLean ?? 0) * BODY.leanRide;
1300
+ head.rotation.order = "YXZ";
1301
+ head.rotation.set(radians(((pose.headPitch ?? 0) / HEAD_CLAMP) * HEAD_DEG.pitch), radians(((pose.headYaw ?? 0) / HEAD_CLAMP) * HEAD_DEG.yaw), 0);
1302
+ // Less the trunk's roll, so the head's world roll is `headRoll` and
1303
+ // nothing else. Exact about Z alone; with the yaw and pitch it composes
1304
+ // with, the error is the product of two sub-degree angles.
1305
+ tilt.rotation.z = -headRollRad - sway;
1306
+ for (const globe of eyes) {
1307
+ globe.rotation.order = "YXZ";
1308
+ globe.rotation.set((pose.pupilY ?? 0) * GAZE_TRAVEL.y / GLOBE_RADIUS, (pose.pupilX ?? 0) * GAZE_TRAVEL.x / GLOBE_RADIUS, 0);
1309
+ }
1310
+ if (eyes.length)
1311
+ gaze.value.setFromMatrix4(turn.makeRotationFromEuler(eyes[0].rotation));
1312
+ if (expression) {
1313
+ expressionWeights(pose, "L", expression, expression.left);
1314
+ expressionWeights(pose, "R", expression, expression.right);
1315
+ }
1316
+ };
1317
+ new GLTFLoader().load(url, (gltf) => {
1318
+ if (destroyed)
1319
+ return;
1320
+ trunk.add(gltf.scene);
1321
+ // Collect the morphed meshes *before* reparenting: every one of them is a
1322
+ // head part, so a traverse of `gltf.scene` after the move finds only the
1323
+ // neck and the torso and the whole face goes rigid — silently, because a
1324
+ // rig with nothing to drive still renders a perfectly good rest pose.
1325
+ gltf.scene.traverse((object) => {
1326
+ const mesh = object;
1327
+ if (mesh.isMesh && mesh.morphTargetDictionary)
1328
+ morphed.push(mesh);
1329
+ // Kept aside as well: its roll pair is driven by a clock and not only by a
1330
+ // pose, so the render loop has to reach it between poses (`HAIR_ROLL`).
1331
+ if (mesh.isMesh && mesh.name === "Hair" && mesh.morphTargetDictionary)
1332
+ hairMesh = mesh;
1333
+ });
1334
+ // Out of the scene before anything draws it: it carries the maps, and is
1335
+ // one triangle behind the body that nothing should pay a draw call for.
1336
+ const carrier = gltf.scene.getObjectByName("Expression");
1337
+ if (carrier) {
1338
+ carrier.removeFromParent();
1339
+ expression = readExpression ? expressive(carrier) : null;
1340
+ if (!expression)
1341
+ carrier.material.map?.dispose();
1342
+ carrier.geometry.dispose();
1343
+ carrier.material.dispose();
1344
+ }
1345
+ for (const name of HEAD_PARTS) {
1346
+ const part = gltf.scene.getObjectByName(name);
1347
+ // Reparenting moves the object into the tilt's frame, whose origin is
1348
+ // ROLL_PIVOT, so subtract that to leave the part where it was authored.
1349
+ // `attach()` would do this from the world matrix, which has not been
1350
+ // computed yet at load.
1351
+ if (part) {
1352
+ part.position.sub(ROLL_PIVOT);
1353
+ tilt.add(part);
1354
+ }
1355
+ }
1356
+ // One clone per source material, shared by every shell that carries the
1357
+ // field; a mesh without it keeps the material it came with.
1358
+ const turned = new Map();
1359
+ head.traverse((object) => {
1360
+ const mesh = object;
1361
+ if (!mesh.isMesh || !mesh.geometry.getAttribute(MOTION_DEPTH))
1362
+ return;
1363
+ const base = mesh.material;
1364
+ const own = turned.get(base) ?? motionDepth(base, expression ?? undefined);
1365
+ turned.set(base, own);
1366
+ mesh.material = own;
1367
+ });
1368
+ // The neck rides the breath with the head; the torso *is* the breath. Both
1369
+ // are authored in world space with identity nodes, and `lift` sits at the
1370
+ // origin until a pose arrives, so moving the neck needs no correction.
1371
+ const neck = gltf.scene.getObjectByName("Neck");
1372
+ if (neck)
1373
+ lift.add(neck);
1374
+ // The shadow's map comes from the build (`build_tara.py`, the neck); an
1375
+ // asset without it keeps the shadow painted on, as every build did before.
1376
+ const skull = head.getObjectByName("Head");
1377
+ const { jaw_shadow_uv: jawUv, jaw_shadow_extent: jawExtent, jaw_shadow_rim_z: jawRim } = neck?.userData ?? {};
1378
+ if (neck && skull && Array.isArray(jawUv) && Array.isArray(jawExtent) && typeof jawRim === "number") {
1379
+ const headInverse = { value: new THREE.Matrix4() };
1380
+ neck.material = jawShadow(neck.material, jawUv, jawExtent, jawRim, headInverse);
1381
+ neck.onBeforeRender = (_renderer, _scene, camera) => {
1382
+ headInverse.value.multiplyMatrices(camera.matrixWorldInverse, skull.matrixWorld).invert();
1383
+ };
1384
+ }
1385
+ torso = gltf.scene.getObjectByName("Body") ?? null;
1386
+ for (const name of ["Eye_L", "Eye_R"]) {
1387
+ const globe = head.getObjectByName(name);
1388
+ if (!globe)
1389
+ continue;
1390
+ // The globe's geometry is authored in world space with no node transform,
1391
+ // so it has to be rotated about its own centre rather than about the
1392
+ // origin. Re-centre the geometry once and put the offset on the node; the
1393
+ // catchlight is already a child and rides the globe, which is what a
1394
+ // reflection on a wet cornea does.
1395
+ const geometry = globe.geometry;
1396
+ geometry.computeBoundingSphere();
1397
+ const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3();
1398
+ geometry.translate(-centre.x, -centre.y, -centre.z);
1399
+ globe.position.add(centre);
1400
+ for (const child of globe.children)
1401
+ child.position.sub(centre);
1402
+ const mesh = globe;
1403
+ // Whether the globe turns deep is the attribute's to say, not the name's —
1404
+ // the same rule the frame's shells are found by above. An asset built
1405
+ // before the globes carried the field keeps the socket shader alone.
1406
+ mesh.material = socketed(mesh.material, name === "Eye_L" ? -1 : 1, gaze, mesh.geometry.getAttribute(MOTION_DEPTH) !== undefined);
1407
+ eyes.push(globe);
1408
+ }
1409
+ // The mouth's inside, which is otherwise the one flat-lit surface on this
1410
+ // face. Found by *mesh* name rather than material name: `flat_material`
1411
+ // hard-codes a `tara_` prefix, so tushar's cavity material is called
1412
+ // `tara_cavity` as well, and the mesh is what distinguishes it.
1413
+ const cavity = head.getObjectByName("Cavity");
1414
+ if (cavity) {
1415
+ cavity.geometry.computeBoundingBox();
1416
+ const box = cavity.geometry.boundingBox;
1417
+ if (box) {
1418
+ cavity.material = cavityShade(cavity.material, box.min.y, box.max.y);
1419
+ }
1420
+ }
1421
+ // A rig with no morph targets still renders a perfectly good rest pose, so
1422
+ // the failure mode of losing them is a face that simply never moves — which
1423
+ // a still frame cannot show. Say so, loudly enough that the Gate B capture
1424
+ // (which fails on any `[avatar]` console error) catches it.
1425
+ if (!morphed.length)
1426
+ console.error("[avatar] this character has no morph targets; the face will not move", url);
1427
+ loaded = true;
1428
+ if (pending)
1429
+ applyPose(pending);
1430
+ onReady?.();
1431
+ }, undefined, (error) => console.error("[avatar] could not load this character", url, error));
1432
+ // The tolerance is not a fudge factor, it is the whole of what makes the cap
1433
+ // land on 30. rAF fires on the panel's own grid, so the elapsed time is only
1434
+ // ever a multiple of the refresh interval and lands *near* 33.3 ms rather than
1435
+ // on it: 33.33 on a 60 Hz panel, and either side of it under any timestamp
1436
+ // jitter. A bare `elapsed < MIN_FRAME_MS` therefore rejects the frame it wants
1437
+ // and waits for the next one — 50 ms, i.e. 20 fps, not 30. Four milliseconds
1438
+ // is under half the interval of every rate worth caring about (8.3 at 120,
1439
+ // 11.1 at 90, 16.7 at 60), so it can never admit two frames where one belongs,
1440
+ // and it puts 60, 90 and 120 Hz all on 30 fps.
1441
+ const GRID_TOLERANCE_MS = 4;
1442
+ let lastFrameMs = 0;
1443
+ renderer.setAnimationLoop(() => {
1444
+ const now = performance.now();
1445
+ const elapsed = now - lastFrameMs;
1446
+ if (elapsed < MIN_FRAME_MS - GRID_TOLERANCE_MS)
1447
+ return;
1448
+ lastFrameMs = now;
1449
+ // The hair lags the skull and settles behind it, which is a state and not a
1450
+ // function of the pose, so it advances on the clock. Clamped, because a tab
1451
+ // that was in the background hands back a gap and not a frame.
1452
+ stepHair(Math.min(elapsed / 1000, 0.1));
1453
+ writeHair();
1454
+ renderer.render(scene, camera);
1455
+ if (!warnedAboutRuntimeBudget &&
1456
+ (renderer.info.render.calls > HARD_BUDGET.drawCalls ||
1457
+ renderer.info.render.triangles > HARD_BUDGET.triangles)) {
1458
+ warnedAboutRuntimeBudget = true;
1459
+ console.warn("[avatar] runtime budget exceeded", {
1460
+ calls: renderer.info.render.calls,
1461
+ triangles: renderer.info.render.triangles,
1462
+ });
1463
+ }
1464
+ });
1465
+ return {
1466
+ apply(frame) {
1467
+ // The mixer smooths every channel before it gets here (each has its own
1468
+ // τ, `docs/internal-rig.md`), so the rig eases nothing itself. Poses that
1469
+ // arrive before the GLB does are not queued, only remembered: the newest
1470
+ // one is the only one that was ever going to be shown.
1471
+ if (!loaded) {
1472
+ pending = frame.pose;
1473
+ return;
1474
+ }
1475
+ applyPose(frame.pose);
1476
+ },
1477
+ destroy() {
1478
+ if (destroyed)
1479
+ return;
1480
+ destroyed = true;
1481
+ observer.disconnect();
1482
+ renderer.setAnimationLoop(null);
1483
+ scene.traverse((object) => {
1484
+ const mesh = object;
1485
+ mesh.geometry?.dispose();
1486
+ for (const item of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
1487
+ const material = item;
1488
+ material?.map?.dispose();
1489
+ material?.emissiveMap?.dispose();
1490
+ material?.dispose();
1491
+ }
1492
+ });
1493
+ // A uniform, not a material's map, so the traverse above never meets it.
1494
+ expression?.map.dispose();
1495
+ renderer.dispose();
1496
+ renderer.domElement.remove();
1497
+ },
1498
+ };
1499
+ }
1500
+ //# sourceMappingURL=tara-rig.js.map