Physics from “The Martian”: Simulating the solar system with three.js

Dana Wensberg
18 min readMay 10, 2024

In 2017, I built a simulation of the solar system using Newton’s law of universal gravitation and three.js to render and explore the celestial bodies in the browser. I tapped into the JPL Horizons API to collect initial conditions for all the bodies, then applied Newton’s law of universal gravitation using Euler’s method to calculate accelerations, velocities, and positions, allowing me to propagate the simulation forward or backward through time.

A screenshot of the simulation

The project was inspired by The Martian. The story of The Martian got me wondering about the real-life science behind the fiction. I decided to embrace my curiosity, to follow it, and see where it would lead. And wow, did it take me somewhere amazing!

This project brought together my passions for storytelling, physics, and programming. Working on this solar system simulation was an absolute blast, and I’m incredibly proud to share it with you today.

Check out the code and run the simulation yourself: https://github.com/pint-drinker/solarsystem

Note: I recently revived and cleaned up the project a bit, but the JS code is still pretty rough. I was very inexperienced and did not know React or ES6 at the time of writing it, so please be kind.

Setting the Stage

It was fall 2017, and I was a senior at Trinity College, following an epic software engineering internship at Paperless Parts. I had already signed up to return to Paperless Parts full-time after graduation and was working about 30 hours a week for Paperless during my senior year. Most of my graduation requirements for my engineering and physics degrees were complete, but I had one more to get through to nab the Physics degree. I needed to complete a “Senior Exercise” that demonstrated depth of knowledge in one or more areas of physics.

Project Inspiration

I was struggling to come up with a project idea, as was my classmate, Louis. We sat down at lunch the first day back in the fall and started brainstorming some ideas. We started talking about the movie The Martian, and we recalled a particular scene where Donald Glover’s character describes a way to send the crew’s ship, the Hermes, in a slingshot around Earth and back to Mars to then intercept Matt Damon in a flyby and bring him home to Earth. The line “I’ve done the math…it checks out” is where things clicked.

Louis and I agreed to do complementary projects. I would build a visualization of the solar system and the Hermes ship, and Louis would recreate the orbital mechanics Donald Glover’s character came up with to get the Hermes back to Mars to save Matt Damon. Ideally, our projects could combine before the end of the semester.

Getting started

During my internship at Paperless Parts, I was immersed in a project that involved developing a 3D part viewer. I worked with three.js, a browser library for rendering 3D shapes. I figured I could reuse a ton of code to jump start the solar system project.

The Paperless Parts 3D part viewer I took inspiration from

You can read more about that experience in the article below. It’s a fun one, so definitely check it out!

Despite the head start, there was still a ton to do to create the experience I wanted. The goals for the simulation were:

  • Dynamically calculate accelerations, velocities, and positions by applying Newton’s laws
  • Have the positions, velocities, accelerations, orientations, mass properties, and lighting conditions of all celestial bodies be physically accurate
  • Render all celestial bodies in real time, where the user could specify the time scale
  • Provide an enjoyable exploration experience, being visually appealing and palatable with convenient navigation of the cosmos

I started by looking at other simulations for inspiration. Here were some I found that I ended up borrowing from:

This one was very basic, but simple and intuitive. It also used a polar coordinate system (2D), and I obviously wanted mine to be fully 3D.

https://github.com/msakuta/WebGL-Orbiter

This was an accurate simulation with good UI and good user feedback, but it did not encourage exploration.

http://jeromeetienne.github.io/threex.planets/examples/select.html#Moon

This one was very visually appealing, but had no physics or simulation aspects.

I liked the principle of simplification of the first, the accuracy of the second, and the visuals of the third. Ultimately, I took elements from all three, and created my own.

Finding initial conditions

One of the trickiest parts of creating a solar system is getting the starting conditions just right. If the initial positions, velocities, and masses of the celestial bodies aren’t set accurately, the results can be pretty dramatic — planets might end up crashing into each other or drifting off into the abyss of space.

