180 lines
5.7 KiB
TypeScript

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)
}