286 lines
8.4 KiB
TypeScript

import { TAU, clamp, createRng, r2 } from './math'
/* ------------------------------------------------------------------ couleurs */
/**
* Les anneaux ne sont pas des couleurs plates : la video montre une roue de
* teintes complete a luminosite constante, avec un degrade le long de chaque
* trace. Mesure : S 45-62 %, L 50-67 %.
*/
function wheel(hue: number, s = 0.55, l = 0.62): string {
const h = ((hue % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = l - c / 2
const [r, g, b] =
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x]
const hex = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, '0')
return `#${hex(r)}${hex(g)}${hex(b)}`
}
/* ------------------------------------------------------------- types de rendu */
export interface DotRender {
x: number
y: number
r: number
opacity: number
/** couleur explicite ; par defaut le rendu prend celle du corps */
color?: string
/**
* Brume de profondeur : 0 = fondu dans le fond, 1 = couleur du corps pleine.
* Le melange se fait au rendu, qui seul connait la couleur choisie.
*/
depth?: number
/**
* Forme non circulaire, en unites de rayon de boule et centree sur l'origine
* (le point du "!" penche est une goutte, pas un disque). Quand elle est
* fournie, `r` n'est plus utilise pour le trace.
*/
d?: string
/** rotation appliquee a `d`, en degres */
rot?: number
}
/**
* Ce qu'un etat declare : la geometrie de l'arc reste en unites de rayon de
* boule, c'est le moteur (seul a connaitre l'echelle du viewBox) qui la
* rasterise. Sans ca les etats devraient connaitre le viewBox.
*/
export interface ArcSpec {
id: string
seed: ArcSeed
t: number
opacity: number
}
export interface ArcRender {
id: string
/** portion devant le corps */
front: string
/** portion derriere le corps (dessinee avant, donc masquee par la silhouette) */
back: string
width: number
opacity: number
/** degrade de teinte le long du trace */
grad: { x1: number; y1: number; x2: number; y2: number; stops: string[] }
}
/* --------------------------------------------------------- arc elliptique 3D */
export interface ArcSeed {
/** demi-grand axe, en unites de rayon de boule */
a: number
/** aplatissement b/a : mesure <= 0.45, les plans d'orbite sont vus par la tranche */
k: number
/** inclinaison du grand axe a l'ecran, radians */
tilt: number
/** tours par seconde */
speed: number
phase: number
/** fraction du tour reellement tracee */
sweep: number
hue: number
hueSpan: number
width: number
cx: number
cy: number
}
/**
* Projette un cercle 3D incline en orthographique.
*
* Le cercle vit dans le plan engendre par u (dans l'ecran) et v (qui plonge
* dans la profondeur). La composante z sert a couper l'arc en deux : la moitie
* arriere est dessinee avant le corps, donc occultee par lui. C'est ce vrai tri
* en profondeur qui fait lire les anneaux comme des orbites et pas comme un
* dessin plat.
*/
export function arcRender(seed: ArcSeed, t: number, scale: number, id: string, opacity = 1): ArcRender {
const spin = seed.phase + t * seed.speed * TAU
const cu = Math.cos(seed.tilt)
const su = Math.sin(seed.tilt)
const kz = Math.sqrt(Math.max(0, 1 - seed.k * seed.k))
const N = 64
const span = seed.sweep * TAU
let front = ''
let back = ''
let prev: boolean | null = null
for (let i = 0; i <= N; i++) {
const th = spin + (i / N) * span
const ct = Math.cos(th)
const st = Math.sin(th)
// u = (cos tilt, sin tilt, 0) ; v = (-sin tilt * k, cos tilt * k, kz)
const x = seed.a * (ct * cu + st * -su * seed.k) + seed.cx
const y = seed.a * (ct * su + st * cu * seed.k) + seed.cy
const z = seed.a * st * kz
const behind = z < 0
const sx = r2(x * scale)
const sy = r2(y * scale)
const cmd = behind !== prev ? 'M' : 'L'
if (behind) back += `${cmd}${sx} ${sy}`
else front += `${cmd}${sx} ${sy}`
prev = behind
}
const gx = Math.cos(seed.tilt) * seed.a * scale
const gy = Math.sin(seed.tilt) * seed.a * scale
return {
id,
front,
back,
width: seed.width * scale,
opacity,
grad: {
x1: r2(seed.cx * scale - gx),
y1: r2(seed.cy * scale - gy),
x2: r2(seed.cx * scale + gx),
y2: r2(seed.cy * scale + gy),
stops: [wheel(seed.hue), wheel(seed.hue + seed.hueSpan * 0.5), wheel(seed.hue + seed.hueSpan)]
}
}
}
/* ---------------------------------------------------------------- anneaux */
const RING_RNG = createRng(0xa11ce)
/**
* 6 anneaux, demi-grand axe 1.30-1.40 (donc nettement plus grands que la
* boule), aplatissement toujours <= 0.45, epaisseur 0.055, ~3.3 tours/s.
*/
export const RINGS: ArcSeed[] = Array.from({ length: 6 }, (_, i) => ({
a: 1.3 + RING_RNG() * 0.1,
k: 0.05 + RING_RNG() * 0.4,
tilt: (i / 6) * Math.PI + RING_RNG() * 0.5,
speed: 3 + RING_RNG() * 0.7,
phase: RING_RNG() * TAU,
sweep: 0.6 + RING_RNG() * 0.25,
hue: (i * 360) / 6 + RING_RNG() * 30,
hueSpan: 60 + RING_RNG() * 60,
width: 0.05 + RING_RNG() * 0.012,
cx: 0,
cy: 0.1
}))
/**
* Bouquet d'arcs emboites qui balaie le triangle juste avant les orbites.
* Vus quasiment par la tranche (d'ou la forme en epingle a cheveux), rmax 1.37.
*/
export const SWOOSH: ArcSeed[] = Array.from({ length: 4 }, (_, i) => ({
a: 0.78 + i * 0.2,
k: 0.05 + i * 0.02,
tilt: -0.62 + i * 0.05,
speed: 0.3,
phase: 0.06 * i,
sweep: 0.4,
hue: 95 + i * 62,
hueSpan: 100,
width: 0.05,
cx: 0,
cy: -0.12
}))
/* ------------------------------------------------------------- 3 points */
/** x mesures : -0.557 / -0.013 / +0.532, y = 0. */
export const DOT_X = [-0.557, -0.013, 0.532] as const
export const DOT_R = 0.165
export const DOT_PEAK = 1.25
/* ------------------------------------------------------------ particules */
const P_RNG = createRng(0xbeef)
/** 5 particules, une nouvelle toutes les 0.2 s, duree de vie 0.55 s. */
const PARTICLES = Array.from({ length: 5 }, (_, i) => ({
birth: i * 0.2,
angle: P_RNG() * TAU,
rho: 0.58 + P_RNG() * 0.18
}))
/**
* Les particules ne partent pas en ligne droite : elles spiralent vers le
* centre (rayon x0.75 par frame, angle +100 deg/s) en grossissant, et passent
* derriere le noyau ou elles sont avalees.
*/
export function particles(t: number, scale: number): DotRender[] {
const out: DotRender[] = []
for (const p of PARTICLES) {
const u = t - p.birth
if (u < 0 || u > 0.62) continue
const rho = p.rho * Math.pow(0.75, u * 10)
const a = p.angle + (u * 100 * Math.PI) / 180
out.push({
x: Math.cos(a) * rho * scale,
y: Math.sin(a) * rho * scale,
r: (0.04 + 0.028 * clamp(u / 0.55)) * scale,
depth: clamp(1 - rho / 0.8),
opacity: clamp(u / 0.06) * clamp((0.62 - u) / 0.08)
})
}
return out
}
/* ------------------------------------------------------------------ comete */
/**
* Contrairement a l'intuition, le point ne traverse pas l'ecran : il reste au
* centre et c'est la trainee qui l'orbite. Ellipse a = 0.85, b = 0.15,
* grand axe incline de +34deg, 4 rubans, ~210 deg/s.
*/
const COMET_RNG = createRng(0xc0e7)
export const COMET_RIBBONS: ArcSeed[] = Array.from({ length: 4 }, (_, i) => {
const d = i - 1.5
return {
a: 0.85 * (1 + d * 0.03),
// meme aplatissement a +-5 % pres : les rubans forment un faisceau serre
k: (0.15 / 0.85) * (1 + d * 0.16),
tilt: (34 * Math.PI) / 180 + d * 0.035,
speed: 210 / 360,
// dephasage mesure : 10 a 20 degres entre rubans, pas davantage
phase: -i * 0.045 + COMET_RNG() * 0.012,
sweep: 0.34,
hue: i * 85 + COMET_RNG() * 20,
hueSpan: 80,
width: 0.095,
cx: 0,
cy: 0
}
})
/** Rayon du point de la comete, mesure a 0.129. */
export const COMET_DOT = 0.129
/* --------------------------------------------------- pastille notification */
/** Bleu releve au pixel. */
export const NOTIF_BLUE = '#2496e8'
/** La pastille est posee exactement sur la circonference, a -42deg. */
export const NOTIF_ANGLE = -42
export const NOTIF_DIST = 1.003
/** Rayon au repos ; le pop culmine 14 % au-dessus. */
export const NOTIF_R = 0.15
export const NOTIF_POP = 1.14
/**
* L'encoche est un disque concentrique a la pastille, soustrait du corps.
* La marge est constante (0.054 R) et suit l'echelle du corps.
*/
export const NOTIF_MARGIN = 0.054