Moon And Earth Simulation on iPhone

Updated: Aug 8


Learn how to make a rough estimate of the moon's orbit around the earth and draw it using iOS Scenekit to make a cool demo App for iPhone!


In this project we are using an Apple iPhone 8.


Watch this video clip to see what you will learn to make in this tutorial:

In this app we can simulate what the earth looks like from the perspective of the moon, view the moon's orbit from afar in space, and get a closeup of the rotating earth -- and in the process we will touch on the topic of parametric equations.


[As an Amazon Associate I earn from qualifying purchases from paid links in the article]

Project Setup

Create a new Xcode Game Project in Swift.


Remove the contents of the art.scnassets directory, and replace with the contents you can download here:


art.scnassets
.zip
Download ZIP • 20.40MB

The folder contains the main scene that will be used for the app, and also .jpg textures available for free at https://www.solarsystemscope.com/Textures.

Contents:

  • MainScene.scn: This is the main scene for the project. The screenshot to the right shows that the scene has already been configured for you in Xcode editor, which is where we assign the background image.

  • earth.jpg: Earth image

  • milky_way.jpg: Background image of stars

  • moon.jpg: Moon image



This is what the file structure looks like in this project. We will be referring to these files in the steps below.



In addition to the files in the art.scnassets directory, there 2 other files we will be editing:


1. AppDelegate.swift - Will contain basic boilerplate code required by Swift, with nothing specific to our project.


2. GameViewController.swift - This is where we will be implementing all of our code specific to this project. Chunks of code will be added in steps as you follow along with the tutorial. If you'd like, you can skip to the final code at the bottom.


First, here is some boilerplate code that needs to go into the file named AppDelegate.swift:

// AppDelegate.swift
// MoonOrbit

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
	var window: UIWindow?
 	func application(_ application: UIApplication,    didFinishLaunchingWithOptions 				      launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
 return true
 }
}

Create a Background of Stars and Set up the Camera

Go to GameViewController.swift and add SCNSceneRendererDelegate to the class definition. This will allow us to add code to the SceneKit rendering loop later on:

class GameViewController: UIViewController, SCNSceneRendererDelegate { .. }

Next, add these three variables right underneath for the scene and camera:

 var sceneView: SCNView!
 var scene: SCNScene!
 var cameraNode: SCNNode! 

Update the function viewDidLoad() to the following:

override func viewDidLoad() {
 super.viewDidLoad()
 setupScene()
 setupCamera(xLoc: 0.0, yLoc: 4.0, zLoc:10.0, radius: 1.0)
 }

Now we have to actually implement the functions setupScene() and setupCamera().

func setupScene() {
   sceneView = self.view as? SCNView
   sceneView.delegate = self as SCNSceneRendererDelegate
   scene = SCNScene(named: "art.scnassets/MainScene.scn")
   sceneView.scene = scene
   let tap = UITapGestureRecognizer(target: self, action:         #selector(self.handleTap(_:)))
   sceneView.addGestureRecognizer(tap)
   view.isUserInteractionEnabled = true
 }

setupScene() does a few boilerplate things, but most importantly refers to "art.scnassets/MainScene.scn", which you may recognize as one of the files you downloaded. This is the main scene, thought of as the canvas, where we are going to do our actual rendering. If you click on MainScene.scn in the project explorer on the left, the editor will appear in Xcode all the way on the right. The Background dropdown refers to our "milky_way.jpg" image we've also placed in the same directory (refer to image above in the Project Setup section of this article).


Next, implement the setupCamera() function so we have a way of actually seeing our scene:

func setupCamera(xLoc: Float, yLoc: Float, zLoc:Float, radius: Float) {
  cameraNode = SCNNode()
  cameraNode.camera = SCNCamera()
  cameraNode.position = SCNVector3(x: xLoc, y: yLoc, z: zLoc)
  cameraNode.camera?.zFar = 500
  cameraNode.eulerAngles = SCNVector3(x: (-20.0 * (Float.pi / 180)), y: 0.0, z: 0.0)
  scene.rootNode.addChildNode(cameraNode)
 }


Now hit play and give it a run in the simulator. This is what we get:

Great! It's empty space. Now since this isn't 4.5 billion years ago, which is prior to when scientists believe the earth was formed, let's place an actual earth there on the canvas.

Creating the Earth

Declare an SCNNode for the earth right under the cameraNode:

 var earthNode: SCNNode!

Update viewDidLoad with:

override func viewDidLoad() {
 super.viewDidLoad()
 setupScene()
 setupCamera(xLoc: 0.0, yLoc: 4.0, zLoc:10.0, radius: 1.0)
 createEarth(xLoc: 0.0, yLoc: 0.4, zLoc:0.0, radius: 3.66)
 }

Implement the createEarth() function:

func createEarth(xLoc: Float, yLoc: Float, zLoc:Float, radius: Float) {
  // Define sphere
  var geometry:SCNGeometry
  geometry = SCNSphere(radius:CGFloat(radius))
  earthNode = SCNNode(geometry: geometry)

  // Cover sphere with earth.jpg
  geometry.materials.first?.diffuse.contents =   ("art.scnassets/earth.jpg")
  // Assign earth position
  earthNode.position = SCNVector3(x: xLoc, y: yLoc, z:zLoc)
  
// Add earth to scene
  scene.rootNode.addChildNode(earthNode)
  
// Rotate earth along the y-axis at rate of 0.5 radians every 15 seconds
 earthNode.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0.0 , y: 0.5, z: 0, duration: 15)))
 }

