456 lines
18 KiB
TypeScript

/**
* Ou poser le visage sur une forme du personnalisateur.
*
* Les yeux vivent sur une sphere, et `radiusAtAngle` les recolle au contour reel au
* prorata du rayon local. Ce prorata place bien leur CENTRE, mais l'oeil a une taille :
* la marge qui lui reste devant le bord est multipliee par le meme facteur, donc une
* silhouette etroite dans sa direction le pousse contre le bord jusqu'a ce que le
* masque l'ouvre vers l'exterieur. La gelule apparaissait comme une encoche dans le
* corps sur `capsule`, `triangle`, `nuage` et `goutte`.
*
* Ce module resout le probleme UNE FOIS, au chargement, et rend une table de decalages.
* Ce choix est l'essentiel du correctif, bien plus que la geometrie qui suit :
*
* Resolue dans la boucle de rendu, la correction reagit a tout ce qui bouge a soixante
* images par seconde — la derive du regard, le pointeur, l'expression en cours de
* morph, le bord le plus proche qui change, l'oeil le plus contraint qui change. Sept
* variantes ont ete ecrites ainsi et toutes produisaient un artefact de mouvement
* visible : tremblement permanent, saut de direction de 26 unites quand le bord de
* reference basculait, grossissement brusque quand la taille entrait dans le calcul.
* Le defaut n'etait dans aucune de leurs geometries, il etait dans le fait de resoudre
* par image.
*
* Le reste du moteur ne travaille pas comme ca : les poses sont DECLAREES et il ne fait
* que les interpoler avec des courbes connues. Un decalage tabule rentre dans ce moule.
* Il ne bouge pas quand le regard derive ni quand le pointeur bouge, et sur un changement
* de forme ou d'expression il ne fait qu'aller d'une entree de table a l'autre, sur la
* courbe de ce morph. Le tremblement devient impossible par construction, au lieu d'etre
* repousse : interpoler entre deux constantes est monotone, alors que re-resoudre le
* probleme sur un regard en cours d'interpolation ne l'est pas.
*
* Corollaire agreable : le solveur n'a plus aucune contrainte de continuite, puisqu'il ne
* tourne pas pendant l'animation. Il peut donc sonder tout un faisceau de directions et
* couvrir le pire cas de la derive du regard, ce qu'une version par image ne pouvait pas
* se permettre.
*
* La table est une constante de module, batie a l'import a partir de donnees pures :
* meme nature que le calendrier de clignements de `face.ts`, deterministe et sans etat,
* donc sans effet sur la purete de `engine.sample(t)`.
*/
import { EXPRESSIONS, type BotExpression } from './expressions'
import { eyePoses } from './face'
import { radiusAtAngle, toPoints, type Point } from './shape'
import { SHAPES } from './skins'
import { STATES, type Pose, type StateDef, type StateId } from './states'
/** Rayon de reference du solveur. Le decalage rendu est en unites de ce rayon. */
const R = 100
/**
* Amplitudes maximales de la vie au repos, lues sur `liveliness` : `loopNoise` est
* borne a 1 en valeur absolue, donc ces sommes sont des bornes exactes et non des
* estimations.
*
* Il faut les couvrir, sinon la correction est juste sur la pose nominale et fausse une
* seconde plus tard : 7 degres de lacet deplacent l'oeil d'une douzaine d'unites sur une
* boule de rayon 100. C'est precisement ce qui faisait deborder `capsule` + `effraye`
* alors qu'une mesure a un seul instant le declarait bon.
*/
const DERIVE_YAW = 5.5 + 1.6
const DERIVE_PITCH = 4.2 + 1.3
/** Flottement du centre, en unites de rayon de boule. */
const DERIVE_X = 0.006
const DERIVE_Y = 0.007
/** Le visage d'une pose, ce dont le solveur a besoin pour placer ses gelules. */
interface Visage {
gaze: Pose['gaze']
split: number
eyes: Pose['eyes']
}
/**
* Une gelule prete a etre mesuree : le segment de son axe, et de quoi calculer le rayon
* a degager DANS UNE DIRECTION donnee.
*
* Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par la
* matrice tangente est donc un segment epaissi d'une ELLIPSE, et le rayon a degager
* depend de la direction : c'est la fonction d'appui de cette ellipse, `r * |A^T u|`.
*
* Prendre a la place sa plus grande valeur singuliere serait conservateur mais faux dans
* la seule direction qui compte, et ca se paie cher : la marge de reference sur le cercle
* ressortait NEGATIVE, donc la demande devenait sans dents et 34 combinaisons
* continuaient de deborder.
*/
interface Empreinte {
/** centre, en unites de viewBox */
x: number
y: number
/** demi-vecteur de l'axe */
ax: number
ay: number
/** rayon du disque local, avant transformation */
r: number
/** colonnes de la matrice tangente, pour la fonction d'appui */
m: [number, number, number, number]
}
/**
* Empreintes des deux yeux d'un visage, posees sur un profil.
*
* Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par
* la matrice tangente est donc un segment epaissi d'une ELLIPSE, et un disque du rayon
* de son grand axe la couvre : d'ou la plus grande valeur singuliere. La mesure reste
* ainsi conservatrice au sens strict, une marge positive garantissant que la gelule est
* dedans.
*
* Le clignement n'y est pas : un oeil ferme n'a pas besoin qu'on lui fasse de la place.
*/
function empreintes(visage: Visage, sil: Pose['sil'], radii: number[]): Empreinte[] {
const out: Empreinte[] = []
const poses = eyePoses(visage.gaze, R, visage.split)
for (let i = 0; i < 2; i++) {
const e = poses[i]!
if (e.depth <= 0.02) continue
const cfg = visage.eyes[i]!
const phi = ((cfg.tilt ?? 0) * Math.PI) / 180
const cp = Math.cos(phi)
const sp = Math.sin(phi)
const ax = e.a * cp + e.c * sp
const ay = e.b * cp + e.d * sp
const cx = -e.a * sp + e.c * cp
const cy = -e.b * sp + e.d * cp
const hw = Math.max(cfg.w * R, 0.01) / 2
const hh = Math.max(cfg.h * R, 0.01) / 2
const r = Math.min(hw, hh)
// l'axe est celui de la plus grande dimension
const long = hh > hw
const demi = long ? hh - r : hw - r
// le prorata du rayon local, exactement comme le fait le moteur
const fit = radiusAtAngle(radii, Math.atan2(e.y, e.x) - sil.rot)
out.push({
x: e.x * fit,
y: e.y * fit,
ax: (long ? cx : ax) * demi,
ay: (long ? cy : ay) * demi,
r,
m: [ax, ay, cx, cy]
})
}
return out
}
/**
* Approche la plus courte entre un contour et un segment : la distance, et le vecteur
* qui va du contour vers le segment — le sens qui degage.
*
* Les deux sortent de la MEME passe. Les calculer separement doublait le seul vrai cout
* de ce module, qui est ce balayage.
*/
function approche(pts: Point[], x0: number, y0: number, x1: number, y1: number) {
const sx = x1 - x0
const sy = y1 - y0
const len2 = sx * sx + sy * sy
let best = Infinity
let vx = 0
let vy = 0
for (let i = 0; i < pts.length; i++) {
const p = pts[i]!
let t = len2 > 0 ? ((p.x - x0) * sx + (p.y - y0) * sy) / len2 : 0
t = t < 0 ? 0 : t > 1 ? 1 : t
const ex = x0 + t * sx - p.x
const ey = y0 + t * sy - p.y
const d2 = ex * ex + ey * ey
if (d2 < best) {
best = d2
vx = ex
vy = ey
}
}
const d = Math.sqrt(best)
return { d, ux: d > 1e-9 ? vx / d : 0, uy: d > 1e-9 ? vy / d : 0 }
}
/** Une epreuve : des gelules a faire tenir dans un contour, et le contour de reference. */
interface Epreuve {
empreintes: Empreinte[]
reference: Empreinte[]
contour: Point[]
calContour: Point[]
}
/**
* Flottement du centre au repos, en unites de viewBox. Il est ajoute au rayon de la
* gelule : moins d'une unite, donc l'absorber ainsi coute moins cher que de multiplier les
* epreuves par ses quatre coins.
*/
const FLOTTEMENT = Math.hypot(DERIVE_X, DERIVE_Y) * R
/** Marge de la gelule la plus serree, et le sens qui la degage. */
function pire(pts: Point[], emps: Empreinte[], tx: number, ty: number) {
let marge = Infinity
let ux = 0
let uy = 0
for (const e of emps) {
const x = e.x + tx
const y = e.y + ty
const a = approche(pts, x - e.ax, y - e.ay, x + e.ax, y + e.ay)
// fonction d'appui de l'ellipse dans la direction de l'approche
const [m0, m1, m2, m3] = e.m
const rayon =
e.r * Math.hypot(m0 * a.ux + m1 * a.uy, m2 * a.ux + m3 * a.uy) + FLOTTEMENT
if (a.d - rayon < marge) {
marge = a.d - rayon
ux = a.ux
uy = a.uy
}
}
return { marge, ux, uy }
}
/**
* Directions sondees et pas de la dichotomie. Le produit des deux est le cout de
* construction de la table, seul chiffre a surveiller ici.
*/
const DIRECTIONS = 12
const DICHOTOMIE = 8
/**
* Le decalage a poser sur les deux yeux pour cette forme, cet etat et cette expression.
*
* Une TRANSLATION commune aux deux yeux, donc une isometrie : ecart entre les yeux,
* tailles et inclinaisons sont conserves au pixel. Le visage est seulement pose un peu
* plus bas sur un corps qui n'a pas de place en haut, ce qui est le geste qu'on ferait a
* la main. Les variantes qui bornaient chaque oeil separement ecartaient la paire, et
* celles qui mettaient le visage a l'echelle rapetissaient les yeux — visiblement.
*
* La marge visee est celle du profil D'ORIGINE, pas un degagement strict : sur le cercle
* l'oeil exterieur frole deja le bord, 17,3 unites pour une boule de rayon 100, et c'est
* voulu, c'est ce qui donne le volume. Elle est plafonnee par ce que la forme offre en son
* centre, sinon la demande est intenable sur un corps plat.
*
* RECHERCHE DIRECTIONNELLE et non descente. On cherche la translation de plus petite
* norme qui tient, donc on sonde une couronne de directions et on dichotomie la distance
* le long de chacune. Une descente de gradient a ete ecrite d'abord et elle ne converge
* pas : degager la paire d'un bord la rapproche de l'autre, si bien qu'elle tatonne et ne
* fait que garder son meilleur essai — passer ses tours de 40 a 18 suffisait a faire
* reapparaitre 34 debordements. Ici le resultat ne depend pas d'une convergence : chaque
* direction est resolue exactement, au pas de dichotomie pres.
*/
function resous(epreuves: Epreuve[]): { x: number; y: number } {
if (!epreuves.length) return { x: 0, y: 0 }
/** La marge la plus serree sur toutes les epreuves, pour une translation donnee. */
const marge = (tx: number, ty: number) => {
let m = Infinity
for (const ep of epreuves) m = Math.min(m, pire(ep.contour, ep.empreintes, tx, ty).marge)
return m
}
// Marge exigee : la plus serree que le profil d'origine tolere, sur toutes les
// epreuves. Puis plafonnee par le plus degage que la forme puisse offrir a la paire,
// son centre.
let requis = Infinity
for (const ep of epreuves) {
requis = Math.min(requis, pire(ep.calContour, ep.reference, 0, 0).marge)
}
/*
* La course doit pouvoir atteindre le centre du corps : `wide` a des gelules de 87
* unites de long, et sur un triangle elles ne tiennent que vers le milieu, a une
* cinquantaine d'unites de leur place nominale. Une course fixe les laissait dehors.
*/
let mx = 0
let my = 0
const emps = epreuves[0]!.empreintes
for (const e of emps) {
mx -= e.x / emps.length
my -= e.y / emps.length
}
const course = Math.max(0.35 * R, Math.hypot(mx, my) * 1.25)
// Plafond de la demande : ce que la forme offre en son centre, toujours atteignable.
requis = Math.min(requis, marge(mx, my))
/*
* Deja bon : le cas du cercle, et de toute forme assez large. La gelule doit RENTRER
* en plus de n'etre pas plus serree que sur le profil d'origine — sans cette seconde
* condition, une forme ou rien ne rentre satisfait la premiere de facon degeneree et
* on abandonnait. `wide` a des gelules de 87 unites de long, `notify` de 50 de
* diametre : sur un triangle ou une goutte elles debordent quoi qu'on fasse, et il
* faut alors viser le moins pire, pas renoncer.
*/
const depart = marge(0, 0)
if (depart >= requis && depart >= 0) return { x: 0, y: 0 }
const cible = Math.max(requis, 0)
let meilleurX = 0
let meilleurY = 0
let meilleureNorme = Infinity
// repli quand rien ne rentre : la translation qui degage le plus, sondee au passage
let secoursX = 0
let secoursY = 0
let secours = depart
for (let d = 0; d < DIRECTIONS; d++) {
const a = (d / DIRECTIONS) * Math.PI * 2
const ux = Math.cos(a)
const uy = Math.sin(a)
if (marge(ux * course, uy * course) < cible) {
// cette direction ne mene nulle part ; on garde quand meme le meilleur degagement
// pas de solution par la, mais peut-etre un meilleur degagement
for (const k of [0.3, 0.6, 1]) {
const m = marge(ux * course * k, uy * course * k)
if (m > secours) {
secours = m
secoursX = ux * course * k
secoursY = uy * course * k
}
}
continue
}
// la plus courte distance qui tient, le long de cette direction
let bas = 0
let haut = course
for (let i = 0; i < DICHOTOMIE; i++) {
const mid = (bas + haut) / 2
if (marge(ux * mid, uy * mid) >= cible) haut = mid
else bas = mid
}
if (haut < meilleureNorme) {
meilleureNorme = haut
meilleurX = ux * haut
meilleurY = uy * haut
}
}
const x = meilleureNorme === Infinity ? secoursX : meilleurX
const y = meilleureNorme === Infinity ? secoursY : meilleurY
// rendu en unites de RAYON DE BOULE : le moteur le remet a son echelle
return { x: +(x / R).toFixed(6), y: +(y / R).toFixed(6) }
}
/**
* Le visage a couvrir : celui de l'expression si l'etat l'accepte, le sien sinon.
*
* UNE entree de table par expression, et non un pire cas commun a toutes. Un pire cas
* paraissait plus sur — un decalage constant ne peut pas bouger quand l'expression change
* — mais il est intenable : sur une capsule, `neutre` a les yeux hauts et demande a
* descendre quand `effraye` les a bas et demande a monter. Aucune translation unique ne
* satisfait les deux, et la mesure le confirme (4 debordements de 4,8 unites).
*
* Une entree par expression n'est pas moins fluide pour autant : le moteur interpole
* entre DEUX CONSTANTES, ce qui est monotone par construction. Ce qui tremblait, c'etait
* de re-resoudre le probleme sur un regard en cours d'interpolation.
*/
function visageDe(def: StateDef, pose: Pose, expr: BotExpression | null): Visage {
if (def.baseFace && expr) return { gaze: expr.gaze, split: expr.split, eyes: expr.eyes }
return { gaze: pose.gaze, split: pose.split, eyes: pose.eyes }
}
/** Les dates a echantillonner dans un etat : une seule si sa pose ne bouge pas. */
function dates(def: StateDef): number[] {
/** Tout ce dont le solveur se sert : si rien ne bouge, une date suffit. */
const signature = (p: Pose) =>
JSON.stringify([p.gaze, p.split, p.eyes, p.sil.rot, p.sil.cx, p.sil.cy, p.sil.sx, p.sil.sy])
if (signature(def.pose(0)) === signature(def.pose(def.duration))) return [0]
const n = 3
return Array.from({ length: n }, (_, i) => (i / (n - 1)) * def.duration)
}
/** Le decalage d'une forme sur un etat et une expression, derive comprise. */
function decalagePour(
def: StateDef,
radii: number[],
expr: BotExpression | null
): { x: number; y: number } {
const epreuves: Epreuve[] = []
for (const t of dates(def)) {
const pose = def.pose(t)
const contour = toPoints({ ...pose.sil, radii }, R)
const calContour = toPoints(pose.sil, R)
const v = visageDe(def, pose, expr)
// Les quatre coins de la derive bornent la pose nominale, qui est leur centre : la
// tester en plus ne changerait aucune marge et coute une epreuve sur cinq.
const coins: Visage[] = []
for (const dy of [-DERIVE_YAW, DERIVE_YAW]) {
for (const dp of [-DERIVE_PITCH, DERIVE_PITCH]) {
coins.push({
...v,
gaze: { yaw: v.gaze.yaw + dy, pitch: v.gaze.pitch + dp, roll: v.gaze.roll }
})
}
}
for (const c of coins) {
epreuves.push({
empreintes: empreintes(c, pose.sil, radii),
reference: empreintes(c, pose.sil, pose.sil.radii),
contour,
calContour
})
}
}
return resous(epreuves)
}
/** Zero, la valeur commune a tout ce qui n'a rien a corriger. */
const NUL = { x: 0, y: 0 } as const
/** Clef d'une entree : l'etat, et l'expression quand l'etat l'accepte. */
const clef = (state: StateId, expr: string | null) => `${state}|${expr ?? ''}`
/**
* Table des decalages, batie a l'import : une entree par (forme, etat a corps de base,
* expression). Seuls `idle` et `swirl` portent le visage de repos, donc seuls eux se
* declinent par expression — les trois autres etats a corps de base ont un visage releve
* sur la video et une seule entree.
*
* Clef par REFERENCE du tableau de rayons, ce qui est deja la convention du moteur : ses
* gardes `radii === this.shape` et `expression === this.expr` reposent sur la meme
* stabilite. Un profil inconnu, ou `null`, ne corrige rien — l'API accepte n'importe quel
* tableau et le moteur n'a pas a dependre de la prudence de ses appelants.
*/
function batir(): Map<number[], Map<string, { x: number; y: number }>> {
return new Map(
SHAPES.map((forme) => {
const par = new Map<string, { x: number; y: number }>()
for (const def of STATES) {
if (!def.baseBody) continue
const expressions = def.baseFace ? [null, ...EXPRESSIONS] : [null]
for (const expr of expressions) {
par.set(clef(def.id, expr?.id ?? null), decalagePour(def, forme.radii, expr))
}
}
return [forme.radii, par]
})
)
}
const DECALAGES = batir()
/**
* Decalage a appliquer aux deux yeux pour cette forme sur cet etat, en unites de rayon
* de boule — le moteur le remet a son echelle.
*
* Vaut zero des que la forme n'est pas au catalogue, ce qui couvre `null` et le cercle :
* sur le cercle les deux profils sont le meme, donc la marge est deja celle exigee et la
* descente sort au premier tour. La forme relevee sur la video ne bouge donc pas, sans
* cas particulier.
*/
export function decalageDesYeux(
radii: number[] | null,
state: StateId,
expr: string | null
): { x: number; y: number } {
if (!radii) return NUL
const par = DECALAGES.get(radii)
if (!par) return NUL
// un etat sans visage de repos n'a qu'une entree, quelle que soit l'expression
return par.get(clef(state, expr)) ?? par.get(clef(state, null)) ?? NUL
}
/** Pour les tests : de quoi verifier la table sans refaire la geometrie. */
/** Pour les tests : de quoi chronometrer la construction de la table. */
export const POUR_TESTS = { batir }