286 lines
8.4 KiB
TypeScript
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
|