The function above is commented throughout, where each step is pretty self explanatory. Basically we are drawing the Earth and telling SceneKit to make it rotate......forever.


We have a rotating earth!

Creating the Moon

Now let's add the moon. Declare an SCNNode for the moon right under the declaration for the cameraNode:

var moonNode: SCNNode!

Where should we place the moon? According to NASA the moon is an average of 238,855 miles (384,400 kilometers) away from earth with a radius of 1,080 miles, and the earth's radius is 3,959 miles (6,371 km). This means that the distance between the them in terms of earth's radius is 238,855 / 3,959 = 60.332 earth radiuses. Also, the earth's radius is 3,959 / 1080 = 3.66 times as larges as the moon's.


Based on the ratios computed above, If we set the moon's radius in our simulation to be a value of 1.0, then the Earth's radius is 3.66. And if we place our Earth at the origin along the z-axis, then the moon should be place along the z-axis at a distance of 60.332 earth radius, which is 3.66 X 60 = 221.0. The values are reflected in the arguments below the functions createEarth() and createMoon(), which we also need to define in our class.


Update viewDidLoad with:

override func viewDidLoad() {
 super.viewDidLoad()
 setupScene()
 setupCamera(xLoc: 0.0, yLoc: 4.0, zLoc:226.0, radius: 1.0)
 createEarth(xLoc: 0.0, yLoc: 0.4, zLoc:0.0, radius: 3.66)
 createMoon(xLoc: 0.0, yLoc: 0.4, zLoc:221.0, radius: 1.0)
 }

Implement the createMoon() function:

func createMoon(xLoc: Float, yLoc: Float, zLoc:Float, radius: Float) {
 // moonExists = true
 // Define sphere
 var geometry:SCNGeometry
 geometry = SCNSphere(radius:CGFloat(radius))
 moonNode = SCNNode(geometry: geometry)
 // Cover sphere with moon.jpg
 geometry.materials.first?.diffuse.contents = ("art.scnassets/moon.jpg")
 // Assign moon position
 moonNode.position = SCNVector3(x: xLoc, y: yLoc, z:zLoc)
 // Add moon to scene
 scene.rootNode.addChildNode(moonNode)
 // Rotate moon along the y-axis
 moonNode.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0.0 , y: 0.5, z: 0, duration: 15)))
 }

The createMoon() function does the same thing as createEarth(), only for the moon instead. Note that in the last line where we tell the moon to rotate forever, it is set to the same rate as the earth. This is because the moon spins synchronously with its orbit, so that the same side is more or less always facing the earth.


Now hit run in the Simulator and we have a view from the moon:


Now we want to put these celestial objects in motion. This will require a little bit of math.

Modeling the Moon's motion as a circle

The orbital motion of the moon around the earth contains a certain amount of eccentricity, in that it is not a perfect circle. There are other orbital elements to factor when creating a simulation as well, such as the semi-major axis, inclination, longitude of the ascending node, argument of perigee, and right ascension at epoch --- these elements are referred to and explained in the book Fundamentals of Astrodynamics beginning on page 324. This text was developed at the U.S. Airforce academy and I think it pretty cool to have as reference for some more advanced reading.

