settembre 30, 2021 · Javascript Space

Disegnare orbite con ThreeJS

Come promesso nelle scorse settimane, provo a mettere assieme l'esperimento di disegno di un pianeta con ThreeJS con l'esperimento di calcolo della propagazione di un'orbita, e vi racconto come ho disegnato questa animazione.

NB: su alcuni cellulari l'animazione non viene visualizzata. In tal caso potete usare questo link per vederla in una nuova pagina.

Cerco di fornire una spiegazione ad alto livello dei passaggi, rifacendomi a quando spiegato nei post linkati e saltando le cose meno interessanti. Per chi è interessato il codice completo è scaricabile da GitHub.

Per prima cosa impostiamo un pò di costanti:

// Fattore di scala per disegno oggetti.
const scaleFactor = 10 / 6371000;
// Durata step calcoli fisici [ms]
const phisicsCalcStep = 300
// Universal gravitation constant
export const G = 6.67e-11;
// Durata step simulazione propagazione [s]
const simStepSize = 100;
// Numero step simulazione propagazione
const simStepNumber = 10000;

Il fattore di scala si applica al disegno di tutti gli oggetti per evitare di usare dimensioni enormi nel canvas (è calcolato per fare si che la Terra abbia un raggio di 10 unità). La durata step calcoli fisici è il tempo in millisecondi che intercorre tra ciascun ricalcolo della scena (per non ricalcolarla alla velocità a cui il browser tenta di ridisegnarla, che sarebbe troppo). La costante di gravitazione universale si commenta da sola. Il numero degli step di simulazione è il numero di propagazioni che vengono fatte per disegnare la traiettoria futura del satellite, e la dimensione è il tempo in secondi che intercorre tra ciascuno di questi step.

Rispetto a quanto visto nel post precedente, l'oggetto Body è un pò più ricco. Abbiamo aggiunto alcune proprietà (raggio del corpo celeste e riferimento alla sua mesh). Abbiamo anche aggiunto una funzione di clonazione (per fare una copia di un oggetto prima di simularne la propagazione) e una funzione per calcolare comodamente la posizione iniziale di un oggetto in orbita dati distanza, inclinazione e azimuth.

import Vector from './vector.js'

export default class Body{
  
  constructor(name){
    this.name = name;
    this.mass = 0;
    this.radius = 0;
    this.SOIRadius = 0;
    this.position = new Vector();
    this.velocity = new Vector();
    this.mesh = null;
  }
  
  /**
  * Calcola la posizione di un satellite.
  * @param {*} height Altezza sopra la superficie [m]
  * @param {*} inclination Inclinazione [°]
  * @param {*} azimuth Azimuth [°]
  * @returns 
  */
  calcSatellitePosition(height, inclination, azimuth){
    let r = height + this.radius;
    let theta = 2 * Math.PI * inclination / 360;
    let phi = 2 * Math.PI * azimuth / 360;
    let x = r * Math.cos(phi) * Math.sin(theta);
    let y = r * Math.sin(phi) * Math.sin(theta);
    let z = r * Math.cos(theta);
    return this.position.add(new Vector(x, y, z));
  }
  
  clone(){
    let newBody = new Body(this.name);
    newBody.name = this.name;
    newBody.mass = this.mass;
    newBody.radius = this.radius;
    newBody.SOIRadius = this.SOIRadius;
    newBody.position = this.position.clone();
    newBody.velocity = this.velocity.clone();
    newBody.mesh = null;
    return newBody;
  }
  
}

Predisponiamo una funzione di comodo per posizionare le mesh dei corpi celesti nella scena, tenendo in considerazione il fattore di scala.

/**
 * Set the position of the mesh.
 * @param {Body} body Corpo da posizionare.
 * @param {number} [myScaleFactor] Fattore di scala.
 */
 function setMeshPosition(body, myScaleFactor = scaleFactor){
  body.mesh.position.x = body.position.x * myScaleFactor;
  body.mesh.position.y = body.position.y * myScaleFactor;
  body.mesh.position.z = body.position.z * myScaleFactor;
}

Predisponiamo anche una funzione per inizializzare la linea che useremo per disegnare l'orbita del satellite.

/**
* Inizializza la mesh lineare per disegnare la propagazione di una orbita.
* @param {*} scene
* @param {*} simSize Numero di step.
* @param {*} color Colore.
* @returns 
*/
function buildLineMesh(scene, simSize, color = 'red'){
  
  // Create mesh with fake points, because the BufferGeometry has to be
  // initialized with the right size.
  const newSimPoints = []
  for (let i = 0; i < simSize; i++){
    newSimPoints.push(new THREE.Vector3(0,0,0));
  }
  const simGeometry = new THREE.BufferGeometry().setFromPoints(newSimPoints);
  const simMaterial = new THREE.LineBasicMaterial({ 
    color : color
  });
  
  const mesh = new THREE.Line( simGeometry, simMaterial );
  mesh.visible = false;
  scene.add(mesh);
  
  return mesh;
  
}

