@voqalize/avatar 0.4.2 → 0.4.3

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