Although the moon's orbit around the earth is elliptical, for the sake of simplicity we are going to approximate it as a perfect circle along the same plane as the earth. This means that for every rendering frame in SceneKit we need to update the Moon's position to follow a circular path with the earth at its center. Mathematically, we know that the standard equation for a circle with a radius of 3, for example, and centered at the origin of a 2-dimensional coordinate system is:

But we're interested in calculating the coordinates of the Moon's circular motion as a function of time, t. For this we will use parametric equations instead of the more standard equation shown in the example above.


A Brief Aside on Parametric Equations

These are the parametric equations of a circle for computing the values of x and y with radius r as a functions of t:


x = r cos(t)

y = r sin(t)

As examples, to the right is a 2-dimensional plot of 2 different parametrically defined circles.

(graphed using Maple)


The outer circle, in blue, represents the equations x = 3cos(t) and y = 3sin(t). This circle has a radius of 3.


The inner circle, in red, represents the equations x = 2cos(t) and y = 2sin(t). This circle has a radius of 2.



I think it's natural to use parametric equations instead of the regular equations for this kind of a simulation for the moon's orbit, since we are dealing with an animation where the position of the moon and sun varies according to time, t.


In this case I keep referring to the variable t as time, but mathematically it is actually representing the moon's position along the circle by it's angle from its center. This is why when we implement these equations in the code so we can pre-compute the Moon's coordinates, it's in the range 0 <= t <= 360, so that it makes a complete circle by computing coordinates along all 360 degrees of the circle.


Here is the code where we implement the equations x=221cos(t-90) and y=-221sin(t-90) for t in the range 0 <= t <= 360. We need to subtract 90 degrees from the starting point of t because we want to begin at the bottom of our circle at -90 degrees, placing the moon right in front of the camera with the earth behind it at the center of the circle.


x=221cos(t-90)

let xCoord = radius * cos(Float(degree - 90) * Float.pi / 180)

y=-221sin(t-90)

let yCoord = -1.0 * (radius * sin(Float(degree - 90) * Float.pi / 180))

Place these statements above into a function so we can store the computed orbital values in a data structure moonCoords for later use, then display the computed values in the console so we can see what's going on. Please note that the trigonometric functions cosine and sine expect arguments in radians, not degrees. This is why we have to multiply our degree variable by pi / 180 to convert from degrees to radians.

func setupMoonCoordinates() {
  let radius = Float(221.0)
  for degree in 1...360 {
  // Convert degrees to radians with pi/180
  let xCoord = radius * cos(Float(degree - 90) * Float.pi / 180)
  let yCoord = -1.0 * (radius * sin(Float(degree - 90) * Float.pi / 180))
  moonCoords.append( SCNVector3(x:Float(xCoord), y:Float(0.04), z:Float(yCoord)))
 }
  // Display computed values of the orbit
  print("XYZ Coordinates of Moon Orbit")
  var position = 1;
  for coord in moonCoords{
  print("\(position): X: \(coord.x) Y: \(coord.y) Z: \(coord.z)")
  position += 1
  }
}

Now add this line in the viewDidLoad() function to invoke the function:

 setupMoonCoordinates()

If you run the app after these latest changes, there will be no difference in the display because we haven't implemented the code to actually update the moon's location based off the coordinates stored in moonCoords. Rather, we have only told our app to print out values to the XCode console:

The console will print out the x, y, and z coordinates computed by the setupMoonCoordinates() function. Since we are computing the coordinates of a 2-dimensional circle but then rendering it in 3-dimensional space, we actually want the orbit to go into and out of the screen, which is the z-direction. So our computed values for y are assigned to the z coordinates, and a constant value of 0.04 is assigned for y for every location because this puts it in the same plane as the earth's which is also at y = 0.04.

Making the Moon move along its predefined orbital path

In order to actually see the moon move we need to add logic to the iPhone's rendering loop. But first, here are some helper variables.


Add these to the top of the file right with the other existing class variables:

var frameRate:TimeInterval = 0
var moonExists:Bool = false