I stumbled upon NASA’s JPL Horizons system. This incredible, free-to-use tool provides precise ephemerides and physical properties of all major celestial bodies in our solar system. It’s a treasure trove of data, offering calculated positions, velocities, and a host of other details at specific times, all crucial for setting up a realistic simulation.

JPL Horizons is essentially an astronomical database accessible via an API. Users can request data for any celestial body within the solar system, specifying the exact date and time they need the information for. This allowed me to fetch historically accurate data on planetary alignments and velocities. Here is a python script I wrote to fetch information for a given body:

def fetch_body_data(body_id: int, start_date: datetime) -> dict:
"""
Fetch the data for a given body ID and start date.
Look here for more docs on Horizons API: https://ssd-api.jpl.nasa.gov/doc/horizons.html
:param body_id:
:param start_date:
:return:
"""
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
start_date_str = start_date.isoformat()
end_date_str = (start_date + timedelta(minutes=10)).isoformat()
params = {
"format": "json",
"COMMAND": f"'{body_id}'",
"OBJ_DATA": "YES",
"MAKE_EPHEM": "YES",
"EPHEM_TYPE": "VECTORS",
"CENTER": "'@sun'",
"START_TIME": start_date_str,
"STOP_TIME": end_date_str,
"STEP_SIZE": "10m",
"REF_PLANE": "ECLIPTIC",
"OUT_UNITS": "AU-D",
"VEC_TABLE": "3", # 3 for vectors as specified
"VEC_LABELS": "YES",
"CSV_FORMAT": "NO",
"TLIST": None # Not needed unless specific times are required
}

response = requests.get(url, params=params)
return response.json()

Here are the ids and names of the bodies I wanted to simulate:

SUN = dict(id=10, name='Sun', local='sun.txt')
MERCURY = dict(id=199, name='Mercury', local='mercury.txt')
VENUS = dict(id=299, name='Venus', local='venus.txt')
EARTH = dict(id=399, name='Earth', local='earth.txt')
MOON = dict(id=301, name='Moon', local='moon.txt')
MARS = dict(id=499, name='Mars', local='mars.txt')
JUPITER = dict(id=599, name='Jupiter', local='jupiter.txt')
IO = dict(id=501, name='Io', local='io.txt')
EUROPA = dict(id=502, name='Europa', local='europa.txt')
GANYMEDE = dict(id=503, name='Ganymede', local='ganymede.txt')
CALLISTO = dict(id=504, name='Callisto', local='callisto.txt')
SATURN = dict(id=699, name='Saturn', local='saturn.txt')
TITAN = dict(id=606, name='Titan', local='titan.txt')
URANUS = dict(id=799, name='Uranus', local='uranus.txt')
NEPTUNE = dict(id=899, name='Neptune', local='neptune.txt')
TRITON = dict(id=801, name='Triton', local='triton.txt')
PLUTO = dict(id=999, name='Pluto', local='pluto.txt')

Here is an example payload for Mercury:

******************************************************************************
Revised: April 12, 2021 Mercury 199 / 1

PHYSICAL DATA (updated 2024-Mar-04):
Vol. Mean Radius (km) = 2439.4+-0.1 Density (g cm^-3) = 5.427
Mass x10^23 (kg) = 3.302 Volume (x10^10 km^3) = 6.085
Sidereal rot. period = 58.6463 d Sid. rot. rate (rad/s)= 0.00000124001
Mean solar day = 175.9421 d Core radius (km) = ~1600
Geometric Albedo = 0.106 Surface emissivity = 0.77+-0.06
GM (km^3/s^2) = 22031.86855 Equatorial radius, Re = 2440.53 km
GM 1-sigma (km^3/s^2) = Mass ratio (Sun/plnt) = 6023682
Mom. of Inertia = 0.33 Equ. gravity m/s^2 = 3.701
Atmos. pressure (bar) = < 5x10^-15 Max. angular diam. = 11.0"
Mean Temperature (K) = 440 Visual mag. V(1,0) = -0.42
Obliquity to orbit[1] = 2.11' +/- 0.1' Hill's sphere rad. Rp = 94.4
Sidereal orb. per. = 0.2408467 y Mean Orbit vel. km/s = 47.362
Sidereal orb. per. = 87.969257 d Escape vel. km/s = 4.435
Perihelion Aphelion Mean
Solar Constant (W/m^2) 14462 6278 9126
Maximum Planetary IR (W/m^2) 12700 5500 8000
Minimum Planetary IR (W/m^2) 6 6 6
*******************************************************************************


