Archivio

Come creare un sito immersivo con Astro e Three.js: il mio, pezzo per pezzo

GuideTecnologia

La home di volpatodavide.com: il nome in Bodoni accanto a un attrattore di particelle dorate

Dal 2019 questo blog girava su WordPress. Andava bene per scrivere guide. Non andava bene per raccontare quello che faccio oggi: agenti AI, automazioni, codice e mondi 3D.

Così l’ho rifatto da zero.

Il nuovo sito è statico, costruito con Astro, e la home è una scena Three.js: 80.000 punti che, mentre scorri, cambiano forma sei volte. Caos, rete neurale, workflow, codice, pianeta, globo. Sempre gli stessi punti. L’idea è tutta lì: ogni punto è un dato, e il mio lavoro è dare ai dati una forma che serva a qualcosa.

In questa guida ti spiego come l’ho costruito, con il codice vero. Ci sono la migrazione del blog senza perdere posizionamento, gli shader, le prestazioni sui telefoni e la pubblicazione. Se sai cos’è un vertex shader ti basta. Se non lo sai, alla fine lo saprai.

La home: il nome in Bodoni e l'attrattore di Aizawa fatto di particelle

La prima forma: un attrattore di Aizawa. È il “caos” da cui parte tutto.

Lo stack, e perché niente WordPress

Per un sito personale WordPress oggi è troppo. Database, plugin da aggiornare, tentativi di accesso al pannello ogni notte. Un sito statico non ha niente da bucare e niente da aggiornare la domenica mattina.

Ecco cosa ho usato:

  • Astro 7 genera HTML statico. JavaScript arriva solo dove serve, cioè nella scena 3D. Il blog è una content collection di file Markdown.
  • Three.js 0.187, con un solo THREE.Points e shader scritti a mano. Niente librerie di effetti, niente post-processing.
  • Lenis per lo scroll fluido su desktop. Sui telefoni resta lo scroll nativo.
  • Due caratteri. Il primo è Bodoni Moda, la versione digitale del carattere che Giambattista Bodoni disegnò a Parma a fine Settecento. Lo uso per i titoli: un sito di un italiano a Dubai deve avere un po’ di Italia. Il secondo è Recursive, per tutto il resto. Ha un asse variabile MONO che passa da proporzionale a monospazio, quindi lo stesso font fa il testo e il terminale.
  • Nessun database, nessun plugin, nessun cookie.

La struttura del progetto

