Three.js è una fantastica libreria javascript per il rendering interattivo in 2D e 3D su HTML5. Qualche giorno fa mi sono trovato a voler riprodurre dei fittizi sistemi stellari ed ho deciso di riportare di seguito un esempio semplificato per chiunque volesse provare da se. Il risultato che vogliamo ottenere è il seguente (zoom con rotellina del mouse, tasto sinistro per ruotare, tasto destro per spostare):
Già che c’ero ho inserito anche un paio di esempi per confronto con sistemi stellari realistici: in particolare il nostro sistema solare con i suoi otto pianeti principali in proporzione, una seconda versione con i pianeti ingranditi, ma proporzionati sempre al Sole ed infine il sistema Glise 581 sempre in proporzione corretta.
Inutile dire che si tratti di una semplificazione estrema (le orbite sono circolari, anziché essere ellittiche, e giacciono tutte sul medesimo piano), ma è sufficiente a darci un’idea della vastità dello spazio. 🙂
Detto questo mettiamoci all’opera.
Anzitutto procuriamoci Three.js e, già che ci siamo, jQuery (non è indispensabile, ma mi divertirò ad unirli insieme).
Adesso creiamo il contenuto della nostra pagina index.htm
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
<!doctype html> <html lang="it"> <head> <link rel="stylesheet" href="css/main.css"> <title>Pianeti con Three.js</title> </head> <body> <div class="pianeti" id="pianeti"></div> <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script> <script src="js/three.min.js"></script> <script src="js/controls/OrbitControls.js"></script> <script src="js/three.interaction.js"></script> <script src="js/main.js"></script> </body> </html> |
Creiamo anche un file main.css per il foglio di stile ed uno main.js per il codice javascript.
Colleghiamo le librerie jQuery, three.min.js, OrbitControls.js e three.interaction.js.
Infine inseriamo un div che farà da contenitore per il nostro rendering.
|
<div class="pianeti" id="pianeti"></div> |
Potremmo usare anche un canvas, ma Three.js ci permette di aggiungere un rendering anche al comune DOM in HTML.
Fatto tutto questo sistemiamo velocemente il CSS inserendo il seguente codice:
|
.pianeti { width: 800px; height: 800px; background-color: #000; } |
Inutile dire che ci potremmo divertire a personalizzarlo nei modi più disparati, ma per il momento è quanto ci basta per creare un riquadro nero di 800px di lato.
Fatto tutto questo possiamo cominciare a lavorare sullo script. Definiamo anzitutto il minimo indispensabile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
|
$( document ).ready(function() { var camera, scene, renderer, controls, interaction; init(); animate(); function init() { var w = $(".pianeti").width(); var h = $(".pianeti").height(); camera = new THREE.PerspectiveCamera( 60, w / h, 0.00001, 10000 ); camera.position.z = 10; scene = new THREE.Scene(); // qui inseriremo ulteriori oggetti renderer = new THREE.WebGLRenderer( { antialias: true } ); renderer.setSize( w, h ); $( ".pianeti" ).append( renderer.domElement ); controls = new THREE.OrbitControls( camera, renderer.domElement ); controls.maxDistance = 500; interaction = new THREE.Interaction(renderer, scene, camera); } function animate() { requestAnimationFrame( animate ); renderer.render( scene, camera ); } }); |
In questo modo abbiamo definito la camera, la scena (è dove aggiungeremo tutti gli oggetti), il renderer che produrrà l’elemento DOM da aggiungere al suddetto div, i controlli che ci permetteranno di muovere la telecamera rispetto alla scena e il sistema di interazione (per sviluppi futuri).
Per far funzionare il tutto creiamo una funzione chiamata init(), il nome è comunque a piacere, che lanciamo all’inizio, ed una funzione animate() che richiamiamo subito dopo.
Grazie a requestAnimationFrame( animate ) animiamo tutto il sistema. Dopo approfondiremo.
Intanto cominciamo disegnando un singolo corpo nello spazio (banalmente una sfera).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
function aggOggetto(x,y,z,dimensione) { var geometry, material, mesh; geometry = new THREE.SphereGeometry( dimensione, 24, 24 ); material = new THREE.MeshBasicMaterial({ color: 0x3333ff, wireframe: true }); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh ); mesh.position.set(x, y, z); mesh.rotation.x = 0.5 * Math.PI; pianeti.push( mesh ); } |
Per poter gestire l’oggetto appena creato aggiungiamolo al vettore pianeti che dichiariamo come variabile globale.
Per aggiungere un grosso oggetto al centro ci sarà sufficiente quindi usare, dentro la funzione init() alla riga evidenziata:
Gli oggetto saranno aggiunti su un piano cartesiano tridimensionale, di coordinate x, y e z. La posizione (0,0,0) è quindi all’origine degli assi del piano cartesiano. La telecamera punta direttamente verso questo origine ad una distanza sull’asse z di 10.
Proviamo ad aggiungere una seconda sfera con:
A questo punto vogliamo aggiungere anche il cerchio dell’orbita, che appunto non è altro che una circonferenza (nel nostro caso semplificato eh!):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
function aggOrbita(r) { var segmentCount = 64, radius = r, geometry = new THREE.Geometry(), material = new THREE.LineBasicMaterial({ color: 0xffffff }); for (var i = 0; i <= segmentCount; i++) { var theta = (i / segmentCount) * Math.PI * 2; geometry.vertices.push( new THREE.Vector3( Math.cos(theta) * radius, Math.sin(theta) * radius, 0)); } scene.add(new THREE.Line(geometry, material)); } |
La nostra funzione accetta come argomento il raggio dell’orbita, che sarà disegnata centrata in (0,0,0).
La circonferenza è composta di 64 segmenti che collegano altrettanti vertici. Creiamo quindi la geometria nella qual inseriamo le coordinate dei vertici, il tutto sarà poi riempito con il materiale di tipo LineBasicMaterial.
Integriamo la funzione in quella per creare i singoli pianeti, utilizzando un argomento apposito per poter decidere se aggiungerla o meno.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
function aggOggetto(x,y,z,dimensione,orbita) { var geometry, material, mesh; geometry = new THREE.SphereGeometry( dimensione, 24, 24 ); material = new THREE.MeshBasicMaterial({ color: colore, wireframe: true }); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh ); mesh.position.set(x, y, z); mesh.rotation.x = 0.5 * Math.PI; pianeti.push( mesh ); if( orbita ) { aggOrbita( Math.sqrt(x*x + y*y) ); } } |
Con Math.sqrt(x*x + y*y) calcoliamo il raggio per ciascuna orbita, ossia la distanza del corpo sul piano xy rispetto all’origine degli assi.
Adesso proviamo ad aggiungere i seguenti pianeti:
|
aggOggetto(0,0,0,2,false); aggOggetto(2,1.6,0,0.2,true); aggOggetto(4,3,0,0.1,true); aggOggetto(7,6,0,0.15,true); aggOggetto(13,16,0,0.5,true); |
Dovremmo poter vedere un sistema statico, simile a quello dell’esempio.
Adesso cominciamo ad animarlo, preparando anzitutto la funzione che farà ruotare i pianeti. Per farlo ruotare utilizziamo delle coordinate polari, dove ci sarà sufficiente aumentare l’angolo theta.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
function ruota( m , massa ) { r = Math.sqrt( m.position.x * m.position.x + m.position.y * m.position.y ); if( r > 0 ) { t = Math.atan( m.position.y / m.position.x ); if( m.position.x < 0 && m.position.y >= 0 ) t += Math.PI; if( m.position.x < 0 && m.position.y < 0 ) t += Math.PI; if( m.position.x >= 0 && m.position.y < 0 ) t += 2*Math.PI; t += Math.sqrt( massa * 0.0001 / r ); m.position.x = r * Math.cos( t ); m.position.y = r * Math.sin( t ); } } |
La funzione ruota accetta un oggetto m e un valore per la “massa”, che ci permetterà di fare variazioni sulla velocità globale del sistema. Dal momento che la velocità dei pianeti varia con l’inverso della distanza, secondo la seguente formula:
Possiamo approssimare il moto angolare con una variazione di theta come segue:
|
t += Math.sqrt( massa * 0.0001 / r ); |
Quindi applichiamo il movimento per ogni singolo corpo creando una specifica funzione:
|
function animaPianeti() { for(i = 0; i < pianeti.length; i++ ) { pianeti[i].rotation.y += 0.01; ruota( pianeti[i] , 1 ); } } |
In questo modo facciamo due cose. Con:
|
pianeti[i].rotation.y += 0.01; |
Facciamo ruotare i corpi attorno a se stessi (rotazione).
Con:
Li facciamo ruotare attorno alla stella (rivoluzione).
Aggiorniamo infine la funzione animate():
|
function animate() { requestAnimationFrame( animate ); animaPianeti(); renderer.render( scene, camera ); } |
Infine aggiungiamo lo sfondo stellato generando dei punti casuali nello spazio tutto intorno con:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
function aggSfondoStellato() { var geometria = new THREE.Geometry(); var minDist = 100; for ( var i = 0; i < 10000; i ++ ) { var star = new THREE.Vector3(); star.x = minDist + THREE.Math.randFloatSpread( 2000 ); star.y = minDist + THREE.Math.randFloatSpread( 2000 ); star.z = minDist + THREE.Math.randFloatSpread( 2000 ); geometria.vertices.push( star ); } var materiale = new THREE.PointsMaterial( { color: 0xffffff } ); var campo = new THREE.Points( geometria, materiale ); scene.add( campo ); } |
Ed impostiamo la telecamera di base:
|
function setStartCamera() { r = 2*Math.sqrt( camera.position.x * camera.position.x + camera.position.y * camera.position.y + camera.position.z * camera.position.z ); t = -Math.PI / 2.1; f = 9*Math.PI / 20; camera.position.x = r * Math.cos( t ) * Math.sin( f ); camera.position.y = r * Math.sin( t ) * Math.sin( f ); camera.position.z = r * Math.cos( f ); } |
In questo modo posizioniamo la telecamere con l’inquadratura leggermente inclinata e di lato rispetto al sistema.
L’intero script che utilizziamo è il seguente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
|
$( document ).ready(function() { var camera, scene, renderer, controls, interaction; var pianeti = []; var colore = 0x3333ff; init(); animate(); function init() { var w = $(".pianeti").width(); var h = $(".pianeti").height(); camera = new THREE.PerspectiveCamera( 60, w / h, 0.00001, 10000 ); camera.position.z = 10; scene = new THREE.Scene(); aggSfondoStellato(); aggOggetto(0,0,0,2,false); aggOggetto(2,1.6,0,0.2,true); aggOggetto(4,3,0,0.1,true); aggOggetto(7,6,0,0.15,true); aggOggetto(13,16,0,0.5,true); setStartCamera(); renderer = new THREE.WebGLRenderer( { antialias: true } ); renderer.setSize( w, h ); $( ".pianeti" ).append( renderer.domElement ); controls = new THREE.OrbitControls( camera, renderer.domElement ); controls.maxDistance = 500; interaction = new THREE.Interaction(renderer, scene, camera); } function animate() { requestAnimationFrame( animate ); animaPianeti(); renderer.render( scene, camera ); } function aggSfondoStellato() { var geometria = new THREE.Geometry(); var minDist = 100; for ( var i = 0; i < 10000; i ++ ) { var star = new THREE.Vector3(); star.x = minDist + THREE.Math.randFloatSpread( 2000 ); star.y = minDist + THREE.Math.randFloatSpread( 2000 ); star.z = minDist + THREE.Math.randFloatSpread( 2000 ); geometria.vertices.push( star ); } var materiale = new THREE.PointsMaterial( { color: 0xffffff } ); var campo = new THREE.Points( geometria, materiale ); scene.add( campo ); } function aggOggetto(x,y,z,dimensione,orbita) { var geometry, material, mesh; geometry = new THREE.SphereGeometry( dimensione, 24, 24 ); material = new THREE.MeshBasicMaterial({ color: colore, wireframe: true }); mesh = new THREE.Mesh( geometry, material ); scene.add( mesh ); mesh.position.set(x, y, z); mesh.rotation.x = 0.5 * Math.PI; pianeti.push( mesh ); if( orbita ) { aggOrbita( Math.sqrt(x*x + y*y) ); } } function aggOrbita(r) { var segmentCount = 64, radius = r, geometry = new THREE.Geometry(), material = new THREE.LineBasicMaterial({ color: 0xffffff }); for (var i = 0; i <= segmentCount; i++) { var theta = (i / segmentCount) * Math.PI * 2; geometry.vertices.push( new THREE.Vector3( Math.cos(theta) * radius, Math.sin(theta) * radius, 0)); } scene.add(new THREE.Line(geometry, material)); } function ruota( m , massa ) { r = Math.sqrt( m.position.x * m.position.x + m.position.y * m.position.y ); if( r > 0 ) { t = Math.atan( m.position.y / m.position.x ); if( m.position.x < 0 && m.position.y >= 0 ) t += Math.PI; if( m.position.x < 0 && m.position.y < 0 ) t += Math.PI; if( m.position.x >= 0 && m.position.y < 0 ) t += 2*Math.PI; t += Math.sqrt( massa * 0.0001 / r ); m.position.x = r * Math.cos( t ); m.position.y = r * Math.sin( t ); } } function animaPianeti() { for(i = 0; i < pianeti.length; i++ ) { pianeti[i].rotation.y += 0.01; ruota( pianeti[i] , 1 ); } } function setStartCamera() { r = 2*Math.sqrt( camera.position.x * camera.position.x + camera.position.y * camera.position.y + camera.position.z * camera.position.z ); t = -Math.PI / 2.1; f = 9*Math.PI / 20; camera.position.x = r * Math.cos( t ) * Math.sin( f ); camera.position.y = r * Math.sin( t ) * Math.sin( f ); camera.position.z = r * Math.cos( f ); } }); |
Se abbiamo fatto tutto bene otterremo il risultato di sopra.