Fosforonero
Dev6 giugno 2026 · 4 min di lettura

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 gμνg_{\mu\nu}. Per un buco nero rotante uso la metrica di Kerr nella forma cartesiana di Kerr–Schild:

gμν=ημν+fkμkν,f=2Mr3r4+a2z2g_{\mu\nu} = \eta_{\mu\nu} + f\,k_\mu k_\nu, \qquad f = \frac{2 M r^{3}}{r^{4} + a^{2} z^{2}}

dove η\eta è lo spazio-tempo piatto di Minkowski, kk un vettore nullo e a=J/Ma = J/M 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 E=1E=1, impulso spaziale uguale alla direzione del raggio) e non devo costruire la tetrade dell'osservatore — uno dei punti dove è facilissimo sbagliarsi. A spin nullo (a=0a=0) 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:

H=12gμνpμpν=0,x˙μ=Hpμ,p˙μ=HxμH = \tfrac{1}{2}\,g^{\mu\nu} p_\mu p_\nu = 0, \qquad \dot x^{\mu} = \frac{\partial H}{\partial p_\mu}, \quad \dot p_\mu = -\frac{\partial H}{\partial x^{\mu}}

Il drift (x˙\dot x, come si muove la posizione) ha forma chiusa; il kick (p˙\dot p, come cambia l'impulso) lo calcolo per differenze finite del gradiente di HH. 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,
yn+1=yn+h6(k1+2k2+2k3+k4)y_{n+1} = y_n + \tfrac{h}{6}\left(k_1 + 2k_2 + 2k_3 + k_4\right)

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:

g=1ut(1Ωλ)g = \frac{1}{u^{t}\,(1 - \Omega\,\lambda)}

con Ω\Omega velocità angolare del gas, utu^{t} la componente temporale della sua 4-velocità (la dilatazione del tempo) e λ\lambda il momento angolare assiale conservato del fotone. La cosa elegante: non devo «trasportare» il colore lungo il raggio. La quantità Iν/ν3I_\nu/\nu^{3} è 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 gg resta un corpo nero a temperatura gTg\,T. 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.

Apri la simulazione → · Le equazioni →