src/
  components/Home.astro        la home con i sei capitoli
  content/blog/*.md            gli articoli migrati
  i18n/home.ts                 testi in italiano e in inglese
  scripts/experience/
    shapes.ts                  le sei forme
    shaders.ts                 vertex e fragment shader
    scene.ts                   renderer, camera, trasformazioni
    main.ts                    scroll e interazioni
    terminal.ts                il terminale
tools/
  migrate-wordpress.mjs        migrazione dal vecchio blog
  build-globe.mjs              i punti di terraferma del globo
public/
  media/                       le immagini del vecchio blog
  _redirects                   i redirect per Cloudflare Pages

Passo 1: il progetto Astro

Astro 7 vuole Node 22.23 o superiore.

mkdir mio-sito && cd mio-sito
npm init -y
npm i astro three lenis @astrojs/sitemap @astrojs/rss \
  @fontsource-variable/bodoni-moda @fontsource-variable/recursive

Il blog è una collezione di file Markdown con uno schema. Se un articolo ha un campo sbagliato, la build si ferma e ti dice quale:

// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    categories: z.array(z.string()).default([]),
    cover: z.string().optional(),
  }),
});

export const collections = { blog };

Passo 2: portare via il blog senza perdere Google

Questa è la parte che conta davvero. Il design lo cambi quando vuoi. Il posizionamento costruito in sette anni, se lo perdi, non torna.

La regola è una: gli articoli devono restare esattamente allo stesso indirizzo. Se un articolo era su /come-creare-un-blog/, deve restare lì, con la barra finale e lo stesso tag canonical. Così per Google non è cambiato niente e non serve nessun redirect.

Ho scritto uno script che usa la REST API di WordPress, che risponde anche senza login:

// tools/migrate-wordpress.mjs (semplificato)
const posts = await getAll('/wp-json/wp/v2/posts?_embed=wp:featuredmedia');

for (const post of posts) {
  const html = clean(post.content.rendered);  // via Easy TOC, classi, attributi lazy-load
  const body = turndown.turndown(html);       // HTML → Markdown
  await writeFile(`src/content/blog/${post.slug}.md`, frontmatter(post) + body);
}

Nella pulizia uso cheerio per togliere quello che i plugin infilano nell’HTML: l’indice di Easy TOC, gli attributi data-lazy-src, le classi di Gutenberg ed Elementor. Poi turndown converte in Markdown. I link interni diventano relativi e le immagini si scaricano in public/media/ con la stessa struttura di wp-content/uploads.

Poi una pagina dinamica di Astro genera ogni articolo alla radice, come faceva WordPress:

---
// src/pages/[slug].astro
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
---

Una lezione che mi è costata un controllo in più. La prima volta avevo scaricato solo le immagini ridimensionate (foto-1024x540.jpg). Ma Google Immagini indicizza gli originali (foto.jpg). Erano 94 file che sarebbero finiti in errore 404. Adesso lo script scarica anche quelli.

Per tutto il resto c’è un file _redirects che Cloudflare Pages legge da solo:

/feed/                           /rss.xml                 301
/wp-content/uploads/*            /media/:splat            301
/category/:cat/page/:n/          /category/:cat/          301
/:slug/feed/                     /:slug/                  301
/tag/*                           /blog/                   301

Poi ho verificato tutto. Ho preso la sitemap di WordPress, cioè le 110 pagine e le 500 immagini che Google conosce. Le ho controllate una per una con wrangler pages dev, che fa girare il sito in locale come farà Cloudflare. Risultato: 109 pagine allo stesso indirizzo, una con redirect 301, tutte le immagini raggiungibili.

Passo 3: una sola nuvola di punti, sei forme

Qui comincia la parte divertente. Il trucco è non animare niente in JavaScript.

All’avvio calcolo sei forme. Ognuna è un Float32Array con le coordinate di tutti i punti. Le carico nella scheda grafica come attributi dello stesso BufferGeometry:

const shapes = buildShapes(N, layout); // sei forme, N punti ciascuna

geometry.setAttribute('position', new BufferAttribute(shapes[0].pos, 3));
for (let i = 1; i < 6; i++) {
  geometry.setAttribute(`aP${i}`, new BufferAttribute(shapes[i].pos, 3));
}

Da lì in poi la CPU non sposta più nessun punto. A ogni frame aggiorna una manciata di numeri, gli uniform: a che punto sei dello scroll, il tempo, la posizione del mouse. Tutto il resto lo calcola il vertex shader, in parallelo, sulla scheda grafica. È per questo che 80.000 punti girano anche su un telefono.

Ogni particella si porta dietro 11 attributi. WebGL2 ne garantisce almeno 16, e Three.js dalla r163 richiede WebGL2.

  • La forma: position e aP1…aP5, dove si trova in ognuna delle sei forme.
  • Il colore: aTintA e aTintB portano sei tinte. Ogni tinta è un numero da 0 a 3 su una palette a tratti: fondale, perla, oro, verde acqua.
  • Il flusso: aFlowA e aFlowB dicono dove sta la particella lungo il percorso dei dati. Servono agli impulsi luminosi.
  • Il caso: aRand contiene quattro numeri casuali per ritardo, dimensione e fase.

Passo 4: disegnare le forme con il codice

Nessun modello 3D. Ogni forma è una funzione.

Il caos è un attrattore di Aizawa: un sistema di tre equazioni differenziali che, integrato passo dopo passo, disegna una sfera di fili attorno a un tubo centrale.

const step = () => {
  const dx = (z - B) * x - D * y;
  const dy = D * x + (z - B) * y;
  const dz = C + A * z - (z * z * z) / 3 - (x * x + y * y) * (1 + E * z) + F * z * x * x * x;
  x += dx * dt;
  y += dy * dt;
  z += dz * dt;
};

Faccio partire 56 traiettorie da punti diversi. Butto i primi 500 passi, perché all’inizio la traiettoria non è ancora sull’attrattore. Poi registro i punti e li normalizzo nel bounding box.

Le altre cinque forme:

  • Rete neurale: 5 strati di nodi disposti su anelli. Il 30% dei punti forma i nodi, il 70% gli archi tra uno strato e l’altro.
  • Workflow: il perimetro dei rettangoli arrotondati più dei connettori a curva di Bézier cubica, come in n8n.
  • Codice: è il codice di un piccolo agente AI che qualifica un lead, spezzato in token con una regex. Ogni token diventa un segmento lungo quanto i suoi caratteri e colorato per tipo: keyword in oro, stringhe in verde acqua, commenti spenti.
  • Pianeta: continenti ottenuti con una somma di seni e una selezione casuale dei punti, anelli a bande e una luna per ogni progetto.
  • Globo: 7.936 punti di terraferma calcolati una volta, in fase di build. Prendo una sfera di Fibonacci, tengo solo i punti che d3-geo trova dentro i confini di Natural Earth e salvo tutto come interi a 16 bit (41 KB). La rotta Italia → Dubai è un’interpolazione sferica tra i due punti, sollevata con sin(πt).

La rete neurale fatta di particelle, con l'impulso d'oro che passa tra gli strati

La seconda forma. L’impulso d’oro è il segnale che passa da uno strato all’altro.

L’ultimo passaggio è quello che fa la magia. Ogni forma viene mescolata:

// Fisher-Yates: ogni particella finisce in un punto a caso della forma
for (let a = n - 1; a > 0; a--) {
  const b = Math.floor(r() * (a + 1));
  // scambia posizione, tinta e flusso di a e b
}

Così la particella numero 1.234 sta in un punto a caso dell’attrattore e in un altro punto a caso della rete neurale. Durante la transizione attraversa lo spazio invece di scivolare ordinata, ed ecco il vortice.

C’è anche un vantaggio nascosto. Siccome l’ordine è casuale, se un dispositivo è lento posso disegnare solo i primi 60% dei punti con setDrawRange. Le forme restano complete, solo meno dense.

Passo 5: la transizione, nel vertex shader

Questo è il cuore di tutto.

float pr  = clamp(uProgress, 0.0, 5.0);   // arriva dallo scroll
float seg = min(floor(pr), 4.0);          // forma di partenza
float t   = pr - seg;                     // 0..1 dentro la transizione

// ogni particella parte con un ritardo diverso
float tt = clamp((t - aRand.x * 0.42) / 0.58, 0.0, 1.0);
tt = tt * tt * (3.0 - 2.0 * tt);          // smoothstep

vec3 pos = mix(shapePos(seg), shapePos(seg + 1.0), tt);

// turbolenza: zero agli estremi, massima a metà strada
float mid = sin(tt * 3.14159);
vec3 q = pos * 0.32 + vec3(0.0, uTime * 0.05, aRand.w * 0.6);
vec3 turb = vec3(snoise(q), snoise(q + vec3(19.1, 0.0, 3.7)), snoise(q + vec3(0.0, 41.3, 11.9)));
pos += turb * (mid * 0.92 + uTurb * 0.45 + 0.028);

Riga per riga:

  • shapePos() è una catena di if. In GLSL non puoi scegliere un attributo con un indice, quindi lo scegli a mano.
  • Il ritardo. Ogni particella parte con un ritardo diverso, fino al 42% della transizione. Il cambio di forma arriva come un’onda, non come uno scatto.
  • La turbolenza è nulla quando la forma è ferma e massima a metà strada. Il rumore è simplex in 3D, calcolato sulla posizione: particelle vicine si muovono insieme. Per questo sembra fumo che scorre e non un tremolio.
  • +0.028 lascia una piccola respirazione costante, così la forma non è mai morta.
  • uTurb aggiunge dispersione quando scorri veloce e poi si spegne da sola.

A metà strada tra due forme, le particelle diventano fumo

A metà transizione: l’attrattore si è sciolto e la rete neurale si sta ancora formando.

Passo 6: collegare lo scroll

Le sezioni della pagina sono normale HTML. Misuro dove finisce ognuna. Attorno a ogni confine c’è una finestra in cui il progresso va da i a i + 1:

const progressAt = (scroll: number) => {
  const m = scroll + vh * 0.5;          // la metà dello schermo
  const before = vh * 0.2;
  const after = vh * 0.4;
  for (let i = 0; i < bounds.length - 1; i++) {
    const b = bounds[i].bottom;
    if (m < b - before) return i;
    if (m <= b + after) return i + (m - (b - before)) / (before + after);
  }
  return bounds.length - 1;
};

Nella scena il valore non arriva di colpo, ma con un po’ di inerzia:

const k = 1 - Math.exp(-dt * 4.2);
this.progress += (this.targetProgress - this.progress) * k;

È un’interpolazione esponenziale che dipende dal tempo trascorso, non dai frame: a 60 o a 120 Hz si muove uguale. E siccome segue lo scroll, se torni indietro le forme si ricompongono al contrario.

Con lo stesso valore interpolo anche posizione, scala e rotazione della forma. Le calcolo dal frustum visibile della camera, così si adattano a qualsiasi schermo: a destra su un monitor, in alto sul telefono.

Il globo con la rotta Italia–Dubai accanto alla sezione dei contatti

L’ultima forma: il globo con la rotta Italia → Dubai. Testo a sinistra, forma a destra, calcolate in base allo schermo.

Un dettaglio su Lenis. Con le impostazioni di base intercetta la rotella del mouse anche sopra i pannelli che scorrono per conto loro, come il terminale. Si sistema con allowNestedScroll: true.

Passo 7: la luce, senza bloom

Il bagliore che vedi non è un effetto di post-processing. È matematica più semplice:

void main(){
  vec2 c = gl_PointCoord - 0.5;
  float d = length(c);
  float a = 1.0 - smoothstep(0.0, 0.5, d);
  a = a * a;
  if (a < 0.01) discard;
  gl_FragColor = vec4(vColor, a * vAlpha);
}

Ogni punto è un disco morbido, e il materiale usa AdditiveBlending con depthWrite disattivato. Dove i punti si sovrappongono la luce si somma, quindi le zone dense brillano da sole. Un passaggio di bloom su un telefono costerebbe troppo.

Lo sfondo è un rettangolo a tutto schermo con un suo shader. Disegna un gradiente color laguna il cui centro segue la forma proiettata sullo schermo, aggiunge un rumore lento, tipo luce sott’acqua, e un leggero dithering per evitare le bande di colore.

Passo 8: impulsi, flusso eseguibile e mouse

Gli impulsi d’oro che corrono sulle forme sono una sola funzione:

float w = fract(s - uTime * speed);
float p = smoothstep(0.0, 0.035, w) * (1.0 - smoothstep(0.035, 0.16, w));

È una banda con il fronte netto e la coda lunga che scorre lungo s. Il trucco è che s cambia significato a ogni forma:

  • Rete neurale: è lo strato, quindi vedi il segnale che attraversa la rete.
  • Workflow: è la profondità dall’alto in basso.
  • Codice: è la riga, e sembra un’esecuzione.
  • Globo: è la posizione sulla rotta Italia → Dubai.

Il workflow mentre "esegue" un lead: il registro avanza e l'impulso scende tra i nodi

Il pulsante “Esegui il flusso” fa scendere un impulso tra i nodi, sincronizzato con il registro.

Il pulsante “Esegui il flusso” aggiunge una gaussiana che scende lungo il percorso in 2,3 secondi, con gli stessi tempi delle righe del registro.

Un bug da cui ho imparato: per accendere il cursore lampeggiante nella forma del codice uso un valore speciale, s > 8. Ma alcune traiettorie dell’attrattore avevano valori di flusso fino a 12,5. Risultato: un quarto delle particelle dell’home lampeggiava in oro una volta al secondo. Se usi valori “sentinella” negli attributi, controlla che i dati veri non ci arrivino mai.

Il mouse fa un raycast sul piano z = 0. Il punto colpito viene portato nelle coordinate locali della nuvola, usando l’inversa della sua matrice, e lo shader spinge via i punti vicini:

vec3 dm = pos - uMouse;
float force = (1.0 - smoothstep(0.0, 1.15, length(dm))) * uMouseStr;
pos += normalize(dm + vec3(0.0001)) * force * 0.5;

Le etichette sopra la scena, i nodi “GoHighLevel” e “WhatsApp”, le lune, “Dubai”, non sono testo 3D. Sono elementi HTML che a ogni frame riposiziono proiettando un punto 3D sullo schermo:

v.set(...anchor).applyMatrix4(points.matrixWorld).project(camera);
const x = (v.x * 0.5 + 0.5) * width;
const y = (-v.y * 0.5 + 0.5) * height;
label.style.transform = `translate3d(${x}px, ${y}px, 0)`;

Restano testo vero: nitido, selezionabile e leggibile dagli screen reader.

Il pianeta con le lune dei progetti e i loro nomi

Le lune sono i miei progetti. I nomi sono etichette HTML agganciate alla scena 3D.

Il codice di un agente AI disegnato a punti, accanto al terminale interattivo

Il codice disegnato dai punti è lo stesso che stampa il terminale con cat agente.ts.

Passo 9: prestazioni, telefoni e accessibilità

Un sito che fa “wow” sul tuo portatile e scatta sul telefono di un cliente è un sito rotto.

  • Punti in base al dispositivo: 80.000 su desktop, 60.000 su computer con pochi core, 34.000 sui telefoni.
  • Qualità adattiva. Dopo 3,5 secondi la scena guarda la mediana dei tempi di frame. Se un frame dura più di 1/28 di secondo, riduce punti e risoluzione. Uso la mediana e non la media perché un iPhone in risparmio energetico è bloccato a 30 fps: non è lento, e non va penalizzato.
  • Canvas alto 100lvh. Sui telefoni la barra del browser appare e scompare mentre scorri. Con l’altezza dinamica la scena cambierebbe proporzioni e la forma saltellerebbe.
  • Scheda in background: quando non è visibile, il loop si ferma.
  • Movimento ridotto: se l’hai attivato nel sistema, niente scroll fluido, niente ingresso animato e impulsi rallentati.
  • Senza WebGL la pagina resta completa, con uno sfondo statico. Tutto il testo è HTML vero, visibile da subito, anche prima che la scena carichi.

Il JavaScript pesa circa 180 KB compressi, quasi tutto Three.js. HTML e CSS arrivano prima, quindi leggi la pagina mentre la scena si prepara.

La versione per telefono: la forma in alto, il testo sotto

Su telefono la forma sta nella metà alta dello schermo e il testo scorre sotto.

Passo 10: SEO, social e assistenti AI

  • Canonical identici a quelli di WordPress, barra finale compresa, più sitemap e RSS.
  • Italiano e inglese: la home esiste in entrambe le lingue (/ e /en/), collegate con hreflang.
  • Dati strutturati: un blocco JSON-LD di tipo Person dice a Google chi sono e dove lavoro.
  • Immagine per i social: è uno screenshot della home a 1200×630. Lo genero con Playwright e uno script di cattura che ferma la scena in uno stato preciso.
  • llms.txt: un file di testo, generato dalla build, che descrive il sito agli assistenti AI con l’elenco di pagine, progetti e articoli. Se fai AI per lavoro, il tuo sito dovrebbe essere leggibile anche da un modello, non solo da Google.

Passo 11: pubblicare

Il sito è una cartella di file statici, quindi va bene qualsiasi hosting. Io ho scelto Cloudflare Pages:

  1. Il progetto va su GitHub.
  2. Su Cloudflare: Workers & Pages → Crea → Pages → collega il repository.
  3. Comando di build npm run build, cartella di output dist, variabile NODE_VERSION=22.
  4. Aggiungi il dominio. I file _redirects e _headers funzionano da soli. Il secondo dà una cache lunga ai file con l’hash nel nome.

Prima di spostare il DNS: backup completo di WordPress, migrazione rilanciata per prendere le ultime modifiche, prova su un sottodominio. Dopo: in Search Console invii la nuova sitemap e attivi “Always Use HTTPS” su Cloudflare.

Cosa mi porto a casa

Un’idea forte batte dieci effetti. “Ogni punto è un dato” regge tutta la pagina. Senza quella frase sarebbero solo particelle carine.

In una migrazione il lavoro vero è la SEO. Nessuno nota un font sbagliato. Google nota 94 immagini sparite.

Un sito statico ti restituisce il tempo. Niente aggiornamenti, niente plugin, niente login da proteggere. Il tempo lo spendo a costruire, che è l’unica cosa che mi interessa.

Se hai domande sul codice o vuoi farne uno simile, scrivimi su LinkedIn o su X.