A questo punto creiamo la Terra e il satellite con le modalità viste nel post precedente. Le uniche differenze sono che ora andremo a settare un raggio per entrambi gli oggetti, e andremo a creare la mesh. Inoltre posizioniamo la mesh appena creata nella scena. Per finire, predisponiamo anche la linea per disegnare l'orbita, usando il metodo descritto prima.

// --- Earth ---

var earth = new Body('Earth');
...
// Costruzione mesh
let earthGeometry = new THREE.SphereGeometry( 
  earth.radius * scaleFactor, 
  50, 
  50 
);
let earthMaterial = new THREE.MeshPhongMaterial({
  map: new THREE.ImageUtils.loadTexture("./images/2k_earth_daymap.jpg"),
  color: 0xaaaaaa,
  specular: 0x333333,
  shininess: 5
});
earth.mesh = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth.mesh);
setMeshPosition(earth);

// --- Ship ---

var ship = new Body('Ship');
...
var shipGeometry = new THREE.SphereGeometry(
  200000 * scaleFactor, 
  50, 
  50 
);
var shipMaterial = new THREE.MeshPhongMaterial({
  color: 0xaaaaaa
});
ship.mesh = new THREE.Mesh(shipGeometry, shipMaterial);
scene.add(ship.mesh);
setMeshPosition(ship);

const shipLineMesh = buildLineMesh(scene, 10000, 'green');

A questo punto non ci resta che implementare il metodo di rendering.

// Variabili per timekeeping (inizializzazione)
let lastIteration = Date.now();
let spentTime = 0;
let sinceLastPhysicsCalc = 0;

var render = function (actions) {
  
  // Checking time
  spentTime = Date.now() - lastIteration;
  sinceLastPhysicsCalc += spentTime;
  lastIteration = Date.now();	
  
  // Step calcoli fisici
  if (sinceLastPhysicsCalc > phisicsCalcStep){
    
    // Rotate earth
    earth.mesh.rotation.y += 2 * Math.PI / (24*60*60*1000) * sinceLastPhysicsCalc;
    
    // Propagate ship status
    let shipRes = propagate(ship.position, ship.velocity, [earth], sinceLastPhysicsCalc / 1000)
    ship.position = shipRes[0]
    ship.velocity = shipRes[1]
    setMeshPosition(ship);
    
    // Resetta tempo calcolo fisica
    sinceLastPhysicsCalc = 0;
    
    // Refresh orbit propagation
    let shipSim = ship.clone();
    for (let step = 0; step < simStepNumber; step++){
      
      let shipRes = propagate(shipSim.position, shipSim.velocity, [earth], simStepSize)
      
      shipSim.position = shipRes[0]
      shipSim.velocity = shipRes[1]
      
      let posArray = shipLineMesh.geometry.getAttribute('position').array;	
      posArray[step*3] = shipSim.position.x*scaleFactor;
      posArray[step*3+1] = shipSim.position.y*scaleFactor;
      posArray[step*3+2] = shipSim.position.z*scaleFactor;
      
    }
    shipLineMesh.geometry.setDrawRange(0, simStepNumber)
    shipLineMesh.geometry.attributes.position.needsUpdate = true;		
    shipLineMesh.visible = true;	
    
  }
  
  resizeRendererToDisplaySizeIfNeeded(renderer);
  renderer.render(scene, camera);
  stats.update()
  requestAnimationFrame(render);
  
};

Il metodo di render viene eseguito continuamente alla velocità che più aggrada al browser. Ad ogni esecuzione registriamo il tempo trascorso dall'ultimo ricalcolo (sinceLastPhysicsCalc), e quando questo è superiore al nostro step desiderato (la costante impostata all'inizio) effettuiamo i ricalcoli.

Per prima cosa propaghiamo il satellite nel tempo trascorso, e settiamo la nuova posizione.

Successivamente ricalcoliamo l'orbita. Partiamo da un clone del satellite nello stato corrente e lo propaghiamo un numero di volte pari al numero degli step desiderati, ogni volta applicando il tempo di propagazione desiderato. Ad ogni ricalcolo salviamo le coordinate xyz (scalate) nella posizione della mesh che rappresenta la linea; per il modo in cui funziona ThreeJS le coordinate sono salvate sequenzialmente (xyzxyzxyz...).

Ecco fatto!

Come contenuto bonus possiamo anche configurare l'interfaccia per modificare la velocità.

let simData = {
  deltaV: 2000
}
ship.velocity = ship.velocity.norm().scale(7650 + simData.deltaV);
const gui = new GUI()
const maneuverFolder = gui.addFolder("Ship maneuver")
maneuverFolder.add(simData, "deltaV", 0, +5000, 1).onChange((val) => {
  ship.velocity = ship.velocity.norm().scale(7650 + val);
})
maneuverFolder.open()

Non è molto sensata (ricalcola come percentuale della velocità iniziale, il che va bene all'inizio della simulazione ma non dopo un pò di tempo poichè in un'orbita la velocità tende a diminuire mentre ci si allontana dal corpo centrale), ma per il nostro esperimento va più che bene.

Spero di essere riuscito a spiegarmi decentemente. Ad ogni modo, il codice completo di questo esempio è disponibile come detto all'inizio del post.

Al prossimo esperimento!