@voqalize/avatar 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// avatars/round/face.mjs — this character, as a PURE function of a control
|
|
3
|
+
// vector. No filesystem, no poses, no serialiser: the parameters, the persona
|
|
4
|
+
// and its palette, the landmark solve, the shape tables, the kit and
|
|
5
|
+
// `buildDraws(c, K)`, which is the whole of what round LOOKS like.
|
|
6
|
+
//
|
|
7
|
+
// It split off `build.mjs` for one reason: `src/live.js` evaluates this file in
|
|
8
|
+
// the BROWSER, once per frame, to draw the face from voqalize's 30 channels
|
|
9
|
+
// directly — the linearity spike's option (d), because the mouth's channel
|
|
10
|
+
// coupling is far too strong for baked poses to reconstruct (a 6 px @1x worst
|
|
11
|
+
// vertex and seven alpha ramps categorically wrong; see NOTES.md). So nothing
|
|
12
|
+
// in this file, or in anything it imports, may touch a `node:` builtin — which
|
|
13
|
+
// is why `writeRig` moved out of `author/rig.mjs` into `author/finish.mjs`.
|
|
14
|
+
// `build.mjs` imports this and adds everything that is authoring: the pose
|
|
15
|
+
// harness, the tracks, the camera's metadata, the wardrobe, the writer.
|
|
16
|
+
//
|
|
17
|
+
// The split is a MOVE and nothing else. Every one of the five round rigs is
|
|
18
|
+
// byte-identical across it.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { spline, polygon, sampleRun, inward, circle, band } from '../../author/path.mjs';
|
|
22
|
+
import {
|
|
23
|
+
clamp, lerp, hsl,
|
|
24
|
+
paintRegistry, makeCtrl, applyWeights, solveIrisBase, REST_CONTROLS, drawPusher,
|
|
25
|
+
} from '../../author/rig.mjs';
|
|
26
|
+
import { makeMouth, mouthRestChannels } from '../../author/parts/mouth.mjs';
|
|
27
|
+
import { makeEye, eyeChannelRest, eyeSide, BROW_PX, EYE_TABLE } from '../../author/parts/eye.mjs';
|
|
28
|
+
import { makeNose } from '../../author/parts/nose.mjs';
|
|
29
|
+
import { makeSkinDetail } from '../../author/parts/skin-detail.mjs';
|
|
30
|
+
import { makeHand, handRest, handFrameOf } from '../../author/parts/hand.mjs';
|
|
31
|
+
import { viewBoxForHead } from '../../../camera.js';
|
|
32
|
+
|
|
33
|
+
// ===========================================================================
|
|
34
|
+
// 1. PARAMETERS — the whole character's proportions live here.
|
|
35
|
+
// ===========================================================================
|
|
36
|
+
|
|
37
|
+
export const P = {
|
|
38
|
+
artboard: { w: 1080, h: 1625 },
|
|
39
|
+
cx: 540,
|
|
40
|
+
|
|
41
|
+
// Round/heart head: y 246..890 (644 tall), widest at the cheeks (±296 -> 592
|
|
42
|
+
// across) for a w:h of 0.92, narrowing to a soft chin ~38% of the cheek
|
|
43
|
+
// width. The opposite of facet's square jaw and rounder than the mascot's
|
|
44
|
+
// oval.
|
|
45
|
+
headTop: 246,
|
|
46
|
+
chinY: 890,
|
|
47
|
+
|
|
48
|
+
// Adult proportions (2026-08): the eye line sits at half the head, the eye
|
|
49
|
+
// is ~21% of the face width and the lid opening is squarer, per the
|
|
50
|
+
// late-twenties reference bust in parts/ref/portrait. The skull itself
|
|
51
|
+
// (headTop/chinY) is untouched so the fringe and the hair plates still fit.
|
|
52
|
+
browY: 525, // brow centreline, mean of bwI/bwM/bwO
|
|
53
|
+
eyeY: 578, // eye centreline, 53% of head height (was 606 / 56%)
|
|
54
|
+
noseBaseY: 694,
|
|
55
|
+
mouthY: 768,
|
|
56
|
+
|
|
57
|
+
eyeGapHalf: 74, // half the inter-canthal gap (1.12 eye-widths apart)
|
|
58
|
+
eyeHalfW: 66, // 20.7% of the cheek width; the reference reads 21-22%
|
|
59
|
+
eyeTopH: 34,
|
|
60
|
+
eyeBotH: 30, // opening h:w 0.48 — an adult lid, not a cute circle
|
|
61
|
+
// Iris 0.52 of the eye opening's width — the canon is 0.55-0.65 and a cute
|
|
62
|
+
// style pushes higher still, but the adult eye is narrower, so the iris
|
|
63
|
+
// comes down with it rather than crowding the lids.
|
|
64
|
+
irisR: 34,
|
|
65
|
+
pupilR: 13, // 0.38 of the iris diameter: mid dilation
|
|
66
|
+
|
|
67
|
+
mouthHalfW: 100,
|
|
68
|
+
lipUpTh: 15,
|
|
69
|
+
lipLowTh: 24,
|
|
70
|
+
restLipGap: 2,
|
|
71
|
+
|
|
72
|
+
jawDrop: 34, // how far the chin travels at jaw = 1
|
|
73
|
+
|
|
74
|
+
scale: 1.0, // whole-figure zoom, anchored at the crown
|
|
75
|
+
shadowInset: 9, // how far the side plane sits inside the silhouette
|
|
76
|
+
neckPivot: [540, 1180], // head rotates about the base of the neck
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Flat-vector shading is planes, not a ramp: five skin values exist and only
|
|
80
|
+
// three of them are large areas (face, side plane, neck).
|
|
81
|
+
const RAMP = 5;
|
|
82
|
+
|
|
83
|
+
// ===========================================================================
|
|
84
|
+
// 2. COLOUR — THE PERSONA
|
|
85
|
+
//
|
|
86
|
+
// A persona is everything this face is made of that is not geometry: a skin
|
|
87
|
+
// tone, an iris, a pair of lips, a brow. It arrives as a plain blob with
|
|
88
|
+
// every key optional, and `makeSpec(persona)` (§10) turns one into a whole
|
|
89
|
+
// spec — so a variant of this character is a PALETTE plus a wardrobe
|
|
90
|
+
// sidecar, and the sidecar carries both: `author/finish.mjs` hands the
|
|
91
|
+
// sidecar's `persona` block straight to the factory without looking inside
|
|
92
|
+
// it. Nothing in the library knows what `skin` means, and nothing here
|
|
93
|
+
// knows what a blazer is.
|
|
94
|
+
//
|
|
95
|
+
// The vocabulary is ../ink's, key for key, wherever the two styles mean the
|
|
96
|
+
// same thing — `skin`, `lips.{up,low}`, `iris.{hue,saturation,brightness,
|
|
97
|
+
// sat,light,eye}`, `brow.{weight,colour}`, `lash.{weight}`. `blush` is the
|
|
98
|
+
// one key that is this style's alone, because the rouge is this style's
|
|
99
|
+
// alone. NOTES.md keeps the shared / round-only split, and the argument for
|
|
100
|
+
// not lifting any of it into the library yet.
|
|
101
|
+
//
|
|
102
|
+
// skin: [h, s, l] the LIT plane — the TOP rung of the five-plane
|
|
103
|
+
// ramp, and the only skin number there is.
|
|
104
|
+
// lips: { up, low } [h, s, l] each; the seam and the corner pockets are
|
|
105
|
+
// derived off `up`, the two highlights are white and
|
|
106
|
+
// are not.
|
|
107
|
+
// iris: { hue, saturation, brightness, the driver dial this face boots on
|
|
108
|
+
// sat, light, the 39-rung ladder's s and l
|
|
109
|
+
// eye } [r,g,b] on screen AT that dial
|
|
110
|
+
// brow: { weight, colour } thickness about the brow's own centre line,
|
|
111
|
+
// and the tone the lash is derived from
|
|
112
|
+
// lash: { weight } the upper lash's mass and the lower ticks
|
|
113
|
+
// eye: { aperture } vertical opening only; 1 is the family
|
|
114
|
+
// geometry, below 1 narrows without changing
|
|
115
|
+
// the adult eye-width ratio
|
|
116
|
+
// { irisScale, finish } optional editorial eye proportions and
|
|
117
|
+
// softer material contrast
|
|
118
|
+
// { refine } optional brow and upper-lid finishing;
|
|
119
|
+
// construction-only, never a driver channel
|
|
120
|
+
// mouth: { philtrum } optional quiet neutral-mouth plane
|
|
121
|
+
// form: { socket, socketDepth, sideShadeAlpha, sideShadeWidth } optional
|
|
122
|
+
// static head-attached form;
|
|
123
|
+
// it adds no performance channel
|
|
124
|
+
// blush: number how much of the cheek rouge survives — 1 is
|
|
125
|
+
// the generator's own, 0 turns it off
|
|
126
|
+
// sex: 'f' | 'm' which rest geometry the rig is BUILT at and
|
|
127
|
+
// which paint conventions apply (§3, SEX_GEO)
|
|
128
|
+
// geo: { channel: n } per-channel override of that rest patch
|
|
129
|
+
//
|
|
130
|
+
// Every default below is the number this file has always written, so
|
|
131
|
+
// `makeSpec()` — no persona — rebuilds `data/round.rig.json` byte for byte.
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
|
|
134
|
+
export const DEFAULT_PERSONA = {
|
|
135
|
+
// 'f' or 'm' — see SEX_GEO / sexPaint below §3. The default is the look this
|
|
136
|
+
// face has always had, so `makeSpec()` with no persona is unchanged.
|
|
137
|
+
sex: 'f',
|
|
138
|
+
// A light warm peach: the top of the ramp, rgb(224,201,184) — clearly
|
|
139
|
+
// lighter and pinker than the mascot's rgb(208,150,125) and nowhere near
|
|
140
|
+
// facet's olive rgb(178,113,79). It used to be rung 4 of a hard-coded
|
|
141
|
+
// `skin(i)`; the rung is now the parameter and the ramp is derived from it.
|
|
142
|
+
skin: [26, 0.40, 0.80],
|
|
143
|
+
// Upper lip 12% darker in L than the lower: it tilts away from the light and
|
|
144
|
+
// carries more pigment, and the delta is what stops a two-shape mouth reading
|
|
145
|
+
// as one flat sticker.
|
|
146
|
+
// Toned down for the adult read (2026-08): 0.42/0.45 was a made-up mouth on
|
|
147
|
+
// a young face. 0.30/0.32 is still a lip and not a lipstick.
|
|
148
|
+
lips: { up: [358, 0.30, 0.56], low: [2, 0.32, 0.64] },
|
|
149
|
+
// hue/saturation/brightness are the driver's own boot state (src/vocab.js,
|
|
150
|
+
// DRIVER_DEFAULTS) — the dial this face wants to be found at, written to
|
|
151
|
+
// `meta.iris` for a player to start on. sat/light are the LADDER's, i.e.
|
|
152
|
+
// what the eye does as somebody drags that dial: the whole excursion across
|
|
153
|
+
// the 39 rungs is `ladder(h) - ladder(hue)`, so a dark ladder is what keeps
|
|
154
|
+
// a dark eye dark at every hue and a bright one cannot be made to.
|
|
155
|
+
// `eye` is the warm hazel this face has always rested on.
|
|
156
|
+
iris: { hue: 200, saturation: 0.15, brightness: 0.5, sat: 0.45, light: 0.42, eye: [138, 105, 72] },
|
|
157
|
+
brow: { weight: 1, colour: [14, 0.44, 0.27] },
|
|
158
|
+
lash: { weight: 1 },
|
|
159
|
+
// Half the rouge the cute face wore. A professional in her late twenties has
|
|
160
|
+
// cheeks, not a doll's blush spots.
|
|
161
|
+
blush: 0.5,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Auburn bob: three values, back / front / highlight.
|
|
165
|
+
const hair = (i) => hsl(lerp(12, 26, i / 2), 0.50 - 0.04 * i, 0.20 + 0.07 * i);
|
|
166
|
+
// Muted sage top, two values.
|
|
167
|
+
const shirt = (i) => hsl(lerp(148, 154, i), 0.22 - 0.02 * i, 0.33 + 0.07 * i);
|
|
168
|
+
|
|
169
|
+
// The tone every offset below was authored against. Hue and saturation travel
|
|
170
|
+
// as OFFSETS off the persona's skin; lightness travels as a RATIO of it, which
|
|
171
|
+
// is this file's whole answer to the ink lesson (../ink/NOTES.md): a step in
|
|
172
|
+
// lightness that is a constant is most of the light a deep brown has left, so
|
|
173
|
+
// the jaw plane turns into a hole. A ratio scales itself, and — because the
|
|
174
|
+
// side plane here is a translucent overlay rather than an opaque tone — it is
|
|
175
|
+
// also what keeps `alpha * (shade - face)` proportional to the face, i.e. the
|
|
176
|
+
// plane darkens the same FRACTION on every skin instead of vanishing on the
|
|
177
|
+
// dark ones.
|
|
178
|
+
const L_REF = DEFAULT_PERSONA.skin[2];
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// The palette, from a persona.
|
|
182
|
+
//
|
|
183
|
+
// DERIVED FROM `skin`:
|
|
184
|
+
// * the five-plane ramp itself — `face` (rung 4, the persona's own tone),
|
|
185
|
+
// `ear` 3.5, `earR` / `neck` 3.0, `nose` 2.9, `earIn` 2.7, `earInR` 2.4.
|
|
186
|
+
// Hue walks 22 degrees redder down the ramp and saturation peaks in the
|
|
187
|
+
// middle of it, both exactly as before; the LIGHTNESS of every rung is a
|
|
188
|
+
// RATIO of the persona's, so rung 3.0 is 89% of the lit plane on a peach
|
|
189
|
+
// and 89% of it on a deep brown, and the neck reads as the same lighting
|
|
190
|
+
// on both instead of as a hole on the second.
|
|
191
|
+
// * `shade` / `neckSh`, the translucent side plane and the chin's cast
|
|
192
|
+
// shadow — a much darker, more saturated version of the same skin
|
|
193
|
+
// (hue -16, sat +0.15, lightness x 0.425), which at the default is exactly
|
|
194
|
+
// the hsl(10, 0.55, 0.34) this file has always written.
|
|
195
|
+
// * `crease` and `water`, both skin marks and therefore both wrong the
|
|
196
|
+
// moment they are constants: a 0.55-lightness crease on a 0.50-lightness
|
|
197
|
+
// skin is a LIGHT line above the eye.
|
|
198
|
+
// * `carun`, the tear duct, and `blush`, the cheek — the same skin pushed
|
|
199
|
+
// pink; `blush`'s alpha is then scaled by `persona.blush`.
|
|
200
|
+
// DERIVED FROM `lips.up`: `seam` and `commiss`, the lip line and the corner
|
|
201
|
+
// pockets — the upper lip at 45% / 52% of its lightness. A lip seam is the
|
|
202
|
+
// shadow between the lips, so it has to follow the lips and not the style.
|
|
203
|
+
// DERIVED FROM `brow.colour`: `brow`, `browR` (the shadow-side brow, 0.05
|
|
204
|
+
// darker) and `lash` — one hair colour for everything above the eye.
|
|
205
|
+
// NOT DERIVED, deliberately:
|
|
206
|
+
// * `sclera`, `scleraShade`, `pupil`, `teeth`, `mouthIn`, `tongue` — an eye
|
|
207
|
+
// white and an enamel are MATERIALS, the same on everybody; what has to be
|
|
208
|
+
// checked on a dark skin is their contrast, not their value.
|
|
209
|
+
// * every translucent overlay whose job is light rather than pigment —
|
|
210
|
+
// `eyeShade`, `limbal`, `irisGlow`, `catch`, `catch2`, `lipHi`, `lipBow`,
|
|
211
|
+
// `toothSep`, `toothSh`, `hairHi`. author/README.md's overlay convention:
|
|
212
|
+
// an overlay is an ink or a white AT AN ALPHA, never a colour derived from
|
|
213
|
+
// what it sits on, because the thing underneath is repainted 39 times by
|
|
214
|
+
// the hue ladder.
|
|
215
|
+
// * `hairBack` / `hairFront` / `shirt` / `collar` — the bald rig's own
|
|
216
|
+
// character, and every persona variant covers all four with a bitmap.
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
function makePalette(p) {
|
|
220
|
+
const [H, S, L] = p.skin;
|
|
221
|
+
const eyeFinish = p.eye?.finish || {};
|
|
222
|
+
|
|
223
|
+
// Skin: five planes off one tone. Hue walks 22 degrees redder down the ramp
|
|
224
|
+
// and saturation peaks in the middle of it, both exactly as before; the
|
|
225
|
+
// LIGHTNESS is a ratio of the persona's, which is the whole of the ink
|
|
226
|
+
// lesson (../ink/NOTES.md) and is why `lerp(0.46, 0.80, t)` is still written
|
|
227
|
+
// out — those are the five rungs of the tone this ramp was tuned at, and
|
|
228
|
+
// every other skin gets them as a proportion. A flat -0.085 of lightness for
|
|
229
|
+
// the neck is 11% of a pale peach and 16% of a deep brown, and the 5% is
|
|
230
|
+
// exactly the difference between "the light is off it" and "it is a
|
|
231
|
+
// different material". The saturation bump travels with it for the mirror
|
|
232
|
+
// reason: +0.10 keeps a PALE plane from going grey, and a deep brown has no
|
|
233
|
+
// such problem — at l = 0.46 it just makes an orange. At the default skin
|
|
234
|
+
// both scalings are 1.0 and the five rungs are the old numbers to the digit.
|
|
235
|
+
const skin = (i) => {
|
|
236
|
+
const t = clamp(i, 0, RAMP - 1) / (RAMP - 1);
|
|
237
|
+
return hsl(lerp(H - 22, H, t),
|
|
238
|
+
S + 0.10 * (L / L_REF) * Math.sin(Math.PI * t),
|
|
239
|
+
L / L_REF * lerp(0.46, 0.80, t));
|
|
240
|
+
};
|
|
241
|
+
// A tone authored against the default skin, re-expressed as a relationship.
|
|
242
|
+
const off = (dh, ds, l) => hsl(H + dh, S + ds, L / L_REF * l);
|
|
243
|
+
|
|
244
|
+
// The side plane and the chin's cast shadow are translucent warm brown rather
|
|
245
|
+
// than opaque skin values. Opaque planes force every shape underneath to know
|
|
246
|
+
// which plane it sits on — the shadow-side eyelid has to be painted in the
|
|
247
|
+
// shadow tone, and it then shows as a patch the moment the plane's boundary
|
|
248
|
+
// moves. A translucent plane painted last tints whatever is under it and that
|
|
249
|
+
// whole class of bug disappears.
|
|
250
|
+
const SHADE = (a) => [...off(-16, 0.15, 0.34).slice(0, 3), a];
|
|
251
|
+
|
|
252
|
+
const [uh, us, ul] = p.lips.up;
|
|
253
|
+
const UL_REF = DEFAULT_PERSONA.lips.up[2];
|
|
254
|
+
const lip = (dh, ds, l) => hsl(uh + dh, us + ds, ul / UL_REF * l);
|
|
255
|
+
const [wh, ws, wl] = p.brow.colour;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
face: skin(4),
|
|
259
|
+
shade: SHADE(p.form?.sideShadeAlpha ?? 0.17), // the one side plane
|
|
260
|
+
ear: skin(3.5),
|
|
261
|
+
earR: skin(3.0),
|
|
262
|
+
earIn: skin(2.7),
|
|
263
|
+
earInR: skin(2.4),
|
|
264
|
+
neck: skin(3.0),
|
|
265
|
+
neckSh: SHADE(0.15),
|
|
266
|
+
nose: skin(2.9),
|
|
267
|
+
noseBridge: off(-17, 0.08, 0.34),
|
|
268
|
+
noseUnder: off(-19, 0.10, 0.25),
|
|
269
|
+
noseAlar: off(-22, 0.10, 0.18),
|
|
270
|
+
skinFleck: off(-13, 0.13, 0.53),
|
|
271
|
+
skinMole: off(-21, 0.15, 0.31),
|
|
272
|
+
hairBack: hair(0),
|
|
273
|
+
hairFront: hair(1),
|
|
274
|
+
// The fringe highlight was an opaque lozenge; at the webcam crop that read as
|
|
275
|
+
// a decal stuck on the hair. Translucent, it becomes a sheen.
|
|
276
|
+
hairHi: [...hair(1.9).slice(0, 3), 0.50],
|
|
277
|
+
shirt: shirt(1),
|
|
278
|
+
collar: shirt(0),
|
|
279
|
+
// The rouge, at whatever fraction of itself the persona asks for. It is a
|
|
280
|
+
// knob rather than a colour because the thing a persona changes about a
|
|
281
|
+
// blush is almost never its hue: 0.20 alpha on a woman's cheek is the
|
|
282
|
+
// style, and the same 0.20 on a man's reads as make-up at 1x.
|
|
283
|
+
blush: [...off(-20, 0.22, 0.70).slice(0, 3), 0.20 * p.blush],
|
|
284
|
+
|
|
285
|
+
// --- the eye stack, back to front ---------------------------------------
|
|
286
|
+
// The two scleras differ by less than they used to: the side shading plane
|
|
287
|
+
// already tints the shadow-side eye, and doubling that up made the pair look
|
|
288
|
+
// asymmetric rather than lit once the camera moved in.
|
|
289
|
+
sclera: eyeFinish.sclera || [250, 248, 245, 1],
|
|
290
|
+
scleraShade: eyeFinish.scleraShade || [240, 236, 233, 1],
|
|
291
|
+
// One shape does both jobs the research asks of the sclera: the upper lid's
|
|
292
|
+
// cast shadow across the top, and the corner darkening — it is the opening's
|
|
293
|
+
// own outline with its lower edge raised most in the middle and not at all
|
|
294
|
+
// at the canthi, so it is deep at the corners and shallow under the lid.
|
|
295
|
+
eyeShade: [...hsl(348, 0.30, 0.30).slice(0, 3), eyeFinish.shadeAlpha ?? 0.15],
|
|
296
|
+
// Limbal ring and inner glow are NEUTRAL overlays on top of the iris — a
|
|
297
|
+
// translucent black annulus and a translucent white disc — never colours
|
|
298
|
+
// derived from the iris hue. That is what keeps the 39-rung `hue/*` ladder
|
|
299
|
+
// (which only ever swaps the iris paint) correct at every rung.
|
|
300
|
+
limbal: [12, 9, 14, eyeFinish.limbalAlpha ?? 0.30],
|
|
301
|
+
irisGlow: [255, 255, 255, eyeFinish.glowAlpha ?? 0.13],
|
|
302
|
+
pupil: [26, 18, 22, 1],
|
|
303
|
+
catch: [255, 255, 255, eyeFinish.catchAlpha ?? 0.95],
|
|
304
|
+
catch2: [255, 255, 255, eyeFinish.catch2Alpha ?? 0.25],
|
|
305
|
+
// The lower lid's own edge, read as a line rather than a lash.
|
|
306
|
+
water: [...off(-20, -0.04, 0.28).slice(0, 3), eyeFinish.waterAlpha ?? 0.22],
|
|
307
|
+
carun: [...off(-22, 0, 0.70).slice(0, 3), 0.42],
|
|
308
|
+
lash: hsl(wh - 6, ws - 0.02, wl - 0.10),
|
|
309
|
+
crease: [...off(-16, -0.06, 0.55).slice(0, 3), 0.42],
|
|
310
|
+
// One warm shade for the two soft folds of skin the eye makes: the
|
|
311
|
+
// thickness of the upper lid above the lash, and the roll of cheek a
|
|
312
|
+
// squint pushes up under the lower one. They are the same material seen
|
|
313
|
+
// from the same light, so they are the same paint — and one paint is what
|
|
314
|
+
// lets `squintSh` fade in on its own alpha without a second registry entry.
|
|
315
|
+
lidFold: [...off(-14, 0.04, 0.62).slice(0, 3), 0.30],
|
|
316
|
+
brow: hsl(wh, ws, wl),
|
|
317
|
+
browR: hsl(wh - 2, ws, wl - 0.05),
|
|
318
|
+
// A shallow upper-orbital plane connects the brow, lid and nose into one
|
|
319
|
+
// lit face. It is deliberately static: expression still belongs entirely
|
|
320
|
+
// to the existing brow/lid channels, while this only supplies head form.
|
|
321
|
+
socket: [...off(-14, 0.02, 0.46).slice(0, 3), p.form?.socketAlpha ?? 0.10],
|
|
322
|
+
|
|
323
|
+
// --- the mouth ------------------------------------------------------------
|
|
324
|
+
lipUp: hsl(...p.lips.up),
|
|
325
|
+
lipLow: hsl(...p.lips.low),
|
|
326
|
+
lipHi: [255, 240, 233, 0.24],
|
|
327
|
+
lipBow: [255, 238, 231, 0.13],
|
|
328
|
+
lipCast: [...off(-17, 0.08, 0.34).slice(0, 3), 0.14],
|
|
329
|
+
seam: [...lip(-4, 0.02, 0.26).slice(0, 3), 0.85],
|
|
330
|
+
commiss: [...lip(-6, 0.03, 0.30).slice(0, 3), 0.30],
|
|
331
|
+
philtrum: [...off(-17, 0.06, 0.46).slice(0, 3), 0.22],
|
|
332
|
+
mouthIn: hsl(350, 0.35, 0.25),
|
|
333
|
+
teeth: [250, 246, 242, 1],
|
|
334
|
+
toothSep: [...hsl(28, 0.14, 0.38).slice(0, 3), 0.55],
|
|
335
|
+
// The upper lip's cast shadow doubles as the gum line: it used to be a
|
|
336
|
+
// neutral dark multiply with a separate pink `gum` band under it, and the
|
|
337
|
+
// multiply ate the pink — 2% contrast, invisible at 1x and at 3x. One
|
|
338
|
+
// warmer, pinker shadow says both things in one draw.
|
|
339
|
+
toothSh: [...hsl(350, 0.52, 0.30).slice(0, 3), 0.40],
|
|
340
|
+
tongue: hsl(352, 0.42, 0.56),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The iris ladder. `hue/NNN` swaps to a solid at that hue; the driver always
|
|
345
|
+
// holds one (or two neighbouring) hue poses at full weight, and layers
|
|
346
|
+
// `iris/eyes-saturation-0` and `iris/eyes-brightness-0` on top of it, all
|
|
347
|
+
// blended in RGBA against the *base* iris paint. So the base is not a colour
|
|
348
|
+
// anybody picks: it is solved backwards from where the persona's `eye` should
|
|
349
|
+
// land at the persona's OWN dial, so `shown(h) = eye + ladder(h) - ladder(hue)`
|
|
350
|
+
// and a dark ladder is the only thing that keeps a dark eye dark at all 39
|
|
351
|
+
// rungs. This file used to carry the answer pasted in — `base: [72,144,192]`,
|
|
352
|
+
// solved by hand — with a comment saying `solveIrisBase` returns exactly that;
|
|
353
|
+
// it does, which is the whole reason it can be wired up now without moving a
|
|
354
|
+
// byte. The two overlay rungs are the ladder's own lightness, greyed flat and
|
|
355
|
+
// lifted — 100% and 158.8% of it, i.e. the [107,107,107] and [170,170,170]
|
|
356
|
+
// this file has always written at light 0.42.
|
|
357
|
+
function makeIris(p) {
|
|
358
|
+
const IRIS = {
|
|
359
|
+
hue: (h) => hsl(h, p.iris.sat, p.iris.light),
|
|
360
|
+
grey: hsl(0, 0, p.iris.light),
|
|
361
|
+
bright: hsl(0, 0, p.iris.light * 1.588),
|
|
362
|
+
target: [...p.iris.eye, 1],
|
|
363
|
+
};
|
|
364
|
+
IRIS.base = solveIrisBase(IRIS.target, IRIS, {
|
|
365
|
+
hue: p.iris.hue, saturation: p.iris.saturation, brightness: p.iris.brightness,
|
|
366
|
+
});
|
|
367
|
+
return IRIS;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A persona, filled in from the defaults one level down (each block is a small
|
|
371
|
+
// flat record, and a half-given `iris` block wants the rest of the driver's
|
|
372
|
+
// defaults, not `undefined`).
|
|
373
|
+
function fill(persona = {}) {
|
|
374
|
+
const d = DEFAULT_PERSONA;
|
|
375
|
+
return sexPaint({
|
|
376
|
+
sex: persona.sex || d.sex,
|
|
377
|
+
geo: persona.geo || undefined, // JSON.stringify drops it when unset
|
|
378
|
+
skin: persona.skin || d.skin,
|
|
379
|
+
lips: { ...d.lips, ...(persona.lips || {}) },
|
|
380
|
+
iris: { ...d.iris, ...(persona.iris || {}) },
|
|
381
|
+
brow: { ...d.brow, ...(persona.brow || {}) },
|
|
382
|
+
lash: { ...d.lash, ...(persona.lash || {}) },
|
|
383
|
+
// Omit the optional construction block when it was omitted on input, so
|
|
384
|
+
// personas that do not use it keep byte-identical `meta.live.persona`.
|
|
385
|
+
eye: persona.eye ? { aperture: 1, ...persona.eye } : undefined,
|
|
386
|
+
mouth: persona.mouth ? { ...persona.mouth } : undefined,
|
|
387
|
+
form: persona.form ? { ...persona.form } : undefined,
|
|
388
|
+
nose: persona.nose ? { ...persona.nose } : undefined,
|
|
389
|
+
skinDetail: persona.skinDetail ? { ...persona.skinDetail } : undefined,
|
|
390
|
+
blush: persona.blush ?? d.blush,
|
|
391
|
+
}, persona);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// SEX. `persona.sex` is 'f' (the default, and the look this family has always
|
|
396
|
+
// had) or 'm'. It is one key and it moves two things:
|
|
397
|
+
//
|
|
398
|
+
// GEOMETRY — `SEX_GEO` is a patch on the rest control vector, so a man is
|
|
399
|
+
// this same skull built at a different rest. `persona.geo` overrides it
|
|
400
|
+
// channel by channel for a persona that wants, say, a man's jaw at 0.6.
|
|
401
|
+
//
|
|
402
|
+
// PAINT — the marks that are conventions of femininity in a flat vector
|
|
403
|
+
// face, not anatomy: the lash mass, the rouge, and a lip that is a different
|
|
404
|
+
// HUE from the skin. Removing them is what stops a male rig reading as a
|
|
405
|
+
// woman with a wide jaw. A persona that states the key itself always wins:
|
|
406
|
+
// `sexPaint` only reaches for a mark the caller left unsaid.
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
export const SEX_GEO = {
|
|
410
|
+
f: {},
|
|
411
|
+
m: { jawWidth: 0.85, neckWidth: 1, eyeSize: -0.18, browH: -0.6, headW: 0.12 },
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export function geoOf(persona = {}) {
|
|
415
|
+
const sex = persona.sex || DEFAULT_PERSONA.sex;
|
|
416
|
+
const g = { ...(SEX_GEO[sex] || {}), ...(persona.geo || {}) };
|
|
417
|
+
// `plateW` is not a knob: it is `headW` frozen at the vector this rig is
|
|
418
|
+
// BUILT at, because the neck's base plate (§5) backs a garment hole that was
|
|
419
|
+
// cut against this rig's rest render. Derived here rather than written into
|
|
420
|
+
// SEX_GEO so a persona that overrides `headW` gets a plate that follows.
|
|
421
|
+
return { ...g, plateW: g.headW || 0 };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The rest vector this persona's rig is BUILT at. `poseHarness` diffs every
|
|
425
|
+
// pose against the builder run at this vector, so the sex geometry costs no
|
|
426
|
+
// pose and no runtime channel — it is simply where the face rests.
|
|
427
|
+
export function restFor(persona) { return ctrl(geoOf(persona)); }
|
|
428
|
+
|
|
429
|
+
function sexPaint(p, given = {}) {
|
|
430
|
+
if (p.sex !== 'm') return p;
|
|
431
|
+
// A man's lip is not a colour of its own: it is the SKIN PLANE, a few degrees
|
|
432
|
+
// toward red, a shade darker and a touch MORE saturated, with the seam under
|
|
433
|
+
// it doing the work of saying "mouth". The first version of this rule took
|
|
434
|
+
// the woman's lip and desaturated it toward the skin — hue = skin, sat capped
|
|
435
|
+
// at 0.15 — which is a different thing and a wrong one: 30% of the lightness
|
|
436
|
+
// gone and two thirds of the chroma with it lands on a NEUTRAL, and a neutral
|
|
437
|
+
// beside a warm skin does not read as a lip, it reads as grey lipstick. So
|
|
438
|
+
// the lip is derived from `skin` now and the female lip is not consulted at
|
|
439
|
+
// all: -12% and -7% of the skin's lightness for the upper and lower plane,
|
|
440
|
+
// +0.05 and +0.07 of its saturation, 6 and 8 degrees redder. Lightness as a
|
|
441
|
+
// RATIO is what keeps the two planes apart on a deep brown as well as on a
|
|
442
|
+
// peach, and it is why the lower lip lands lighter and rosier than the upper
|
|
443
|
+
// on every skin in the set rather than only on the pale ones.
|
|
444
|
+
const [sh, ss, sl] = p.skin;
|
|
445
|
+
if (!given.lips) p.lips = { up: [sh - 6, ss + 0.05, sl * 0.88], low: [sh - 8, ss + 0.07, sl * 0.93] };
|
|
446
|
+
if (!given.lash) p.lash = { ...p.lash, weight: 0 };
|
|
447
|
+
if (given.blush === undefined) p.blush = 0;
|
|
448
|
+
if (!given.brow) p.brow = { ...p.brow, weight: Math.max(p.brow.weight, 1.35) };
|
|
449
|
+
return p;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ===========================================================================
|
|
453
|
+
// 4. LANDMARKS
|
|
454
|
+
// CENTER points sit on the midline; SIDE points are mirrored into a left
|
|
455
|
+
// (viewer-left, lit) and a right (shadow) copy, suffixed L / R; FREE points
|
|
456
|
+
// carry a signed x and are NOT mirrored — they are what makes the fringe
|
|
457
|
+
// sweep to one side and the nose tick sit on the shadow side.
|
|
458
|
+
// ===========================================================================
|
|
459
|
+
|
|
460
|
+
const CENTER = {
|
|
461
|
+
crown: [0, P.headTop],
|
|
462
|
+
glab: [0, 520], // glabella, between the brows
|
|
463
|
+
ntip: [0, P.noseBaseY],
|
|
464
|
+
philt: [0, 732],
|
|
465
|
+
mth_c: [0, P.mouthY], // centre of the mouth opening
|
|
466
|
+
chin_t: [0, 840], // crease under the lower lip
|
|
467
|
+
chin: [0, P.chinY],
|
|
468
|
+
// hair
|
|
469
|
+
hb_t: [0, 232],
|
|
470
|
+
hb_b: [0, 1052],
|
|
471
|
+
// neck + throat
|
|
472
|
+
nk_t: [0, 818],
|
|
473
|
+
nk_b: [0, 1220],
|
|
474
|
+
nsh_t: [0, 800],
|
|
475
|
+
// the neck's base plate (§5, `platePts`) — a frozen copy of the neck's own
|
|
476
|
+
// four points, so the plate is the neck's REST outline and stays there.
|
|
477
|
+
pl_t: [0, 818],
|
|
478
|
+
pl_b: [0, 1220],
|
|
479
|
+
// shirt
|
|
480
|
+
colc: [0, 1146],
|
|
481
|
+
colc_t: [0, 1132],
|
|
482
|
+
colc_b: [0, 1182],
|
|
483
|
+
botc: [0, 1700],
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const SIDE = {
|
|
487
|
+
// silhouette, crown -> chin. Widest at the cheeks (chk), soft small chin.
|
|
488
|
+
crn: [162, 262],
|
|
489
|
+
tmpT: [260, 344],
|
|
490
|
+
tmp: [292, 468],
|
|
491
|
+
chk: [302, 600],
|
|
492
|
+
lchk: [276, 700],
|
|
493
|
+
jaw: [214, 800],
|
|
494
|
+
// chin 48% of the cheek width — the adult range is 45-50%; it was 37%
|
|
495
|
+
jawm: [145, 862],
|
|
496
|
+
// brows: soft arch, peak at the outer third. Flatter than the cute original
|
|
497
|
+
// (36px of arch became 22) and closer to the eye: an adult brow-eye gap.
|
|
498
|
+
bwI: [80, 530],
|
|
499
|
+
bwM: [166, 512],
|
|
500
|
+
bwO: [232, 534],
|
|
501
|
+
// nose
|
|
502
|
+
nwing: [60, 694],
|
|
503
|
+
// anchors that drive the smooth inserts, morphed with everything else
|
|
504
|
+
eyeC: [P.eyeGapHalf + P.eyeHalfW, P.eyeY],
|
|
505
|
+
mcor: [P.mouthHalfW, 758], // 10px above mth_c: the resting smile
|
|
506
|
+
// the cheek rouge, as its INNER and OUTER edge rather than a centre: an
|
|
507
|
+
// ellipse whose centre is morphed and whose radius is not is a rouge that
|
|
508
|
+
// keeps its size while the face under it changes size, and at
|
|
509
|
+
// `morph/head_-100` that put 1240 px of it on the page either side of the
|
|
510
|
+
// narrowed jaw. Two landmarks 128 apart go through every morph with the rest
|
|
511
|
+
// of the mesh, so the width is whatever the mesh did to that span — which is
|
|
512
|
+
// more than what it did to the midpoint, because the head-width ramp is
|
|
513
|
+
// steeper at 242 than at 114.
|
|
514
|
+
blushI: [114, 706],
|
|
515
|
+
blushO: [242, 706],
|
|
516
|
+
// ears, sitting in the brow -> nose-base band per the Loomis canon
|
|
517
|
+
earA: [272, 550],
|
|
518
|
+
earB: [328, 558],
|
|
519
|
+
earC: [348, 614],
|
|
520
|
+
earD: [332, 676],
|
|
521
|
+
earE: [288, 702],
|
|
522
|
+
earIA: [299, 588],
|
|
523
|
+
earIB: [322, 617],
|
|
524
|
+
earIC: [307, 654],
|
|
525
|
+
earID: [287, 619],
|
|
526
|
+
// the bob
|
|
527
|
+
hbA: [176, 248],
|
|
528
|
+
hbB: [292, 338],
|
|
529
|
+
hbC: [338, 470],
|
|
530
|
+
hbD: [342, 660],
|
|
531
|
+
hbE: [326, 862],
|
|
532
|
+
hbF: [302, 1002],
|
|
533
|
+
hbG: [176, 1044],
|
|
534
|
+
// neck
|
|
535
|
+
// neck 49% of the cheek width (was 41%) — an adult neck carries the skull
|
|
536
|
+
nk: [148, 826],
|
|
537
|
+
nkm: [166, 1020],
|
|
538
|
+
nkb: [182, 1220],
|
|
539
|
+
// …and the plate's copy of them. Same numbers, different morph exemptions.
|
|
540
|
+
plt: [148, 826],
|
|
541
|
+
plm: [166, 1020],
|
|
542
|
+
plb: [182, 1220],
|
|
543
|
+
// The chin's cast shadow falls ON the neck, so it has to fit INSIDE it. These
|
|
544
|
+
// two used to be 172 / 170 against a neck edge that renders at 148 / 165, and
|
|
545
|
+
// the overhang was invisible only because the vector `shirt` was painted over
|
|
546
|
+
// it. A bitmap garment hides `shirt`, and the 15%-alpha plane then landed on
|
|
547
|
+
// the page itself as two pale wedges either side of the neck base — worst on
|
|
548
|
+
// the men, where `neckWidth` widens the shadow and the neck by the same 22%
|
|
549
|
+
// but the SPILL by 22% as well. The curve bulges ~10 past its own points, so
|
|
550
|
+
// these two render at 150 at the widest against a neck edge of 165 — inside
|
|
551
|
+
// it by 9% of the neck all the way down, with room for the head to turn.
|
|
552
|
+
nshA: [131, 812],
|
|
553
|
+
nshB: [137, 892],
|
|
554
|
+
// shirt
|
|
555
|
+
col: [186, 1044],
|
|
556
|
+
sho: [452, 1076],
|
|
557
|
+
out: [528, 1310],
|
|
558
|
+
bot: [548, 1700],
|
|
559
|
+
colT: [192, 1036],
|
|
560
|
+
colB: [196, 1062],
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// Points with a signed x that must NOT be mirrored: the swept fringe, the
|
|
564
|
+
// highlight riding on it, the one-stroke nose and the boundary of the side
|
|
565
|
+
// shading plane. Symmetry is the default in a face; the small asymmetries are
|
|
566
|
+
// most of what stops a generated character looking generated.
|
|
567
|
+
const FREE = {
|
|
568
|
+
// fringe: outer edge over the crown, then the hairline right -> left with
|
|
569
|
+
// the parting left of centre
|
|
570
|
+
fr1: [-312, 480], fr2: [-324, 350], fr3: [-210, 250], fr4: [0, 226],
|
|
571
|
+
fr5: [215, 252], fr6: [324, 356], fr7: [316, 478],
|
|
572
|
+
fr8: [244, 496], fr9: [100, 454], fr10: [-46, 424], fr11: [-192, 462],
|
|
573
|
+
// highlight band on the lit side of the fringe
|
|
574
|
+
hh1: [-256, 330], hh2: [-150, 268], hh3: [-40, 246],
|
|
575
|
+
hh4: [-48, 272], hh5: [-156, 292], hh6: [-248, 352],
|
|
576
|
+
// nose: one L-shaped tick, drawn as a tapered closed stroke
|
|
577
|
+
no1: [24, 654], no2: [36, 686], no3: [56, 704], no4: [84, 710],
|
|
578
|
+
no5: [80, 724], no6: [42, 716], no7: [14, 692], no8: [8, 656],
|
|
579
|
+
// bottom of the chin's cast shadow — pushed a little to the shadow side
|
|
580
|
+
nsh_b: [12, 946],
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// How strongly each point follows the jaw when the mouth opens.
|
|
584
|
+
const JAWW = {
|
|
585
|
+
chin: 1, chin_t: 0.88, jawm: 0.76, jaw: 0.30, lchk: 0.10,
|
|
586
|
+
mcor: 0.40, mth_c: 0.42, philt: 0.16, ntip: 0.04, nwing: 0.05,
|
|
587
|
+
blushI: 0.08, blushO: 0.08,
|
|
588
|
+
no1: 0.02, no2: 0.03, no3: 0.04, no4: 0.04, no5: 0.04, no6: 0.04, no7: 0.03, no8: 0.02,
|
|
589
|
+
};
|
|
590
|
+
// Cheek raise (a smile pushes the cheek mass up under the eye).
|
|
591
|
+
const CHEEKW = { blushI: 1, blushO: 1, lchk: 0.5, chk: 0.28, mcor: 0.45, sh5: 0.5, sh6: 0.35 };
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
// Landmarks the BODY is made of — the shirt, and the neck's base plate. They
|
|
595
|
+
// are exempt from every head morph: nothing in the rig.json format nests a body
|
|
596
|
+
// under a head, so "only the head" has to be spelled out as a list of point
|
|
597
|
+
// names. The plate is in the list for a second reason as well: the garment's
|
|
598
|
+
// neck hole is cut ONCE, against this rig's own rest render, and a head morph
|
|
599
|
+
// does not move it — so the thing that backs the hole must not move either.
|
|
600
|
+
const SHIRT_PTS = new Set(
|
|
601
|
+
['col', 'sho', 'out', 'bot', 'colT', 'colB', 'plt', 'plm', 'plb']
|
|
602
|
+
.flatMap((n) => [n, n + 'L', n + 'R'])
|
|
603
|
+
.concat(['colc', 'colc_t', 'colc_b', 'botc', 'pl_t', 'pl_b']));
|
|
604
|
+
// The plate's own eight, listed once: exempt from the head morph like the rest
|
|
605
|
+
// of the shirt, but widened by `plateW` (§4, `widen`) so it still matches the
|
|
606
|
+
// neck this rig was built with.
|
|
607
|
+
const PLATE_PTS = ['pl_t', 'pl_b', 'pltL', 'pltR', 'plmL', 'plmR', 'plbL', 'plbR'];
|
|
608
|
+
|
|
609
|
+
// Sex-axis geometry. `jawWidth` and `neckWidth` are named point sets rather
|
|
610
|
+
// than a y-window scale like headW: a band low enough to catch the jaw also
|
|
611
|
+
// catches the mouth corners, the blush and the nose tick, and a wider jaw must
|
|
612
|
+
// not widen the mouth. The weight is how much of the full push each point takes.
|
|
613
|
+
const JAWWIDE = { lchk: 0.20, jaw: 0.62, jawm: 1 };
|
|
614
|
+
// The neck carries its own cast shadow (nshA/nshB) so the shadow never spills
|
|
615
|
+
// past the silhouette it is cast on.
|
|
616
|
+
const NECKWIDE = { nk: 1, nkm: 1, nkb: 1, nshA: 1, nshB: 1, plt: 1, plm: 1, plb: 1 };
|
|
617
|
+
// The collar is the one part of the shirt that a wider neck must move: it is
|
|
618
|
+
// what the neck comes out of. `sho`/`out`/`bot`/`colc*` stay put, so the
|
|
619
|
+
// shoulders and the shirt body keep the size the wardrobe bitmaps were cut for.
|
|
620
|
+
const COLLARWIDE = { col: 1, colT: 1, colB: 1 };
|
|
621
|
+
|
|
622
|
+
export function landmarks(c) {
|
|
623
|
+
const cx = P.cx, pts = {};
|
|
624
|
+
for (const [n, [x, y]] of Object.entries(CENTER)) pts[n] = [cx + x, y];
|
|
625
|
+
for (const [n, [dx, y]] of Object.entries(SIDE)) {
|
|
626
|
+
pts[n + 'L'] = [cx - dx, y];
|
|
627
|
+
pts[n + 'R'] = [cx + dx, y];
|
|
628
|
+
}
|
|
629
|
+
for (const [n, [x, y]] of Object.entries(FREE)) pts[n] = [cx + x, y];
|
|
630
|
+
|
|
631
|
+
// --- identity morphs ----------------------------------------------------
|
|
632
|
+
// eye spacing: slide the eye anchor and everything hung off it
|
|
633
|
+
for (const s of [-1, 1]) {
|
|
634
|
+
const k = s < 0 ? 'L' : 'R';
|
|
635
|
+
pts['eyeC' + k][0] += s * 22 * c.eyeSpace;
|
|
636
|
+
for (const n of ['bwI', 'bwM', 'bwO']) pts[n + k][0] += s * 13 * c.eyeSpace;
|
|
637
|
+
}
|
|
638
|
+
// nose width: the wing landmarks and the tick's own eight points
|
|
639
|
+
for (const s of [-1, 1]) {
|
|
640
|
+
const k = s < 0 ? 'L' : 'R';
|
|
641
|
+
pts['nwing' + k][0] = cx + (pts['nwing' + k][0] - cx) * (1 + 0.34 * c.noseW);
|
|
642
|
+
}
|
|
643
|
+
for (const n of ['no1', 'no2', 'no3', 'no4', 'no5', 'no6', 'no7', 'no8']) {
|
|
644
|
+
pts[n][0] = cx + (pts[n][0] - cx) * (1 + 0.30 * c.noseW);
|
|
645
|
+
}
|
|
646
|
+
// brow height: brows carry a little of the forehead mesh with them
|
|
647
|
+
// The three brow channels are per SIDE now (`eyeSide`), and they arrive in
|
|
648
|
+
// the driver's -1..1 rather than in px: `BROW_PX` is the part's own statement
|
|
649
|
+
// of what one unit of each is worth, so the px live next to the geometry they
|
|
650
|
+
// were tuned against and this loop only says WHERE they land.
|
|
651
|
+
for (const s of [-1, 1]) {
|
|
652
|
+
const k = s < 0 ? 'L' : 'R';
|
|
653
|
+
const e = eyeSide(c, s);
|
|
654
|
+
for (const [n, g] of [['bwI', 1], ['bwM', 1], ['bwO', 0.85]]) {
|
|
655
|
+
pts[n + k][1] -= (20 * c.browH + e.browRaise * BROW_PX.raise
|
|
656
|
+
+ e.browInner * BROW_PX.inner * (n === 'bwI' ? 1 : n === 'bwM' ? 0.4 : 0)
|
|
657
|
+
+ e.browAngle * BROW_PX.angle * (n === 'bwO' ? 1 : n === 'bwM' ? 0.4 : 0)) * g * 0.55;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// --- head width: a global x-scale that fades in away from the midline ----
|
|
662
|
+
// The shirt is deliberately exempt: the head morph must not resize the body,
|
|
663
|
+
// and there is no transform hierarchy in the format to give us that for free.
|
|
664
|
+
// Unlike facet, which widens hardest at the jaw, a round face widens hardest
|
|
665
|
+
// across the cheeks.
|
|
666
|
+
const widen = (names, w) => {
|
|
667
|
+
if (!w) return;
|
|
668
|
+
for (const n of names) {
|
|
669
|
+
const dx = pts[n][0] - cx, a = Math.abs(dx);
|
|
670
|
+
const t = clamp((a - 30) / 240, 0, 1);
|
|
671
|
+
const ramp = 0.14 + 0.86 * (t * t * (3 - 2 * t));
|
|
672
|
+
let g = 0.20 * w * ramp;
|
|
673
|
+
const y = pts[n][1];
|
|
674
|
+
if (y > 540 && y < 760) g += 0.07 * w * ramp; // the cheeks widen most
|
|
675
|
+
pts[n][0] = cx + dx * (1 + g);
|
|
676
|
+
}
|
|
677
|
+
// a wider head is also a slightly shorter one
|
|
678
|
+
for (const n of names) {
|
|
679
|
+
if (pts[n][1] < 920) pts[n][1] = P.eyeY + (pts[n][1] - P.eyeY) * (1 - 0.045 * w);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
widen(Object.keys(pts).filter((n) => !SHIRT_PTS.has(n)), c.headW);
|
|
683
|
+
// The plate is the one SHIRT_PT that takes this transform anyway — off its
|
|
684
|
+
// own channel. `plateW` is the head width the rig was BUILT at (§3, geoOf)
|
|
685
|
+
// and no pose ever moves it, so the plate lands exactly on the neck's REST
|
|
686
|
+
// outline on a man as well as on a woman, while `morph/head_±100` still
|
|
687
|
+
// slides the neck across a plate that stays where the garment's hole is.
|
|
688
|
+
// Without this the men's plate sat 2.4 px inside their own neck and left a
|
|
689
|
+
// hairline of page open along the neckline: 1205 px at 3x on round-m2.
|
|
690
|
+
widen(PLATE_PTS, c.plateW);
|
|
691
|
+
|
|
692
|
+
// --- jaw width: the silhouette from the cheek down, and the gonial angle --
|
|
693
|
+
// A man's mandible is wider at the corner and squarer where it turns; a
|
|
694
|
+
// woman's tapers sooner. This is the strongest single cue at 1x.
|
|
695
|
+
if (c.jawWidth) {
|
|
696
|
+
for (const [n, w] of Object.entries(JAWWIDE)) {
|
|
697
|
+
for (const k of ['L', 'R']) {
|
|
698
|
+
const q = pts[n + k];
|
|
699
|
+
q[0] = cx + (q[0] - cx) * (1 + 0.32 * c.jawWidth * w);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// squarer corner: the gonial point drops as it widens
|
|
703
|
+
for (const k of ['L', 'R']) pts['jaw' + k][1] += 12 * c.jawWidth;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// --- neck width: the neck, its cast shadow, and the collar it wears -------
|
|
707
|
+
if (c.neckWidth) {
|
|
708
|
+
for (const [n, w] of Object.entries(NECKWIDE)) {
|
|
709
|
+
for (const k of ['L', 'R']) {
|
|
710
|
+
const q = pts[n + k];
|
|
711
|
+
q[0] = cx + (q[0] - cx) * (1 + 0.22 * c.neckWidth * w);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const n of Object.keys(COLLARWIDE)) {
|
|
715
|
+
for (const k of ['L', 'R']) {
|
|
716
|
+
const q = pts[n + k];
|
|
717
|
+
q[0] = cx + (q[0] - cx) * (1 + 0.16 * c.neckWidth);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// --- eye-derived points (the lid plate bounds, not the eye itself) -------
|
|
723
|
+
const hw = P.eyeHalfW * (1 + 0.20 * c.eyeSize);
|
|
724
|
+
const bh = P.eyeBotH * (1 + 0.24 * c.eyeSize);
|
|
725
|
+
for (const s of [-1, 1]) {
|
|
726
|
+
const k = s < 0 ? 'L' : 'R';
|
|
727
|
+
const e = pts['eyeC' + k];
|
|
728
|
+
pts['eyI' + k] = [e[0] - s * (hw + 20), e[1] + 5];
|
|
729
|
+
pts['eyO' + k] = [e[0] + s * (hw + 26), e[1] - 11];
|
|
730
|
+
pts['eyB' + k] = [e[0] + s * 4, e[1] + bh + 34];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// --- expression / animation deformation ---------------------------------
|
|
734
|
+
if (c.jaw) {
|
|
735
|
+
const d = c.jaw * P.jawDrop;
|
|
736
|
+
applyWeights(pts, JAWW, (p, w) => {
|
|
737
|
+
p[1] += d * w;
|
|
738
|
+
if (w > 0.6) p[0] = cx + (p[0] - cx) * (1 - 0.030 * c.jaw);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
if (c.cheekRaise) {
|
|
742
|
+
applyWeights(pts, CHEEKW, (p, w) => { p[1] -= c.cheekRaise * 13 * w; });
|
|
743
|
+
}
|
|
744
|
+
if (P.scale !== 1) {
|
|
745
|
+
const K = P.scale, oy = P.headTop;
|
|
746
|
+
for (const n of Object.keys(pts)) {
|
|
747
|
+
pts[n][0] = cx + (pts[n][0] - cx) * K;
|
|
748
|
+
pts[n][1] = oy + (pts[n][1] - oy) * K;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return pts;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ===========================================================================
|
|
755
|
+
// 5. SHAPES
|
|
756
|
+
// [slot, group, [landmark names, in order round a closed loop], tone,
|
|
757
|
+
// tension]. Where facet had a table of triangles over its landmarks, this
|
|
758
|
+
// has a table of closed splines over them. Nothing is mirrored
|
|
759
|
+
// automatically: a loop that crosses the midline has to name both halves.
|
|
760
|
+
// ===========================================================================
|
|
761
|
+
|
|
762
|
+
// Three groups, and the third one is new. `head` turns with the head matrix,
|
|
763
|
+
// `body` does not; `hand` is neither, because the hand is not on the character
|
|
764
|
+
// at all — it is the nearest object in the FRAME, placed by the camera window's
|
|
765
|
+
// own numbers, and a head that turns must not take it along.
|
|
766
|
+
export const HEAD = 'head', BODY = 'body', HAND = 'hand';
|
|
767
|
+
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// `band`, `bulge`, `contours` and `ring` — the shape idioms the fidelity pass
|
|
770
|
+
// needed — are in `author/path.mjs`; two of the three generators had each of
|
|
771
|
+
// them. `onRun`, the polyline sampler the lower lashes hang off, went the other
|
|
772
|
+
// way: it had one call site, the call site moved into `author/parts/eye.mjs`,
|
|
773
|
+
// and so did it.
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
const FACE_LOOP = [
|
|
777
|
+
'crown', 'crnL', 'tmpTL', 'tmpL', 'chkL', 'lchkL', 'jawL', 'jawmL',
|
|
778
|
+
'chin', 'jawmR', 'jawR', 'lchkR', 'chkR', 'tmpR', 'tmpTR', 'crnR',
|
|
779
|
+
];
|
|
780
|
+
|
|
781
|
+
export const SHAPES = [
|
|
782
|
+
// the bob, behind everything
|
|
783
|
+
['hairBack', HEAD, ['hb_t', 'hbAL', 'hbBL', 'hbCL', 'hbDL', 'hbEL', 'hbFL', 'hbGL',
|
|
784
|
+
'hb_b', 'hbGR', 'hbFR', 'hbER', 'hbDR', 'hbCR', 'hbBR', 'hbAR'], 'hairBack', 1],
|
|
785
|
+
// ears, in front of the hair and behind the face
|
|
786
|
+
['earL', HEAD, ['earAL', 'earBL', 'earCL', 'earDL', 'earEL'], 'ear', 1],
|
|
787
|
+
['earInL', HEAD, ['earIAL', 'earIBL', 'earICL', 'earIDL'], 'earIn', 1],
|
|
788
|
+
['earR', HEAD, ['earAR', 'earBR', 'earCR', 'earDR', 'earER'], 'earR', 1],
|
|
789
|
+
['earInR', HEAD, ['earIAR', 'earIBR', 'earICR', 'earIDR'], 'earInR', 1],
|
|
790
|
+
// neck, and the shadow the chin casts on it
|
|
791
|
+
['neck', HEAD, ['nk_t', 'nkR', 'nkmR', 'nkbR', 'nk_b', 'nkbL', 'nkmL', 'nkL'], 'neck', 1],
|
|
792
|
+
['neckSh', HEAD, ['nshAL', 'nsh_t', 'nshAR', 'nshBR', 'nsh_b', 'nshBL'], 'neckSh', 1],
|
|
793
|
+
// shirt (BODY: exempt from the head matrix and every head morph)
|
|
794
|
+
['shirt', BODY, ['colL', 'shoL', 'outL', 'botL', 'botc', 'botR', 'outR', 'shoR', 'colR', 'colc'], 'shirt', 1],
|
|
795
|
+
['collar', BODY, ['colTL', 'colc_t', 'colTR', 'colBR', 'colc_b', 'colBL'], 'collar', 1],
|
|
796
|
+
// the face itself
|
|
797
|
+
['face', HEAD, FACE_LOOP, 'face', 1],
|
|
798
|
+
];
|
|
799
|
+
|
|
800
|
+
// The slots a wardrobe layer follows when it is a GARMENT rather than hair or
|
|
801
|
+
// glasses. The build reads it to find where the neck's base plate has to stop
|
|
802
|
+
// (§5, `plateTop`); it is stated here because this table is where the names
|
|
803
|
+
// are, and a renamed shirt should break the build rather than the picture.
|
|
804
|
+
export const GARMENT_SLOTS = ['shirt', 'collar'];
|
|
805
|
+
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
// THE NECK'S BASE PLATE — one static shape, drawn behind everything.
|
|
808
|
+
//
|
|
809
|
+
// The neck is a HEAD draw and the garment is a BODY one, so under `headRoll`,
|
|
810
|
+
// `headYaw` and `headPitch` the neck swings inside a garment neckline that does
|
|
811
|
+
// not move: 9 deg about `neckPivot` is 21 px of lateral travel at the collar and
|
|
812
|
+
// a yaw is 40 px flat. The wardrobe matte is cut at the neck's REST outline
|
|
813
|
+
// (`wardrobe/extract.py`, `roi &= ~neck`) so the two silhouettes have no overlap
|
|
814
|
+
// at all, and the page opened up beside the upper neck — ~1345 px at 3x on
|
|
815
|
+
// round-m2. Blending the garment toward the head matrix would swing the
|
|
816
|
+
// shoulders with it; a second, collar-height bitmap layer on the head matrix
|
|
817
|
+
// only moves the seam down to the collar's own join and does nothing for the
|
|
818
|
+
// two bald rigs, which wear a vector shirt.
|
|
819
|
+
//
|
|
820
|
+
// What actually closes it is that the hole is FIXED and the wedge is therefore
|
|
821
|
+
// always a subset of the neck's rest footprint: so back the hole with skin.
|
|
822
|
+
// `neckPlate` is that backing — the neck's own rest outline, inset 1 px so it
|
|
823
|
+
// is strictly inside the neck's opaque interior (hidden at rest, on every rig,
|
|
824
|
+
// at every scale), painted in the BODY group so it stays put while the head
|
|
825
|
+
// moves. It is pushed FIRST, behind the bob, because where a rig already has
|
|
826
|
+
// hair behind the neck the hair is what should show through, not skin.
|
|
827
|
+
//
|
|
828
|
+
// It has a hard top edge, and where that edge sits is the whole of the rest of
|
|
829
|
+
// the design. THE PLATE STOPS WHERE ITS COVER STARTS. Above the garment's own
|
|
830
|
+
// neckline nothing ever covers the plate, so a plate carried higher would show
|
|
831
|
+
// beside a neck that had rolled out from under it — a second, static neck edge
|
|
832
|
+
// standing in the page, which is a worse picture than the wedge it was cutting.
|
|
833
|
+
// `plateTop` is that line, in design y, written by the build (build.mjs, §10)
|
|
834
|
+
// as the top edge of the topmost bitmap layer that follows a body slot; a rig
|
|
835
|
+
// with no such layer wears the vector shirt and falls back to `colT`, the
|
|
836
|
+
// collar's own top landmark. Both are ABOVE the neckline they stand for — a
|
|
837
|
+
// crop starts at the garment's shoulders, not at its collar — so the wedge is
|
|
838
|
+
// always closed, and what is left over is the few rows between the two. The
|
|
839
|
+
// `lift` is slack for a stroke's half-width and for the flat cut's own
|
|
840
|
+
// sampling error; it costs those rows and nothing else. A sidecar that has
|
|
841
|
+
// measured its own neckline may state it and be believed instead — which is
|
|
842
|
+
// what the four open-jacket personas do, their crops starting 70 rows above
|
|
843
|
+
// the line they draw beside the neck.
|
|
844
|
+
//
|
|
845
|
+
// The cut also puts the plate below the `wardrobe/hair-back` bitmaps that sit
|
|
846
|
+
// behind the neck's edges from design y 865 to 904 on this face. Skin painted
|
|
847
|
+
// over hair is the one thing worse than page, and now the plate cannot reach
|
|
848
|
+
// that far up on any rig.
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
|
|
851
|
+
const PLATE = { inset: 1, lift: 4, per: 32 };
|
|
852
|
+
const PLATE_LOOP = ['pl_t', 'pltR', 'plmR', 'plbR', 'pl_b', 'plbL', 'plmL', 'pltL'];
|
|
853
|
+
|
|
854
|
+
function platePts(L, c) {
|
|
855
|
+
const cx = P.cx, { inset, lift, per } = PLATE;
|
|
856
|
+
const loop = PLATE_LOOP.map((n) => L[n]);
|
|
857
|
+
// `plateTop` is 0 on a rig the build said nothing about, and a rig the build
|
|
858
|
+
// says nothing about is one that wears the vector shirt: the collar's own top
|
|
859
|
+
// landmark is where its cover starts.
|
|
860
|
+
const top = (c.plateTop || L.colTL[1]) - lift;
|
|
861
|
+
const bot = L.pl_b[1] - 2 * inset;
|
|
862
|
+
// Sampled off the plate loop's own curve rather than re-splined through its
|
|
863
|
+
// four points, so the plate follows the neck's contour instead of a second
|
|
864
|
+
// curve that only meets it at the landmarks. Clamped, never filtered: the
|
|
865
|
+
// point count of a draw has to be the same in every pose — which is why the
|
|
866
|
+
// run is sampled deep. Every sample above the cut collapses onto it keeping
|
|
867
|
+
// its own x, so the flat top edge is only as wide as the last sample above
|
|
868
|
+
// it; at 32 the neck's flare costs less than a pixel there.
|
|
869
|
+
const side = (from, to) => sampleRun(loop, 1, from, to, per).map(([x, y]) => {
|
|
870
|
+
const yy = clamp(y, top, bot);
|
|
871
|
+
const half = Math.max(0, Math.abs(x - cx) - inset);
|
|
872
|
+
return [cx + Math.sign(x - cx) * half, yy];
|
|
873
|
+
});
|
|
874
|
+
return [...side(1, 3), ...side(5, 7)];
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Drawn after the face: the fringe covers the top of the head, so its outer
|
|
878
|
+
// edge deliberately overshoots the silhouette onto the hair behind.
|
|
879
|
+
export const SHAPES_TOP = [
|
|
880
|
+
['fringe', HEAD, ['fr1', 'fr2', 'fr3', 'fr4', 'fr5', 'fr6', 'fr7', 'fr8', 'fr9', 'fr10', 'fr11'], 'hairFront', 1],
|
|
881
|
+
['hairHi', HEAD, ['hh1', 'hh2', 'hh3', 'hh4', 'hh5', 'hh6'], 'hairHi', 1],
|
|
882
|
+
];
|
|
883
|
+
|
|
884
|
+
// The shading plane is a band that hugs the shadow-side contour: FACE_LOOP
|
|
885
|
+
// indices 8 ('chin') up to 14 ('tmpTR', which sits under the fringe). Both of
|
|
886
|
+
// its edges are offsets of the same sampled curve, so it is parallel to the
|
|
887
|
+
// silhouette by construction and there is no second set of landmarks to keep
|
|
888
|
+
// in sync with the first.
|
|
889
|
+
const SHADOW_RUN = [8, 14];
|
|
890
|
+
const SHADOW_W = 58; // widest point of the band, at the cheek
|
|
891
|
+
|
|
892
|
+
// ===========================================================================
|
|
893
|
+
// 6. THE KIT — one persona, resolved
|
|
894
|
+
// A palette, an iris ladder and a paint registry of its own. Everything
|
|
895
|
+
// below takes it as an argument, because a second persona is a second
|
|
896
|
+
// display list built by the same builder and it must not share a paint
|
|
897
|
+
// table with the first one.
|
|
898
|
+
// ===========================================================================
|
|
899
|
+
|
|
900
|
+
export function makeKit(persona) {
|
|
901
|
+
const p = fill(persona);
|
|
902
|
+
const PALETTE = makePalette(p), IRIS = makeIris(p), reg = paintRegistry();
|
|
903
|
+
// Eye WIDTH is already an adult 20.7% of the cheek and remains identity
|
|
904
|
+
// geometry. `aperture` only scales the two vertical radii, so a persona can
|
|
905
|
+
// lose the startled/cute opening without becoming narrow-eyed or moving the
|
|
906
|
+
// iris, canthi, brow, wardrobe fit, or head silhouette.
|
|
907
|
+
const EYE_P = {
|
|
908
|
+
...P,
|
|
909
|
+
eyeTopH: P.eyeTopH * (p.eye?.aperture ?? 1),
|
|
910
|
+
eyeBotH: P.eyeBotH * (p.eye?.aperture ?? 1),
|
|
911
|
+
irisR: P.irisR * (p.eye?.irisScale ?? 1),
|
|
912
|
+
pupilR: P.pupilR * (p.eye?.pupilScale ?? 1),
|
|
913
|
+
};
|
|
914
|
+
// The two parts are constructed HERE, not in the draw builder: everything
|
|
915
|
+
// they take is a constant of this persona — the proportions, the resolved
|
|
916
|
+
// palette, this persona's own paint registry, the solved base iris, the lash
|
|
917
|
+
// and brow weights. Construction once, `draws(c, …)` once per control vector.
|
|
918
|
+
return {
|
|
919
|
+
p, PALETTE, IRIS, ...reg,
|
|
920
|
+
mouth: makeMouth({ P, PALETTE, solid: reg.solid, group: HEAD, marks: p.mouth }),
|
|
921
|
+
eye: makeEye({
|
|
922
|
+
P: EYE_P, PALETTE, solid: reg.solid, group: HEAD,
|
|
923
|
+
irisBase: IRIS.base, lashWeight: p.lash.weight, browWeight: p.brow.weight,
|
|
924
|
+
shape: p.eye?.shape,
|
|
925
|
+
marks: p.eye?.marks,
|
|
926
|
+
catch: p.eye?.finish?.catch,
|
|
927
|
+
refine: p.eye?.refine,
|
|
928
|
+
}),
|
|
929
|
+
nose: makeNose({ P, PALETTE, solid: reg.solid, group: HEAD, shape: p.nose }),
|
|
930
|
+
skinDetail: makeSkinDetail({
|
|
931
|
+
PALETTE, solid: reg.solid, group: HEAD, profile: p.skinDetail,
|
|
932
|
+
}),
|
|
933
|
+
// The third part takes no persona of its own beyond the skin rungs: a
|
|
934
|
+
// character's hand is the same character's hand, so `PALETTE.face` /
|
|
935
|
+
// `shade` / `crease` are the whole of it, which is also why the paint
|
|
936
|
+
// table does not grow by a single entry.
|
|
937
|
+
hand: makeHand({ P, PALETTE, solid: reg.solid, frame: HAND_FRAME, group: HAND }),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ===========================================================================
|
|
942
|
+
// 7. THE CONTROL VECTOR
|
|
943
|
+
// ===========================================================================
|
|
944
|
+
|
|
945
|
+
// The channels the driver's vocabulary needs somebody to implement, at rest —
|
|
946
|
+
// six identity morphs, a jaw, a cheek, an eye block and a mouth block. The
|
|
947
|
+
// table lives in author/rig.mjs (REST_CONTROLS) because all three styles hold
|
|
948
|
+
// it byte-for-byte; a style that grew a channel of its own spreads it here,
|
|
949
|
+
// which is exactly what this one now does — twice, because both of its parts
|
|
950
|
+
// have moved into channel space.
|
|
951
|
+
//
|
|
952
|
+
// The mouth block is overridden with `author/parts/mouth.mjs`'s: voqalize's ten
|
|
953
|
+
// mouth CHANNELS (`open` 0..1, `width` 0..1, `round`, `press`, `tuck`, per-side
|
|
954
|
+
// corners) in place of the nine pre-channel keys REST_CONTROLS still carries.
|
|
955
|
+
// The eye block likewise carries `author/parts/eye.mjs`'s: `eye:` in channel
|
|
956
|
+
// space (lid / squint / pupilX / pupilY / browRaise / browAngle / browInner),
|
|
957
|
+
// plus the two empty per-side override blocks `eyeL:` and `eyeR:` that let a
|
|
958
|
+
// patch say `{ eye: { lid: 0.12 }, eyeR: { lid: 1 } }` and get a wink.
|
|
959
|
+
//
|
|
960
|
+
// `facet` and `ink` still hold both features inline and read the px-and-ratio
|
|
961
|
+
// blocks REST_CONTROLS ships, so each is an override rather than a change to
|
|
962
|
+
// the shared table — until they are parts too, when the overrides go and
|
|
963
|
+
// `REST_CONTROLS` changes once.
|
|
964
|
+
//
|
|
965
|
+
// `hand:` is the third block and the odd one out: it is not a channel vector
|
|
966
|
+
// at all, it is voqalize's `HandFrame` — a gesture NAME, a progress and a
|
|
967
|
+
// side. It rides the control vector because the control vector is what
|
|
968
|
+
// `buildDraws` is given and because a hand is part of what this character is
|
|
969
|
+
// doing this frame; it stays OUT of `REST_CONTROLS` because facet and ink have
|
|
970
|
+
// no hand and a shared table that carries one would be lying about them.
|
|
971
|
+
export const ctrl = makeCtrl({
|
|
972
|
+
...REST_CONTROLS, plateW: 0, plateTop: 0, mouth: mouthRestChannels(), ...eyeChannelRest(), ...handRest(),
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// The same factory, rebased on a persona's rest (§3, restFor). `finishRig`
|
|
976
|
+
// builds every pose through `spec.ctrl`, so a male rig must hand it THIS one:
|
|
977
|
+
// otherwise each pose would silently reset jawWidth/neckWidth to the family
|
|
978
|
+
// neutral and every pose would carry the whole face as a diff.
|
|
979
|
+
export function ctrlFor(persona) { return makeCtrl(restFor(persona)); }
|
|
980
|
+
|
|
981
|
+
// ===========================================================================
|
|
982
|
+
// 8. DRAW BUILDER
|
|
983
|
+
// Runs the whole character for one control vector. Called once for the
|
|
984
|
+
// rest pose (which becomes the base display list) and once per pose; the
|
|
985
|
+
// poses are then a straight diff, so topology can never drift.
|
|
986
|
+
// `K` is the kit above: the persona's palette, its iris ladder and its
|
|
987
|
+
// paint registry. It is a second argument rather than a module constant
|
|
988
|
+
// because one process can build several of these characters.
|
|
989
|
+
// ===========================================================================
|
|
990
|
+
|
|
991
|
+
export function buildDraws(c, K) {
|
|
992
|
+
const { PALETTE, solid } = K;
|
|
993
|
+
const L = landmarks(c);
|
|
994
|
+
const out = [];
|
|
995
|
+
// `rule` and `blend` are draw-level fields `toRig` passes through verbatim
|
|
996
|
+
// (author/README.md). 'evenodd' is the only way to say "this contour is a
|
|
997
|
+
// hole", which the limbal ring needs; 'multiply' is what makes the teeth's
|
|
998
|
+
// upper-lip shadow and the mouth-corner pockets darken what is under them
|
|
999
|
+
// rather than paint over it.
|
|
1000
|
+
const push = drawPusher(out);
|
|
1001
|
+
|
|
1002
|
+
// Behind everything, including the bob: the neck's base plate (§5).
|
|
1003
|
+
push('neckPlate', BODY, polygon(platePts(L, c)), solid(PALETTE.neck));
|
|
1004
|
+
|
|
1005
|
+
const emit = (table) => {
|
|
1006
|
+
for (const [slot, group, names, tone, tension] of table) {
|
|
1007
|
+
const pts = names.map((n) => {
|
|
1008
|
+
if (!L[n]) throw new Error(`shape ${slot}: no landmark "${n}"`);
|
|
1009
|
+
return L[n];
|
|
1010
|
+
});
|
|
1011
|
+
push(slot, group, spline(pts, tension), solid(PALETTE[tone]));
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
emit(SHAPES);
|
|
1016
|
+
|
|
1017
|
+
// ---- cheeks --------------------------------------------------------------
|
|
1018
|
+
// Seven points and 0.26 alpha read as a flat lozenge once the camera moved
|
|
1019
|
+
// in; eleven points and 0.20 read as a soft ellipse. Same one draw.
|
|
1020
|
+
//
|
|
1021
|
+
// The WIDTH comes off the two cheek landmarks, so the rouge is warped by the
|
|
1022
|
+
// same field as the face it sits on (SIDE.blushI/blushO). The HEIGHT does
|
|
1023
|
+
// not: the only thing a head morph does vertically is make a wider head 4.5%
|
|
1024
|
+
// shorter, and 1.7 px on this blob is not worth a second pair of landmarks —
|
|
1025
|
+
// so it keeps the 37.12 px it was authored at, stated as the radius and the
|
|
1026
|
+
// squash it used to be written as. At rest the pair is 128 apart and this is
|
|
1027
|
+
// the same eleven points it always was, to the bit.
|
|
1028
|
+
const RY = 64 * 0.58;
|
|
1029
|
+
for (const side of [-1, 1]) {
|
|
1030
|
+
const k = side < 0 ? 'L' : 'R';
|
|
1031
|
+
const bi = L['blushI' + k], bo = L['blushO' + k];
|
|
1032
|
+
const bx = (bi[0] + bo[0]) / 2, by = (bi[1] + bo[1]) / 2;
|
|
1033
|
+
const rx = Math.abs(bo[0] - bi[0]) / 2;
|
|
1034
|
+
push('blush' + k, HEAD, spline(circle(bx, by, rx, 11, RY / rx), 1), solid(PALETTE.blush));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Identity marks sit above the broad blush, below hair and eyes. Their
|
|
1038
|
+
// profile uses outer upper-cheek anchors, away from every expressive fold.
|
|
1039
|
+
out.push(...K.skinDetail.draws(c, L));
|
|
1040
|
+
|
|
1041
|
+
emit(SHAPES_TOP);
|
|
1042
|
+
|
|
1043
|
+
// The eye socket is one broad, low-contrast plane, not another expression
|
|
1044
|
+
// line. Painting it under the animated eyes lets the lids, sclera and brows
|
|
1045
|
+
// keep their existing topology while the forehead-to-eye transition reads
|
|
1046
|
+
// as a continuous head instead of separate symbols placed on flat skin.
|
|
1047
|
+
if (K.p.form?.socket) {
|
|
1048
|
+
const socketDepth = K.p.form.socketDepth ?? 1;
|
|
1049
|
+
for (const side of [-1, 1]) {
|
|
1050
|
+
const k = side < 0 ? 'L' : 'R';
|
|
1051
|
+
const bi = L['bwI' + k], bm = L['bwM' + k], bo = L['bwO' + k];
|
|
1052
|
+
const [ex, ey] = L['eyeC' + k];
|
|
1053
|
+
push('socket' + k, HEAD, spline([
|
|
1054
|
+
[bi[0], bi[1] + 7], [bm[0], bm[1] + 9], [bo[0], bo[1] + 11],
|
|
1055
|
+
[bo[0], bo[1] + 11 + 14 * socketDepth],
|
|
1056
|
+
[ex, ey - 29 + 6 * socketDepth],
|
|
1057
|
+
[bi[0], bi[1] + 9 + 13 * socketDepth],
|
|
1058
|
+
], 0.82), solid(PALETTE.socket));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ---- eyes ---------------------------------------------------------------
|
|
1063
|
+
// Eighteen draws a side, in paint order, from author/parts/eye.mjs. The brow
|
|
1064
|
+
// rides with them: its landmarks are solved above (brow height carries a
|
|
1065
|
+
// little of the forehead mesh with it, which is the mesh's business), the
|
|
1066
|
+
// arch over them is the part's.
|
|
1067
|
+
for (const side of [-1, 1]) out.push(...K.eye.draws(c, L, side));
|
|
1068
|
+
|
|
1069
|
+
// ---- nose: persona-selectable planes; legacy tick is exact by default ----
|
|
1070
|
+
out.push(...K.nose.draws(c, L));
|
|
1071
|
+
|
|
1072
|
+
// ---- mouth ---------------------------------------------------------------
|
|
1073
|
+
// Eleven draws, in paint order, from author/parts/mouth.mjs. It reads `c.jaw`
|
|
1074
|
+
// (the lower lip follows the chin) and `c.lipFull` (the identity morph) as
|
|
1075
|
+
// well as its own `c.mouth` block, and nothing outside it reads anything it
|
|
1076
|
+
// computes — which is what let it move out whole.
|
|
1077
|
+
out.push(...K.mouth.draws(c, L));
|
|
1078
|
+
|
|
1079
|
+
// ---- the side shading plane, last ---------------------------------------
|
|
1080
|
+
// Painted over the finished face, so it tints the eye and the lip corner on
|
|
1081
|
+
// its side too, which is what makes it read as light rather than as a decal.
|
|
1082
|
+
// Its outer edge is sampled off the face outline and pulled P.shadowInset px
|
|
1083
|
+
// inboard: there is no clip in play, so anything that overshoots the
|
|
1084
|
+
// silhouette lands on the background.
|
|
1085
|
+
const fc = [P.cx, (P.headTop + P.chinY) / 2 + 40];
|
|
1086
|
+
const outline = FACE_LOOP.map((n) => L[n]);
|
|
1087
|
+
const contour = sampleRun(outline, 1, SHADOW_RUN[0], SHADOW_RUN[1], 3);
|
|
1088
|
+
const nb = contour.length - 1;
|
|
1089
|
+
// Widest over the cheek and jaw, tapering to a point at the chin and to
|
|
1090
|
+
// nothing up at the temple where the fringe covers the end of it.
|
|
1091
|
+
// The tips get pushed further in than the middle. A band that ends *on* the
|
|
1092
|
+
// contour looks like a leak: the fill bulges over the chin and onto the neck
|
|
1093
|
+
// where the outline curves hardest, and a tapering tip has no width left to
|
|
1094
|
+
// hide it. Recessing the ends costs nothing and reads as the shadow dying
|
|
1095
|
+
// out.
|
|
1096
|
+
const tipIn = (t) => P.shadowInset + 15 * Math.pow(1 - Math.sin(Math.PI * t), 2);
|
|
1097
|
+
// Always at least a few px wider than the outer inset, or the two edges
|
|
1098
|
+
// cross over and the band ties itself in a knot at the ends.
|
|
1099
|
+
const wAt = (t) => tipIn(t) + 4
|
|
1100
|
+
+ SHADOW_W * (K.p.form?.sideShadeWidth ?? 1)
|
|
1101
|
+
* Math.pow(Math.sin(Math.PI * t), 0.75) * (1 - 0.34 * t);
|
|
1102
|
+
push('faceShade', HEAD, band(
|
|
1103
|
+
contour.map((q, i) => inward(q, fc, tipIn(i / nb))),
|
|
1104
|
+
contour.map((q, i) => inward(q, fc, wAt(i / nb))),
|
|
1105
|
+
0.7,
|
|
1106
|
+
), solid(PALETTE.shade));
|
|
1107
|
+
|
|
1108
|
+
// ---- the hand ------------------------------------------------------------
|
|
1109
|
+
// LAST, and last for a reason: the hand is the nearest object in the frame,
|
|
1110
|
+
// so anything drawn over it is a depth lie. That includes the wardrobe's
|
|
1111
|
+
// bitmap hair and glasses, which anchor `front-all` — `finishRig` keeps this
|
|
1112
|
+
// group behind nothing (author/finish.mjs, the tail group).
|
|
1113
|
+
//
|
|
1114
|
+
// At rest `c.hand.gesture` is null and all ten draws come back at alpha 0,
|
|
1115
|
+
// parked below the bottom edge. Which is two independent reasons for a hand
|
|
1116
|
+
// not to be on screen, and that is the right number for a display list that
|
|
1117
|
+
// a pose blend, a wardrobe and a live evaluator all write to.
|
|
1118
|
+
out.push(...K.hand.draws(c.hand));
|
|
1119
|
+
|
|
1120
|
+
return out;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ===========================================================================
|
|
1124
|
+
// 9. PERFORMANCE — the control patches the driver's vocabulary gets filled
|
|
1125
|
+
// with. The base build and the pose diffing moved into `makeSpec` (§10):
|
|
1126
|
+
// the display list is a function of the persona now, and one process
|
|
1127
|
+
// builds several of them.
|
|
1128
|
+
// ===========================================================================
|
|
1129
|
+
|
|
1130
|
+
// ---- visemes --------------------------------------------------------------
|
|
1131
|
+
// The 16 mascot codes live in `author/parts/mouth-tables.mjs` now, next to the
|
|
1132
|
+
// channels they are written in — that header carries the translation out of
|
|
1133
|
+
// this avatar's old pixel units. Imported rather than re-declared: two idioms
|
|
1134
|
+
// driving the same part should be driving it off the same table.
|
|
1135
|
+
//
|
|
1136
|
+
// `RHUBARB_POSES` is the nine-letter Rhubarb set baked straight from voqalize's
|
|
1137
|
+
// own `VISEME_SHAPES` with no hand-tuning, as `rhubarb/A` … `rhubarb/X`. They
|
|
1138
|
+
// are OPTIONAL poses (src/vocab.js), so a rig whose mouth is still inline does
|
|
1139
|
+
// not have them and still validates.
|
|
1140
|
+
|
|
1141
|
+
// ---- eyes -----------------------------------------------------------------
|
|
1142
|
+
// The EYE half of all six states now comes from the PART (author/parts/eye.mjs,
|
|
1143
|
+
// EYE_TABLE), written in the driver's channel space — lid, squint, curveUp and
|
|
1144
|
+
// the three brow channels, plus the `cheekRaise` that belongs to a Duchenne
|
|
1145
|
+
// smile. That table is a statement about what those channels DO and is the same
|
|
1146
|
+
// statement in any style, so it lives with the code that spends them.
|
|
1147
|
+
//
|
|
1148
|
+
// What stays HERE is the MOUTH each state wears, which is not the eye's to say
|
|
1149
|
+
// and is very much this character's — and it is in the mouth part's channel
|
|
1150
|
+
// space too, since §7 hands `mouthRestChannels()` to the control vector. A
|
|
1151
|
+
// smile parts the lips: `open: 0.09` is a couple of millimetres of gap and
|
|
1152
|
+
// `teeth: 0.45` puts a sliver of the upper band in it, which is what the
|
|
1153
|
+
// lip-sync convention says a smile shows. Corners are per side now, so a smirk
|
|
1154
|
+
// would be `cornerL` alone — this face just does not happen to want one yet. A
|
|
1155
|
+
// sad mouth narrows and turns both corners down. Both closed variants carry the
|
|
1156
|
+
// same mouth as their open ones, so a blink over a smile does not shut the
|
|
1157
|
+
// mouth with it.
|
|
1158
|
+
//
|
|
1159
|
+
// So `eyes-happy` is ONE line assembled from two halves: the Duchenne eye out
|
|
1160
|
+
// of EYE_TABLE and the corner lift out of SMILE.
|
|
1161
|
+
const SMILE = { open: 0.09, width: 0.54, press: 0.15, teeth: 0.45, cornerL: 0.34, cornerR: 0.34 };
|
|
1162
|
+
const FROWN = { width: 0.25, cornerL: -0.44, cornerR: -0.44 };
|
|
1163
|
+
|
|
1164
|
+
const EYE_MOUTH = {
|
|
1165
|
+
'eyes-happy': SMILE,
|
|
1166
|
+
'eyes-happy_closed': SMILE,
|
|
1167
|
+
'eyes-sad': FROWN,
|
|
1168
|
+
'eyes-sad_closed': FROWN,
|
|
1169
|
+
};
|
|
1170
|
+
export const EYES = Object.fromEntries(Object.entries(EYE_TABLE).map(
|
|
1171
|
+
([n, patch]) => [n, EYE_MOUTH[n] ? { ...patch, mouth: EYE_MOUTH[n] } : patch],
|
|
1172
|
+
));
|
|
1173
|
+
|
|
1174
|
+
// ===========================================================================
|
|
1175
|
+
// 9.5 CAMERA — where the webcam is, in design space.
|
|
1176
|
+
// `meta.artboard` + `meta.align` crop this rectangle and blow it up to
|
|
1177
|
+
// fill a 4:3 frame (author/rig.mjs, `cameraMeta`). It is metadata: no
|
|
1178
|
+
// point, pose, stroke width or paint anywhere above changes because of it,
|
|
1179
|
+
// which is what makes a re-framing free of fidelity risk.
|
|
1180
|
+
// ===========================================================================
|
|
1181
|
+
|
|
1182
|
+
// The wardrobe sidecars put the outer hair edge at y=120 and y=135. Their
|
|
1183
|
+
// midpoint keeps both identities within 0.7% of the shared headroom while a
|
|
1184
|
+
// single live face module continues to serve both. The vector fringe at 226 is
|
|
1185
|
+
// hidden inside that silhouette and is not the crown a viewer sees.
|
|
1186
|
+
const VISIBLE_CROWN = 127.5;
|
|
1187
|
+
const SKULL_H = P.chinY - FREE.fr4[1];
|
|
1188
|
+
export const CAMERA_WINDOW = viewBoxForHead({
|
|
1189
|
+
centerX: P.cx,
|
|
1190
|
+
crownY: VISIBLE_CROWN,
|
|
1191
|
+
chinY: P.chinY,
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
export const CAMERA = {
|
|
1195
|
+
frame: { w: 1440, h: 1080 }, // a 4:3 webcam feed
|
|
1196
|
+
window: { cx: P.cx, y: CAMERA_WINDOW.y, h: CAMERA_WINDOW.h },
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
// ...and the same rectangle, spelled out, because the HAND needs it.
|
|
1200
|
+
//
|
|
1201
|
+
// Everything else in this file is in design space and does not care where the
|
|
1202
|
+
// crop is. The hand does: it enters from the frame's BOTTOM EDGE, which is a
|
|
1203
|
+
// fact about the camera and not about the character, and keeps its size tied
|
|
1204
|
+
// to the same native head. `author/parts/hand.mjs` turns those measurements
|
|
1205
|
+
// into its four numbers.
|
|
1206
|
+
//
|
|
1207
|
+
// The camera crop may change; the hand-to-head relationship must not.
|
|
1208
|
+
export const HAND_FRAME = handFrameOf(CAMERA_WINDOW, SKULL_H);
|
|
1209
|
+
|
|
1210
|
+
// ===========================================================================
|
|
1211
|
+
// 11. LIVE — what `src/live.js` needs on top of a control vector.
|
|
1212
|
+
//
|
|
1213
|
+
// The runtime evaluator drives `buildDraws` from voqalize's channels, and
|
|
1214
|
+
// two things it cannot get from the channels themselves live here, because
|
|
1215
|
+
// both are this character's and not the adapter's:
|
|
1216
|
+
//
|
|
1217
|
+
// REST the control vector at rest, which the adapter patches. It already
|
|
1218
|
+
// agrees with voqalize's own REST channel for channel (mouth open
|
|
1219
|
+
// 0.02 / width 0.42 / round 0.10 / press 0.15, lid 0.12, pupilY
|
|
1220
|
+
// 0.05) — that agreement is why a rest pose comes out as the baked
|
|
1221
|
+
// base and not as a face that twitches the moment it is switched on.
|
|
1222
|
+
//
|
|
1223
|
+
// HEAD how far one unit of headYaw / headPitch / headRoll / breath moves
|
|
1224
|
+
// this head. The rig's vocabulary has NO head poses (src/vocab.js:
|
|
1225
|
+
// visemes, eyes, morphs, hues — head motion only ever existed inside
|
|
1226
|
+
// the ambient TRACKS), so the adapter spends those channels the same
|
|
1227
|
+
// way `headMatFactory` does: a rotation about the neck pivot written
|
|
1228
|
+
// onto every head draw. The numbers are scaled off this avatar's own
|
|
1229
|
+
// idle sway (deg 2.4, tx 7) and its breath key ([0, 0, -3.6, -7]),
|
|
1230
|
+
// taken out to a full excursion at channel = 1.
|
|
1231
|
+
// ===========================================================================
|
|
1232
|
+
|
|
1233
|
+
export const REST = ctrl();
|
|
1234
|
+
|
|
1235
|
+
export const HEAD_LIVE = {
|
|
1236
|
+
pivot: P.neckPivot,
|
|
1237
|
+
yawPx: 40, // headYaw 1 slides the head this far toward the +x side
|
|
1238
|
+
pitchPx: 26, // headPitch 1 drops it this far (chin down)
|
|
1239
|
+
rollDeg: 9, // headRoll 1 tilts it this many degrees, + = viewer's right
|
|
1240
|
+
// The pre-swell breath: a flat rise of head and body, this avatar's own
|
|
1241
|
+
// `breathing` key ([0, 0, -3.6, -7]) taken out to channel 1. `BODY_LIVE`
|
|
1242
|
+
// now states a `breathSwell` instead and the head's rise is derived from
|
|
1243
|
+
// it, so these two are the fallback for a face that declares no swell.
|
|
1244
|
+
breathTy: -3.6,
|
|
1245
|
+
breathBodyTy: -7,
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
// BODY — the four channels that move the trunk rather than the head, in the
|
|
1249
|
+
// same shape voqalize's own faces spend them (face-core.js `poseTransforms`,
|
|
1250
|
+
// constants from face-peep.js `POSE`). Three facts carried over rather than
|
|
1251
|
+
// invented:
|
|
1252
|
+
//
|
|
1253
|
+
// * A LEAN IS A CHANGE OF SCALE. "In a webcam frame a lean is read almost
|
|
1254
|
+
// entirely as a change of scale, so that is how it is drawn" — a uniform
|
|
1255
|
+
// 1 + 0.055 about a point in the lower face, plus a small drop, and it is
|
|
1256
|
+
// applied to the WHOLE figure. The head is not counter-rotated and does
|
|
1257
|
+
// not stay put: peep prefixes the same lean transform to every layer it
|
|
1258
|
+
// draws, head and torso alike, and so does this.
|
|
1259
|
+
// * SHOULDERS AND THE TRUNK'S TURN ARE TORSO-ONLY. peep's `torsoLayers` is
|
|
1260
|
+
// `['body']`; nothing above the collar sees them. Ours is `meta.live.body`,
|
|
1261
|
+
// which is the same set for the same reason.
|
|
1262
|
+
// * ONE SHOULDER CANNOT RISE WITHOUT THE OTHER, because the torso is one
|
|
1263
|
+
// filled shape here as it is there. The mean of the two lifts it and the
|
|
1264
|
+
// difference rotates it a degree and a half about the sternum.
|
|
1265
|
+
//
|
|
1266
|
+
// The travels are peep's, converted rather than re-tuned. Both cameras now put
|
|
1267
|
+
// crown-to-chin at 70% of frame height, so native head heights are the whole
|
|
1268
|
+
// conversion: this face's visible 762.5 units / peep's 480 = 1.5885 design
|
|
1269
|
+
// units per peep unit. The two PIVOTS are anchored on the chin
|
|
1270
|
+
// rather than on the neck, because peep's are stated against a head (its
|
|
1271
|
+
// `leanPivot` is 37 units above its `CHIN_Y`, its `shrugPivot` 203 below) and
|
|
1272
|
+
// the two characters have different necks.
|
|
1273
|
+
// The last three are the trunk's share of channels the HEAD also takes, and
|
|
1274
|
+
// they are what `poseTransforms` does after `torsoT` rather than inside it:
|
|
1275
|
+
//
|
|
1276
|
+
// * `yawPx` — peep's `PARALLAX.body` is 0.1 against a head layer at 1.0, so
|
|
1277
|
+
// at `headYaw 1` its trunk slides 0.1 x 28 = 2.8 units. Converted, 4.45
|
|
1278
|
+
// design px here. It is NOT 10% of OUR `yawPx`: the travel is what the
|
|
1279
|
+
// viewer measures. On top of `turnPx`, which the mixer drives from the
|
|
1280
|
+
// same headYaw at 0.45 and a near-3x tau, arriving late.
|
|
1281
|
+
// * `rollDeg` — peep's `ROLL_TORSO 1.5` against its `ROLL_HEAD 5.5`. The
|
|
1282
|
+
// RATIO converts, not the number: degrees are degrees, but 1.5 deg is
|
|
1283
|
+
// peep's answer to a 5.5-deg head and ours is 9, so the trunk takes
|
|
1284
|
+
// 9 x 1.5/5.5 = 2.45. About `HEAD_LIVE.pivot`, which is the pivot peep
|
|
1285
|
+
// rolls both its head and its torso about.
|
|
1286
|
+
// * `breathSwell` / `swellPivot` — breath as chest expansion. The torso
|
|
1287
|
+
// scales about the HEM, so the shoulder line rises and the chest widens
|
|
1288
|
+
// while the bottom of the shirt stays put; the head rides the swell's
|
|
1289
|
+
// displacement at the neck pivot, which `src/live.js` derives rather than
|
|
1290
|
+
// taking a second constant. 0.008 is peep's own figure, unconverted
|
|
1291
|
+
// because a fraction has no units. The hem is this shirt's `bot`/`botc`
|
|
1292
|
+
// landmark at y 1700, the same anchor peep uses (its `swellPivot` is the
|
|
1293
|
+
// bottom edge of its own torso path, y 950).
|
|
1294
|
+
export const BODY_LIVE = {
|
|
1295
|
+
leanScale: 0.055, // torsoLean 1 scales the figure by this much...
|
|
1296
|
+
leanTravel: 36.54, // ...and drops it this far (23 peep units)
|
|
1297
|
+
leanPivot: [P.cx, P.chinY - 58.78],
|
|
1298
|
+
shrugLift: 47.66, // both shoulders at 1 lift the torso this far
|
|
1299
|
+
shrugTiltDeg: 1.6, // one shoulder at 1 rolls it this many degrees
|
|
1300
|
+
shrugPivot: [P.cx, P.chinY + 322.47],
|
|
1301
|
+
turnPx: 25.42, // torsoTurn 1 slides the torso this far in x
|
|
1302
|
+
yawPx: 4.45, // headYaw 1 drags the torso this far as well
|
|
1303
|
+
rollDeg: 2.45, // headRoll 1 rolls the torso this much, about
|
|
1304
|
+
// HEAD_LIVE.pivot — 1.5/5.5 of the head's 9
|
|
1305
|
+
breathSwell: 0.008, // breath 1 swells the torso by this fraction...
|
|
1306
|
+
swellPivot: [P.cx, 1700], // ...about the shirt's hem (`bot`/`botc`)
|
|
1307
|
+
};
|