import { clamp, createRng, loopNoise } from './math' /** * Les yeux sont peints sur une sphere, pas poses a plat. * * Mesure sur la video : l'oeil le plus proche du bord fait 0.69 fois la largeur * de l'autre, et son aire 0.663 fois — exactement le facteur de profondeur * (z = 0.669) d'un point de sphere a cette distance du centre. On modelise donc * une vraie orientation de tete : chaque oeil recupere le repere tangent de la * sphere, projete en orthographique. La compression et l'inclinaison en * decoulent toutes seules, c'est ce qui donne le volume. * * Les constantes ci-dessous ne sont pas choisies a la main : elles sortent d'un * ajustement du modele sur les positions et tailles relevees image par image * (erreur residuelle ~1 px sur un rayon de 190 px). */ type Vec3 = [number, number, number] /** Demi-ecart des yeux sur la sphere, en degres (separation totale ~31deg). */ export const EYE_SPLIT = 15.46 /** Taille de l'oeil au repos, en unites de rayon de boule. */ export const EYE_W = 0.186 export const EYE_H = 0.412 /** Orientation de tete au repos, ajustee sur les frames de reference. */ export const REST_GAZE: HeadGaze = { yaw: 28.49, pitch: 28.62, roll: -13 } export interface EyePose { x: number y: number /** matrice tangente 2x2 : [a b c d] au sens SVG matrix(a,b,c,d,e,f) */ a: number b: number c: number d: number /** composante z de la normale : > 0 = face visible */ depth: number } export interface HeadGaze { /** lacet, degres, positif = regarde a droite */ yaw: number /** tangage, degres, positif = regarde en haut */ pitch: number /** roulis, degres, inclinaison de la tete */ roll: number } const deg = (d: number) => (d * Math.PI) / 180 /** Fait tourner deux vecteurs d'un repere orthonorme dans leur plan commun. */ function spin(u: Vec3, v: Vec3, angle: number): [Vec3, Vec3] { const c = Math.cos(angle) const s = Math.sin(angle) return [ [u[0] * c + v[0] * s, u[1] * c + v[1] * s, u[2] * c + v[2] * s], [v[0] * c - u[0] * s, v[1] * c - u[1] * s, v[2] * c - u[2] * s] ] } /** * Repere de la tete puis des deux yeux. * Repere ecran : x a droite, y vers le bas, z vers le spectateur. * L'indice 0 est l'oeil interieur, l'indice 1 l'oeil exterieur. */ export function eyePoses(gaze: HeadGaze, scale: number, split = EYE_SPLIT): [EyePose, EyePose] { let f: Vec3 = [0, 0, 1] let right: Vec3 = [1, 0, 0] let down: Vec3 = [0, 1, 0] // lacet : forward bascule vers right ;[f, right] = spin(f, right, deg(gaze.yaw)) // tangage : forward bascule vers le haut (donc a l'oppose de down) ;[down, f] = spin(down, f, deg(gaze.pitch)) // roulis : la tete penche dans son propre plan ;[right, down] = spin(right, down, deg(gaze.roll)) const build = (side: number): EyePose => { const [ef, er] = spin(f, right, deg(split * side)) return { x: ef[0] * scale, y: ef[1] * scale, a: er[0], b: er[1], c: down[0], d: down[1], depth: ef[2] } } return [build(-1), build(1)] } /** * Vie au repos : derive lente du regard, saccades, clignements. * * Fonction pure du temps (aucun etat interne), donc pause, reprise et saut a * une date arbitraire donnent toujours la meme image. Les valeurs sont des * ECARTS a ajouter a la pose de l'etat courant. */ export interface Liveliness { dYaw: number dPitch: number dRoll: number /** 1 = oeil ouvert, 0 = ferme (ecrasement vertical en repere ecran) */ lid: number driftX: number driftY: number breath: number } const BLINK_RNG = createRng(0x5eed) /** Calendrier de clignements pre-tire : deterministe et sans etat. */ const BLINKS: number[] = (() => { const out: number[] = [] let t = 1.4 while (t < 900) { out.push(t) // 1.9 a 4.6 s entre deux clignements, plus un double clignement parfois t += 1.9 + BLINK_RNG() * 2.7 if (BLINK_RNG() < 0.18) { out.push(t) t += 0.24 } } return out })() /** Mesure : 1 a 2 frames a 10 fps. */ const BLINK_DUR = 0.18 function blinkLid(t: number): number { for (let i = 0; i < BLINKS.length; i++) { const start = BLINKS[i]! if (t < start) break const k = (t - start) / BLINK_DUR if (k >= 0 && k <= 1) { // fermeture rapide, reouverture un peu plus lente return k < 0.45 ? 1 - k / 0.45 : (k - 0.45) / 0.55 } } return 1 } export interface LivelinessOptions { wander?: number blink?: boolean float?: boolean } export function liveliness(t: number, opt: LivelinessOptions = {}): Liveliness { const { wander = 1, blink = true, float = true } = opt // Periodes premieres entre elles : la derive ne se repete jamais a l'oeil. return { dYaw: (loopNoise(t, 11.3, 0.4) * 5.5 + loopNoise(t, 3.7, 2.1) * 1.6) * wander, dPitch: (loopNoise(t, 9.1, 1.3) * 4.2 + loopNoise(t, 4.3, 0.7) * 1.3) * wander, dRoll: loopNoise(t, 13.7, 3.2) * 2.2 * wander, lid: blink ? blinkLid(t) : 1, // Au repos la video est quasiment immobile (centre stable a +-0.003, rayon // constant) : toute la vie passe par le regard et les clignements. On garde // juste de quoi ne pas figer completement l'image. driftX: float ? loopNoise(t, 7.9, 1.9) * 0.006 : 0, driftY: float ? loopNoise(t, 5.3, 0.3) * 0.007 : 0, // La largeur est constante, seule la hauteur respire tres legerement. breath: float ? 1 + Math.sin((t / 3.4) * Math.PI * 2) * 0.005 : 1 } } /** * Le clignement est un ecrasement VERTICAL en repere ecran autour du centre de * l'oeil (mesure : la largeur de bbox est conservee, la hauteur tombe a ~0.35), * pas un retrecissement le long de l'axe incline de la gelule. On le compose * donc apres la matrice tangente, en n'affectant que les sorties en y. */ export function blinkScale(lid: number): number { return 0.06 + 0.94 * clamp(lid) }