Add this code to the bottom of the file, after the GamViewController class definition:

extension GameViewController {
  func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
    if (moonExists == true) {
      if time > frameRate{
       if (degreeIndex >= 360) {
         degreeIndex = 1
       }
     let newLocationMoon = moonCoords[degreeIndex]
     let moonAction = SCNAction.move(to: newLocationMoon, duration: 0.60)
     moonNode.runAction(moonAction)
     frameRate = time + TimeInterval(Float(0.60))
     degreeIndex+=1
    }
   }
  }
}

Lastly, let's temporarily update our camera position and angle:

setupCamera(xLoc: 0.0, yLoc: 4.0, zLoc:260.0, radius: 1.0)

Then in the setupCamera function, update this line with a new y-angle:

 cameraNode.eulerAngles = SCNVector3(x: (-20.0 * (Float.pi / 180)), y: (-25.0 * (Float.pi / 180)), z: 0.0)
 

Give the app a run and you should see this below:


Putting it all together for a final app

Here is the complete code for the final version of the App and a summary of items which have changed:

  1. @objc func handleTap(_ sender: UITapGestureRecognizer) - This function handles user input this way we can alter the game state. This is how the user will toggle between the view of the moon versus the view of the earth.

  2. enum GameStateType {..} - We define an enum data type to handle the different game states.

  3. func setupCameraCoordinates() - This function does the same thing as setupMoonCoordinates(). If we want to track the circular movement of the moon, we must defines a slight larger circle for our camera so that our camera will move in tandum with the moon.


Final Version of GameViewController.swift


// GameViewController.swift
// MoonOrbitTutorial

import UIKit
import QuartzCore
import SceneKit

class GameViewController: UIViewController, SCNSceneRendererDelegate {

 var sceneView: SCNView!
 var scene: SCNScene!
 var cameraNode: SCNNode!
 var frameRate:TimeInterval = 0
 var earthNode: SCNNode!
 var moonNode: SCNNode!
 var moonCoords = [SCNVector3]()
 var cameraCoords = [SCNVector3]()
 var degreeIndex:Int!
 var moonExists:Bool = false
 var newLocationCamera = SCNVector3(0.0,0.0,0.0)
 
 // Game state
 enum GameStateType {
 case moonView
 case earthView
 }
 var state = GameStateType.moonView
 
 override func viewDidLoad() {
 super.viewDidLoad()
 setupScene()
 setupCamera(xLoc: 0.0, yLoc: 8.0, zLoc:235.0, radius: 1.0)
 degreeIndex = 1
 setupMoonCoordinates()
 setupCameraCoordinates()
 createEarth(xLoc: 0.0, yLoc: 0.4, zLoc:0.0, radius: 3.66)
 createMoon(xLoc: 0.0, yLoc: 0.4, zLoc:221.0, radius: 1.0)
 }
 
