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

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 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.Pointse 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
MONOche 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:
positioneaP1…aP5, dove si trova in ognuna delle sei forme. - Il colore:
aTintAeaTintBportano sei tinte. Ogni tinta è un numero da 0 a 3 su una palette a tratti: fondale, perla, oro, verde acqua. - Il flusso:
aFlowAeaFlowBdicono dove sta la particella lungo il percorso dei dati. Servono agli impulsi luminosi. - Il caso:
aRandcontiene 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-geotrova 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 consin(πt).

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 diif. 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.028lascia una piccola respirazione costante, così la forma non è mai morta.uTurbaggiunge dispersione quando scorri veloce e poi si spegne da sola.

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.

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 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.

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

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.

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 conhreflang. - Dati strutturati: un blocco JSON-LD di tipo
Persondice 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:
- Il progetto va su GitHub.
- Su Cloudflare: Workers & Pages → Crea → Pages → collega il repository.
- Comando di build
npm run build, cartella di outputdist, variabileNODE_VERSION=22. - Aggiungi il dominio. I file
_redirectse_headersfunzionano 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.