Ray-tracing relativistico in tempo reale: un buco nero di Kerr in WebGL
Come ho reso le geodetiche nulle esatte della metrica di Kerr in un fragment shader: la forma di Kerr–Schild, l'integrazione hamiltoniana, tre integratori (Eulero, RK4, Yoshida via Tao), il colore di corpo nero spostato dal Doppler, e la battaglia col compilatore shader mobile. Con le equazioni.
La parte facile di un simulatore di buco nero sono i dati. La parte difficile è far girare la relatività generale dentro i ~16 millisecondi a fotogramma di un fragment shader WebGL — su un telefono. Queste sono le note tecniche di come ci sono riuscito per un buco nero di Kerr.
L'idea: sparare la luce all'indietro
Per ogni pixel dello schermo sparo un raggio dalla camera e lo seguo nello spazio-tempo curvo. Sembra al contrario — la luce va verso l'occhio, non da esso — ma in relatività generale i cammini dei fotoni sono reversibili: tracciare all'indietro dà la stessa identica immagine. È il trucco che ci risparmia di simulare miliardi di fotoni che non colpiranno mai lo schermo. Ne lanciamo uno solo per pixel, esattamente quello che serve.
La metrica: dove vive la geometria
Qui la gravità non è una forza, è la forma dello spazio-tempo, scritta nella metrica . Per un buco nero rotante uso la metrica di Kerr nella forma cartesiana di Kerr–Schild:
dove è lo spazio-tempo piatto di Minkowski, un vettore nullo e lo spin. Perché questa forma e non le solite coordinate di Boyer–Lindquist? Perché non ha la singolarità di coordinate all'orizzonte ed è asintoticamente piatta: così il fotone, alla camera lontana, parte con un momento banale (energia , impulso spaziale uguale alla direzione del raggio) e non devo costruire la tetrade dell'osservatore — uno dei punti dove è facilissimo sbagliarsi. A spin nullo () si riduce esattamente a Schwarzschild.
L'integrazione: hamiltoniana
Un fotone viaggia su una geodetica nulla. La tratto come il flusso di un'hamiltoniana, perché così posso scrivere il passo in modo pulito:
Il drift (, come si muove la posizione) ha forma chiusa; il kick (, come cambia l'impulso) lo calcolo per differenze finite del gradiente di . Il passo è adattivo: fine vicino al buco — dove la curvatura conta e si forma il photon ring — e grosso lontano, dopo un avanzamento analitico in linea retta fino alla sfera d'influenza, per non bruciare passi nel vuoto.
Tre integratori per tre esigenze
Integrare un'equazione differenziale è come camminare al buio a passi finiti: troppo lunghi e sbagli strada, troppo corti e non arrivi mai. Uso tre «andature», scelte dalla qualità.
- Eulero simplettico (media/bassa): un solo gradiente per passo, economico, 1° ordine.
- Runge–Kutta del 4° ordine (alta): valuta la pendenza quattro volte e ne fa una media pesata,
portando l'errore di deflessione a ~10⁻⁷ rad (l'ho verificato offline contro una soluzione di riferimento).
- Yoshida del 6° ordine, simplettico (Ultra): qui c'è un trucco. Yoshida compone passi leapfrog, ma il leapfrog vuole un'hamiltoniana separabile; la nostra non lo è. Serve il metodo di Tao (2016), che raddoppia lo spazio delle fasi per renderla integrabile. La demo «Orbite» ha invece l'equazione di Binet, che è separabile: lì un Yoshida diretto tiene il drift dell'energia a ~10⁻¹³, e lo mostro dal vivo.
Il colore non lo scelgo io
Il disco emette come un corpo nero: ogni anello ha una temperatura, da cui ricavo il vero colore planckiano. Poi lo sposto con il fattore Doppler relativistico, esatto per un'orbita circolare di Kerr:
con velocità angolare del gas, la componente temporale della sua 4-velocità (la dilatazione del tempo) e il momento angolare assiale conservato del fotone. La cosa elegante: non devo «trasportare» il colore lungo il raggio. La quantità è un invariante (teorema di Liouville: i fotoni nel loro spazio delle fasi non si accalcano né si diradano), quindi un corpo nero visto con fattore resta un corpo nero a temperatura . Niente palette arbitrarie — e un lato del disco diventa abbagliante e bluastro, l'altro cupo e rosso.
La battaglia col compilatore shader mobile
L'errore più istruttivo. Avevo messo l'integratore Yoshida-6 (Tao) dentro lo shader. Su desktop: perfetto. Su mobile: il disco spariva, anche in qualità media. La causa non era la velocità, era la dimensione: quel codice veniva compilato nell'unico shader, sempre, e superava il limite del compilatore della GPU del telefono → falliva l'intero rettangolo a schermo (lensing e disco insieme). La soluzione: isolarlo dietro un #define GLSL, compilato solo quando selezioni «Ultra». Lo shader di default resta piccolo e gira ovunque. Lezione che mi porto dietro: su WebGL mobile conta quanto è grande il programma da compilare, non solo quanto è veloce.
Stack e onestà
Three.js + React Three Fiber, GLSL3, pipeline a 16-bit con dithering anti-banding, KaTeX per le equazioni. Ogni approssimazione è dichiarata: il disco è un modello (non una soluzione GRMHD), i getti sono stilizzati, i corpi del playground non sono lensati. La derivazione completa, con le fonti accademiche, è nella pagina delle equazioni.