 func setupScene() {
  sceneView = self.view as? SCNView
  seneView.delegate = self as SCNSceneRendererDelegate
  scene = SCNScene(named: "art.scnassets/MainScene.scn")
  sceneView.scene = scene
  let tap = UITapGestureRecognizer(target: self, action:   #selector(self.handleTap(_:)))
  sceneView.addGestureRecognizer(tap)
  view.isUserInteractionEnabled = true
 }
 
 func setupCamera(xLoc: Float, yLoc: Float, zLoc:Float, radius: Float) {
  cameraNode = SCNNode()
  cameraNode.camera = SCNCamera()
  cameraNode.position = SCNVector3(x: xLoc, y: yLoc, z: zLoc)
  cameraNode.camera?.zFar = 500
  cameraNode.eulerAngles = SCNVector3(x: (-20.0 * (Float.pi / 180)), y: 0.0, z: 0.0)
  scene.rootNode.addChildNode(cameraNode)
 }
 
 func createEarth(xLoc: Float, yLoc: Float, zLoc:Float, radius: Float) {
  // Define sphere
  var geometry:SCNGeometry
  geometry = SCNSphere(radius:CGFloat(radius))
  earthNode = SCNNode(geometry: geometry)
  // Cover sphere with earth.jpg
  geometry.materials.first?.diffuse.contents = ("art.scnassets/earth.jpg")
  // Assign earth position
  earthNode.position = SCNVector3(x: xLoc, y: yLoc, z:zLoc)
  // Add earth to scene
  scene.rootNode.addChildNode(earthNode)
  // Rotate earth along the y-axis
  earthNode.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0.0 , y: 0.5, z: 0, duration: 15)))
 }
 
 func createMoon(xLoc: Float, yLoc: Float, zLoc:Float, radius: Float) {
  // Define sphere
  var geometry:SCNGeometry
  geometry = SCNSphere(radius:CGFloat(radius))
  moonNode = SCNNode(geometry: geometry)
  // Cover sphere with moon.jpg
  geometry.materials.first?.diffuse.contents = ("art.scnassets/moon.jpg")
  // Assign moon position
  moonNode.position = SCNVector3(x: xLoc, y: yLoc, z:zLoc)
  // Add moon to scene
  scene.rootNode.addChildNode(moonNode)
  // Rotate moon along the y-axis
 moonNode.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0.0 , y: 0.5, z: 0, duration: 15)))
 moonExists = true
 }
 
 func setupMoonCoordinates() {
  let radius = Float(221.0)
  for degree in 1...360 {
  // Convert degrees to radians with pi/180
  let xCoord = radius * cos(Float(degree - 90) * Float.pi / 180)
  let yCoord = -1.0 * (radius * sin(Float(degree - 90) * Float.pi / 180))
  moonCoords.append( SCNVector3(x:Float(xCoord), y:Float(0.04), z:Float(yCoord)))
  }
 }
 
 func setupCameraCoordinates() {
  let radius = Float(223.0)
  for degree in 1...360 {
  // Convert degrees to radians with pi/180
  let xCoord = radius * cos(Float(degree - 90) * Float.pi / 180)
  let yCoord = (-1.0 * (radius * sin(Float(degree - 90) * Float.pi / 180)))
  cameraCoords.append( SCNVector3(x:Float(xCoord), y:Float(1.5), z:Float(yCoord)))
  }
 }
 
 @objc func handleTap(_ sender: UITapGestureRecognizer) {

 // Toggle GameState
  if (state == GameStateType.moonView) {
   state = GameStateType.earthView
   var cameraMoveAction:SCNAction!
   let camLocation = SCNVector3(0.0, 3.5, 9.0)
   cameraMoveAction = SCNAction.move(to: camLocation, duration: 3.0)
   cameraNode.runAction(cameraMoveAction)
   cameraNode.eulerAngles = SCNVector3(x: (-20.0 * (Float.pi / 180)), y: 0.0, z: 0.0)
 
 } else if (state == GameStateType.earthView){
   state = GameStateType.moonView
   cameraNode.position = newLocationCamera
   cameraNode.eulerAngles = SCNVector3(x: (-20.0 * (Float.pi / 180)), y: moonNode.eulerAngles.y, z: 0.0)
 }
 }
 
 override var shouldAutorotate: Bool {
  return true
 }
 
 override var prefersStatusBarHidden: Bool {
  return true
 }
 
 override var supportedInterfaceOrientations:  UIInterfaceOrientationMask {
 if UIDevice.current.userInterfaceIdiom == .phone {
 return .allButUpsideDown
 } else {
 return .all
 }
 }
}

extension GameViewController {
  func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
  if (moonExists == true) {
   if time > frameRate{
    if (degreeIndex >= 360) {
      degreeIndex = 1
    }
   let newLocationMoon = moonCoords[degreeIndex]
   var cameraAction:SCNAction!
   var cameraRotateAction:SCNAction!
   newLocationCamera = cameraCoords[degreeIndex]
   if (state == GameStateType.moonView) {
     cameraAction = SCNAction.move(to: newLocationCamera, duration: 0.60)
    cameraRotateAction = SCNAction.rotate(by: CGFloat(1.0 * (Float.pi / 180)), around: SCNVector3(x: 0.0 , y: 0.01, z: 0), duration: 0.50)
    cameraNode.runAction(cameraRotateAction)
    cameraNode.runAction(cameraAction)
   }
  let moonAction = SCNAction.move(to: newLocationMoon, duration: 0.60)
   moonNode.runAction(moonAction)
   frameRate = time + TimeInterval(Float(0.60))
   degreeIndex+=1
   }
  }
 }
}