*******************************************************************************
Ephemeris / API_USER Thu May 2 19:29:13 2024 Pasadena, USA / Horizons
*******************************************************************************
Target body name: Mercury (199) {source: DE441}
Center body name: Sun (10) {source: DE441}
Center-site name: BODY CENTER
*******************************************************************************
Start time : A.D. 2015-Jan-04 01:37:00.0000 TDB
Stop time : A.D. 2015-Jan-04 01:47:00.0000 TDB
Step-size : 10 minutes
*******************************************************************************
Center geodetic : 0.0, 0.0, 0.0 {E-lon(deg),Lat(deg),Alt(km)}
Center cylindric: 0.0, 0.0, 0.0 {E-lon(deg),Dxy(km),Dz(km)}
Center radii : 695700.0, 695700.0, 695700.0 km {Equator_a, b, pole_c}
Output units : AU-D
Calendar mode : Mixed Julian/Gregorian
Output type : GEOMETRIC cartesian states
Output format : 3 (position, velocity, LT, range, range-rate)
Reference frame : Ecliptic of J2000.0
*******************************************************************************
JDTDB
X Y Z
VX VY VZ
LT RG RR
*******************************************************************************
$$SOE
2457026.567361111 = A.D. 2015-Jan-04 01:37:00.0000 TDB
X = 3.569339516927043E-01 Y =-1.216565952620585E-01 Z =-4.268757797003043E-02
VX= 3.603833529939107E-03 VY= 2.789699257497791E-02 VZ= 1.948783709787900E-03
LT= 2.191840803391245E-03 RG= 3.795054707835890E-01 RR=-5.772542471321696E-03
2457026.574305556 = A.D. 2015-Jan-04 01:47:00.0000 TDB
X = 3.569589317132349E-01 Y =-1.214628502723925E-01 Z =-4.267403917725223E-02
VX= 3.590411517399457E-03 VY= 2.790156347605340E-02 VZ= 1.950388610257442E-03
LT= 2.191609273002635E-03 RG= 3.794653825395001E-01 RR=-5.772871592397992E-03
$$EOE
*******************************************************************************

To integrate this data into the three.js simulation, I developed a pipeline that converted the above text output from the Horizons system into a global JS variable for each of the bodies. The global JS variable was then used to hydrate three.js objects. It was pretty crude, but worked well. Check out this file for more details on the pipeline.

Building the simulation

The construction of the solar system simulation began with the creation of celestial bodies, each modeled to reflect its real-world counterpart. This step involved several tasks:

  1. Defining mass and radius: Each planet and moon was given properties based on actual astronomical data. Masses were particularly important, as they directly influence the gravitational interactions between bodies.
  2. Texture and image wrapping for realism: To make the planets look lifelike, I applied images and textures that mimicked the surfaces of these celestial bodies. Using three.js, I wrapped these textures around spherical models, creating familiar looking representations.
  3. Hydrating initial conditions: The starting positions and velocities of these bodies were pulled from the JPL Horizons system. This ensured that the simulation began with each planet accurately placed according to a specific point in time, providing a snapshot of the solar system that could evolve from a known state.

Here is a code snippet I used to hydrate the bodies using the ephemeris information from JPL Horizons:

createOrbitalBodies = function() {
// create the bodies from
bodies = {};
for (const [key, bodyObj] of Object.entries(GLOBAL_BODY_DATA)) {
bodies[key] = new OrbitalBody(bodyObj, key);
}

// now add all the moons to their appropriate hosts
bodies.moon.host = bodies.earth;
bodies.io.host = bodies.jupiter;
bodies.europa.host = bodies.jupiter;
bodies.ganymede.host = bodies.jupiter;
bodies.callisto.host = bodies.jupiter;
bodies.titan.host = bodies.saturn;
bodies.triton.host = bodies.neptune;

// set up shadows
if (SHADOWS_ENABLED) {
for (var i in bodies) {
if (bodies[i].name == 'sun') {
bodies[i].body.castShadow = false;
bodies[i].body.receiveShadow = false;
} else if (bodies[i].host) {
bodies[i].body.castShadow = false;
bodies[i].body.receiveShadow = true;
} else {
bodies[i].body.castShadow = true;
bodies[i].body.receiveShadow = false;
}
}
}
return bodies;
}

class OrbitalBody {
constructor(obj, name) {
this.name = name;
this.group = new THREE.Group();

this.mass = obj.mass;
this.radius = obj.radius;
this.up = new THREE.Vector3(0, 0, 1);

// set up a structure to add the two planets and then test it out
this.theta = 0;
this.omega = obj.omega;

// cartesian updates
this.position = new THREE.Vector3(obj.position[0], obj.position[1], obj.position[2]);
this.velocity = new THREE.Vector3(obj.velocity[0], obj.velocity[1], obj.velocity[2]);
this.acceleration = new THREE.Vector3();

// axis tilt, representing a rotation about the z direction in radians
this.obliquity = obj.obliquity;
this.axis = new THREE.Vector3(0, Math.cos(this.obliquity), Math.sin(this.obliquity));

// create the actual body and any moons
this.create_rings = this.create_rings.bind(this);
this.body = this.create(this.name, this.radius);
this.rings = this.create_rings(this.name);
this.group.add(this.body);
if (this.rings) {
this.group.add(this.rings);
}

this.max_points = 500;
this.points = [];

this.host = undefined;

// only initially move it isnt labeled a moon, moons will be moved relative to their hosts
this.move_body();
}

create(name, radius) {
var loader = new THREE.TextureLoader();
if (name == 'earth') {
var geometry = new THREE.SphereGeometry(radius / PLANET_SCALE, 32, 32)
var material = new THREE.MeshPhongMaterial({
map : loader.load('images/earthmap1k.jpg'),
bumpMap : loader.load('images/earthbump1k.jpg'),
bumpScale : 0.05,
specularMap : loader.load('images/earthspec1k.jpg'),
specular : new THREE.Color('grey'),
shininess: 0
})
var mesh = new THREE.Mesh(geometry, material)
return mesh
}
// ...then other bodies
}

Implementing the physics

With the celestial bodies in place, the next phase was implementing the physics that would drive their interactions. Using Newton’s law of gravitation, I calculated the forces exerted between all pairs of objects:

get_acceleration_contribution = function (body1, body2) {
// this is the force of body 2 acting on body 1, and will update both vector wise
var separation_vector = new THREE.Vector3().subVectors(body2.position, body1.position);
var separation = separation_vector.length();
separation_vector.normalize(); // this is the direction of the force from body2 on body 1, so point at body 2
// console.log(separation);
var force = separation_vector.multiplyScalar(G * body1.mass * body2.mass / (separation * separation));
// now add the acceleration to the body accelerations accordingly
body1.acceleration.add(force.clone().multiplyScalar(1 / body1.mass));
body2.acceleration.sub(force.clone().multiplyScalar(1 / body2.mass)); // sub because force acts in other direction
}

Using Euler’s method, I then determined the velocity and position of the body at the next time interval.

class SolarSystem {
// other methods...

updateBodies() {
// perform updating with delegated number of calculations per frame
for (const k = 0; k < NUMBER_OF_CALCULATIONS_PER_FRAME; k++) {
// first need to reset the accelerations of all of the bodies
for (const key in this.bodies) {
this.bodies[key].acceleration.set(0, 0, 0);
}
// resolve all the accelerations
const tracking = new Set();
for (const key1 in this.bodies) {
tracking.add(key1);
for (const key2 in this.bodies) {
if (!tracking.has(key2)) {
get_acceleration_contribution(this.bodies[key1], this.bodies[key2]);
}
}
}
// add any user input accelerations to the hermes
// can only do burns when the time is at real time or slower
if (this.time_factor <= 2.0) {
this.bodies.hermes.update_thrusters(DELTA_T);
}

// now update all the telemetry of all the bodies
for (const key in this.bodies) {
this.bodies[key].update_kinematics(DELTA_T);
}
}
// now move the actual bodies on the screen
for (const key in this.bodies) {
this.bodies[key].move_body();
}
}
}

class OrbitalBody {
// other methods...

update_kinematics(dt) {
// update
this.velocity.add(this.acceleration.clone().multiplyScalar(dt));
this.position.add(this.velocity.clone().multiplyScalar(dt));
// need to resolve local spinning based on local axis, need to make that nice
this.theta += this.omega * dt;
}

move_body() {
if (!this.host) {
var x_comp = this.position.x / DISTANCE_SCALE;
var y_comp = this.position.y / DISTANCE_SCALE;
var z_comp = this.position.z / DISTANCE_SCALE;
this.group.position.set(x_comp, y_comp, z_comp);
} else {
var x_comp = this.host.position.x / DISTANCE_SCALE;
var y_comp = this.host.position.y / DISTANCE_SCALE;
var z_comp = this.host.position.z / DISTANCE_SCALE;
var sep_vec = new THREE.Vector3().subVectors(this.host.position.clone(), this.position.clone());
var fac = 1 / DISTANCE_SCALE * MOON_FAC * this.host.radius / 6371010; // scaling based on siz of planet realtive to earth
this.group.position.set(x_comp + sep_vec.x * fac,
y_comp + sep_vec.y * fac,
z_comp + sep_vec.z * fac);
}
if (this.name == 'sun') {
this.body.rotation.set(0, 0, 0);
this.body.rotateX(Math.PI / 2 - this.obliquity); // for mapping transformation, and matches seasons correctly
this.body.rotateY(this.theta);
} else {
this.group.rotation.set(0, 0, 0);
this.group.rotateX(Math.PI / 2 - this.obliquity); // for mapping transformation, and matches seasons correctly
this.group.rotateY(this.theta);
}
}
}

I then implemented time control. The DELTA_T variable getting referenced in those equations above needed to be controlled to push the simulation forward or backward at a desired rate. I did this using a basic slider UI to control time scale using dat.gui:

The DELTA_T value needed to represent an increment of time for applying Euler’s method. Let’s say the simulation is performing N calculations per frame and there are M frames per second. If the desired time increment for the user is 1 hour per second (3600 seconds per second), the DELTA_T needs to be 3600 sec/ N calculations per frame / M frames per second. The code below shows how I managed this:

var week_dt = 0;
var day_dt = 0;
var hour_dt = 0;
var minute_dt = 0;
var second_dt = 0;
var NUMBER_OF_CALCULATIONS_PER_FRAME = 100;
var DELTA_T = 3600 * 24 / NUMBER_OF_CALCULATIONS_PER_FRAME; // one day
var FRAME_RATE = 60;

// setting up gui
setupTimeControls = function () {
var gui = new dat.GUI();
// time scaling units are in weeks
var parameters = {
week_scale: 0.14, day_scale: 0, hour_scale: 0, minute_scale: 0, second_scale: 0, color: "#ffff00"
};

var top = gui.addFolder('Time Scaling');

var cGUI = top.add(parameters, 'week_scale').min(-52).max(52).step(1).name("Weeks/Sec").listen(); // 1.65 * Math.pow(10, -6)
cGUI.onChange(function (value) {
week_dt = 604800 * value;
DELTA_T = (week_dt + day_dt + hour_dt + minute_dt + second_dt) / NUMBER_OF_CALCULATIONS_PER_FRAME / FRAME_RATE;
});
var dGUI = top.add(parameters, 'day_scale').min(-7).max(7).step(1).name("Days/Sec").listen();
dGUI.onChange(function (value) {
day_dt = 86400 * value;
DELTA_T = (week_dt + day_dt + hour_dt + minute_dt + second_dt) / NUMBER_OF_CALCULATIONS_PER_FRAME / FRAME_RATE;
});
var hGUI = top.add(parameters, 'hour_scale').min(-24).max(24).step(1).name("Hours/Sec").listen();
hGUI.onChange(function (value) {
hour_dt = 3600 * value;
DELTA_T = (week_dt + day_dt + hour_dt + minute_dt + second_dt) / NUMBER_OF_CALCULATIONS_PER_FRAME / FRAME_RATE;
});
var mGUI = top.add(parameters, 'minute_scale').min(-60).max(60).step(1).name("Mins/Sec").listen();
mGUI.onChange(function (value) {
minute_dt = 60 * value;
DELTA_T = (week_dt + day_dt + hour_dt + minute_dt + second_dt) / NUMBER_OF_CALCULATIONS_PER_FRAME / FRAME_RATE;
});
var sGUI = top.add(parameters, 'second_scale').min(-60).max(60).step(1).name("Seconds/Sec").listen();
sGUI.onChange(function (value) {
second_dt = value;
DELTA_T = (week_dt + day_dt + hour_dt + minute_dt + second_dt) / NUMBER_OF_CALCULATIONS_PER_FRAME / FRAME_RATE;
});
}

These sliders let you speed up, slow down, and reverse the direction of the simulation. Messing around with these sliders is easily the most fun part of the experience for me:

Seeing the solar system propagate between -7 days/sec and +7 days/sec

I was at least a hundred hours into the project at this point. I remember the exact moment when everything just worked as I hit the play button. It was probably about 3 AM, and I woke up all 8 of my roommates with a very loud “LET’S F**CKING GO!”

I had hit the play button hundreds of times, watching planets fly off into space or crash into the sun. And then all of a sudden, it just worked. It was an emotional moment for me, a vindicating realization that something as complex as our solar system could be modeled with just one equation. All my physics teachers to date had, in fact, not been lying.

Adjusting scales and lighting

One of the inherent challenges in simulating the solar system is the vastness of space. Realistically scaled, most planets would be barely visible specks. To keep the simulation visually engaging, I had to adjust the scales. Distances between planets were reduced more than their actual sizes, allowing users to appreciate the relative positions of bodies without losing sight of them. The radii of different planets and moons were scaled up or down to ensure they did not overwhelm the scene or become less than a pixel wide.

Lighting the scene was also a fun challenge. I aimed to be as true to reality as possible, with the Sun being a point source of light, but I ended up needing to add some ambient light as well to keep things visible. One of the most enjoyable parts of the project was coding the celestial bodies to respond to the Sun’s light. In three.js, the materials can respond to the light around them depending on their surface characteristics.

The earth and moon respecting the suns lighting conditions

Navigating space

One of the most fun parts of the project was building the navigational experience for the user. For the basics, I used the three.js trackball controls example for mouse and scroll wheel navigation. The real challenge was in letting the user zip around the solar system from planet to planet. I thought it would be awesome to move the user around via an animated path when requesting to see another planet. I used a library called tween.js to gradually update the position and orientation of the camera. This created an animation effect of moving the user from one body to another:

Tweening from the sun to earth to mars

Here is how I created the tween:

makeToTween(pos2, rot2) {
tweening_rot = true;
tweening_tran = true;
trackball.enabled = false;
var tween_rot = new TWEEN.Tween(this.camera.rotation).to({x: rot2.x, y: rot2.y, z: rot2.z}, 500)
.easing(TWEEN.Easing.Linear.None).onComplete(onTweeningRotComplete);
var tween_tran = new TWEEN.Tween(this.camera.position).to({x: pos2.x, y: pos2.y, z: pos2.z}, 2000)
.easing(TWEEN.Easing.Linear.None).onComplete(onTweeningTranComplete);

tween_rot.chain(tween_tran);
tween_rot.start();
}

As a final touch, I wanted to have the background of the scene simulate the starry expanse of the universe. I did that by randomly generating Points objects with white, shiny materials:

createStars = function () {
var radius = 5000;
var i, r = radius, starsGeometry = [new THREE.Geometry(), new THREE.Geometry()];
for (i = 0; i < 250; i++) {
var vertex = new THREE.Vector3();
vertex.x = Math.random() * 2 - 1;
vertex.y = Math.random() * 2 - 1;
vertex.z = Math.random() * 2 - 1;
vertex.multiplyScalar(r);
starsGeometry[0].vertices.push(vertex);
}
for (i = 0; i < 1500; i++) {
var vertex = new THREE.Vector3();
vertex.x = Math.random() * 2 - 1;
vertex.y = Math.random() * 2 - 1;
vertex.z = Math.random() * 2 - 1;
vertex.multiplyScalar(r);
starsGeometry[1].vertices.push(vertex);
}
var stars;
var starsMaterials = [
new THREE.PointsMaterial({color: 0x555555, size: 2, sizeAttenuation: false}),
new THREE.PointsMaterial({color: 0x555555, size: 1, sizeAttenuation: false}),
new THREE.PointsMaterial({color: 0x333333, size: 2, sizeAttenuation: false}),
new THREE.PointsMaterial({color: 0x3a3a3a, size: 1, sizeAttenuation: false}),
new THREE.PointsMaterial({color: 0x1a1a1a, size: 2, sizeAttenuation: false}),
new THREE.PointsMaterial({color: 0x1a1a1a, size: 1, sizeAttenuation: false})
];
var out = [];
for (i = 10; i < 30; i++) {
stars = new THREE.Points(starsGeometry[i % 2], starsMaterials[i % 6]);
stars.rotation.x = Math.random() * 6;
stars.rotation.y = Math.random() * 6;
stars.rotation.z = Math.random() * 6;
stars.scale.setScalar(i * 10);
stars.matrixAutoUpdate = false;
stars.updateMatrix();
out.push(stars.clone());
}
return out
}

With all of this sorted out, and many, many hours spent tweaking things to make them look and feel just right, I was satisfied with my simulation.

Now it came time to try to integrate more of The Martian’s story into the simulation.

Introducing the Hermes

I wanted to try to get a controllable Hermes spacecraft into the scene, one that you could manipulate with keyboard interaction and maybe even prescribe a burn pattern like we saw in the movie to get the ship from Mars to Earth and back again.

A 3D rendering of the Hermes in the browser

I found a site with a 3DS model for the Hermes and used the 3DS loader example to integrate it into a geometry object. I modeled the ship as a solid cylinder and estimated torque and thrust values for the roll, pitch, yaw, and thruster engines based on specifications from the Space Shuttle and the ISS. I then mapped the roll, pitch, yaw, and thrust/reverse thrust commands to keyboard keys for interactive control.

class SpaceShip {
constructor(group, position, velocity) {
this.name = 'hermes';
this.group = group;

this.cockpit_view = false;

this.position = position;
this.velocity = velocity;
this.acceleration = new THREE.Vector3();

this.theta_roll = 0; // about the local central axis, x
this.theta_yaw = 0; // about the local central crossed axis, y
this.theta_pitch = 0; // about the local central cross axis, z
this.theta = new THREE.Vector3();
this.omega = new THREE.Vector3();
this.alpha = new THREE.Vector3();

this.mass = 1000000; // one million kg
this.length = 300; // meters
this.radius = 25; // average radius

this.I_yaw = 1 / 12 * this.mass * this.length * this.length;
this.I_pitch = this.I_yaw;
this.I_roll = 1 / 2 * this.mass * this.radius * this.radius;

this.booster_thrust = 10000000 * 4.44822;
this.brake_thrust = 5000000 * 4.44822;
this.roll_torque = this.I_roll;
this.yaw_torque = this.I_yaw;
this.pitch_torque = this.I_pitch;

this.host = undefined;

this.pointer = new THREE.Vector3(1, 0, 0);

this.move_body();
}

update_thrusters() {
this.alpha.set(0, 0, 0);
if (burn.booster) {
this.acceleration.add(this.pointer.clone().multiplyScalar(this.booster_thrust / this.mass));
}
if (burn.brake) {
this.acceleration.add(this.pointer.clone().multiplyScalar(-this.brake_thrust / this.mass));
}
if (burn.roll_right || burn.yaw_right || burn.pitch_up) {
var dir = new THREE.Vector3(this.roll_torque / this.I_roll * burn.roll_right,
this.yaw_torque / this.I_yaw * burn.yaw_right, this.pitch_torque / this.I_pitch * burn.pitch_up);
this.alpha.add(dir);
}
if (burn.roll_left || burn.yaw_left || burn.pitch_down) {
var dir = new THREE.Vector3(this.roll_torque / this.I_roll * burn.roll_left,
this.yaw_torque / this.I_yaw * burn.yaw_left, this.pitch_torque / this.I_pitch * burn.pitch_down);
this.alpha.sub(dir);
}
}

update_kinematics(dt) {
// get WorldDirection gives you world direction of positive x-axis
this.pointer = this.group.getWorldDirection().normalize().cross(new THREE.Vector3(0, -1, 0));
// update
this.velocity.add(this.acceleration.clone().multiplyScalar(dt));
this.position.add(this.velocity.clone().multiplyScalar(dt));
// need to resolve local spinning based on local axis, need to make that nice
this.omega.add(this.alpha.clone().multiplyScalar(dt));
this.theta.add(this.omega.clone().multiplyScalar(dt));
}

move_body() {
var x_comp = this.position.x / DISTANCE_SCALE;
var y_comp = this.position.y / DISTANCE_SCALE;
var z_comp = this.position.z / DISTANCE_SCALE;
this.group.position.set(x_comp, y_comp, z_comp);
this.group.rotation.set(this.theta.x, this.theta.y, this.theta.z);
}
}

This gave you crude control of the spaceship letting you manipulate the orientation of the craft:

Manipulating the Hermes with the keyboard

Wrapping up the project

Unfortunately, I ran out of time before I could integrate more complex burn mechanics for the Hermes. As a result, I was also unable to incorporate some of the calculations Louis was working on for his senior exercise. Nonetheless, I was really proud of what I had come up with.

After a few really fun months, I put together a presentation and demoed my work in front of the whole department, as well as some friends and classmates. Everyone was really fired up, which translated into a great grade. You can check out the presentation here if you’d like. Below is a picture with my advisor, Dr. Silverman, following the presentation:

Always stay curious

I’ll end with this. Always stay curious. That little voice inside you that asks “why” or “what if” is your greatest tool for learning and discovery. Listening to it can open up a world of possibilities. This project was a chance to follow my curiosity and dive deep into a topic I’m passionate about, and it’s shown me that when you get an opportunity like that, you should definitely take it. So, here’s to curiosity, to asking questions, and to the incredible places it can take us.

--

--