In the final challenge for the Mapbox Explore Outdoors 2018 challenge we were asked to create a 3D map of our hometown POIs. For this challenge, I created a 3D visualization of the Valley of the 10 Peaks in Banff National Park (aka Reddit Lake).
To get you excited, this is what the final product will look like (gif may take a bit to load)!
First let's create a single view application named ExploreOutdoors. Close XCode, navigate to your project directory and create this podfile.
source 'https://github.com/CocoaPods/Specs.git' | |
project 'ExploreOutdoors.xcodeproj' | |
platform :ios, '11' | |
workspace 'ExploreOutdoors' | |
use_frameworks! | |
target 'ExploreOutdoors' do | |
pod 'Mapbox-iOS-SDK' | |
pod 'MapboxSceneKit', :git => 'https://github.com/mapbox/mapbox-scenekit.git' | |
pod 'MapboxMobileEvents' | |
end |
Open the terminal and run: pod repo update && pod install
Now open up XCode and make sure it compiles. Also, don't forget to add your Mapbox access token to your info.plist
Alright, once everything is compiling we are ready to get coding. Let's start by creating a SceneView.
import UIKit | |
import Mapbox | |
import SceneKit | |
import MapKit | |
import MapboxSceneKit | |
class ViewController: UIViewController { | |
var sceneView: SCNView = SCNView() | |
var scene: SCNScene = SCNScene() | |
let cameraNode = SCNNode() | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
setupSceneView() | |
} | |
func setupSceneView(){ | |
self.view.addSubview(sceneView) | |
sceneView.frame = self.view.bounds | |
sceneView.scene = scene | |
sceneView.allowsCameraControl = true | |
sceneView.isPlaying = true | |
cameraNode.camera = SCNCamera() | |
cameraNode.position = SCNVector3(x: 0, y: 0, z: 3) | |
scene.rootNode.addChildNode(cameraNode) | |
let lightNode = SCNNode() | |
let light = SCNLight() | |
light.type = .ambient | |
light.intensity = 1000 | |
lightNode.light = light | |
scene.rootNode.addChildNode(lightNode) | |
} | |
} |
Setting up a scene is simple, we need a scene, a view to add that scene to and a camera. A camera is a sceneview node and it uses a SCNVector3. This sounds intimidating but X is your horizontal axis (left and right), Y is your vertical access (up and down) and Z is your depth (forward and backwards). We setup our camera to be staring straight at our map and a couple of steps back (that's where we get the Z: 3).
We also add some light to our scene. After we've setup our camera and light we add them to the sceneview with scene.rootNode.addChildNode(cameraNode). You should be able to build and run your app now and see absolutely nothing. HOW EXCITING!
Ok, now that we got that out of the way let's make our scene do something fun that will impress people at parties. I'm going to throw a lot of code at you but don't worry, I'll try my best to explain it!
//Valley of the 10 Peaks | |
//NE & SW Corners | |
var minLat = 51.20 | |
var minLon = -116.28 | |
var maxLat = 51.32 | |
var maxLon = -116.12 | |
var terrainNode: TerrainNode? | |
var terrainNodeScale = SCNVector3(0.0003, 0.0003, 0.0003) // Scale down map (otherwise it's far too big) | |
func createTerrain() { | |
terrainNode = TerrainNode(minLat: minLat, maxLat: maxLat, | |
minLon: minLon, maxLon: maxLon) | |
if let terrainNode = terrainNode { | |
terrainNode.scale = terrainNodeScale // Scale down map | |
terrainNode.geometry?.materials = defaultMaterials() | |
scene.rootNode.addChildNode(terrainNode) | |
//create terrain geometry based on mapbox elevation data | |
terrainNode.fetchTerrainHeights(minWallHeight: 100.0, enableDynamicShadows: true, progress: { progress, total in }, completion: { | |
NSLog("Terrain load complete") | |
}) | |
terrainNode.fetchTerrainTexture("mapbox/satellite-v9", progress: { progress, total in }, completion: { image in | |
NSLog("Texture load complete") | |
//apply the satellite image to the terrain mesh | |
terrainNode.geometry?.materials[4].diffuse.contents = image | |
}) | |
} | |
} | |
// Create default materials for each side of the terrain node | |
func defaultMaterials() -> [SCNMaterial] { | |
let groundImage = SCNMaterial() | |
groundImage.diffuse.contents = UIColor.darkGray | |
groundImage.name = "Ground texture" | |
let sideMaterial = SCNMaterial() | |
sideMaterial.diffuse.contents = UIColor.darkGray | |
sideMaterial.isDoubleSided = true | |
sideMaterial.name = "Side" | |
let bottomMaterial = SCNMaterial() | |
bottomMaterial.diffuse.contents = UIColor.black | |
bottomMaterial.name = "Bottom" | |
return [sideMaterial, sideMaterial, sideMaterial, sideMaterial, groundImage, bottomMaterial] | |
} |
Yikes! Let me explain.
var minLat = 51.20
var minLon = -116.28
var maxLat = 51.32
var maxLon = -116.12
This is just the NW and SE corners latitudes and longitudes of the bounds of our 3D map.
A terrainNode is a special SceneKit node that Mapbox has built which allows us to put a map image onto a 3D Node. We set it up with the bounds of our map using this line : terrainNode = TerrainNode(minLat: minLat, maxLat: maxLat, minLon: minLon, maxLon: maxLon)
Next we scale it down a bit, this is because if the images are too big they take forever to render on a phone. Then we add what the side, bottom and ground will look like for out terrain using this method: terrainNode.geometry?.materials = defaultMaterials() we've picked some lovely tones of gray and black. Next let's add it to the scene with the familiar scene.rootNode.addChildNode(terrainNode).
Now comes the magic! We use the Mapbox function fetchTerrainHeights to grab the 3D heights of the terrain. They've done all the hard work for us! The we use the fetchTerrainTexture to grab the mapbox satellite map of our area and place the images onto our terrain. If you build and run the app now you should get a 3D view of the Valley of the 10 Peaks! How neat is that? That's pretty neat.
Ok, one last thing to do. Let's label each peak and add the labels to the 3D map. Add this method to your ViewController:
func addValleyofTheTenPeaksPOIMarkers() { | |
let locations : [String: (CLLocationDegrees, CLLocationDegrees)] = [ | |
"Moraine Lake": (51.320143, -116.185173), | |
"Mt. Fay": (51.298343, -116.163270), | |
"Mt Little": (51.295878, -116.183472), | |
"Mt Bowlen": (51.299772, -116.188203), | |
"Mt Tonsa": (51.296674, -116.201430), | |
"Mt Perren": (51.296779, -116.208143), | |
"Mt Allen": (51.291698, -116.219711), | |
"Mt Tuzo": (51.301836, -116.227780), | |
"Deltaform":(51.301585, -116.245475), | |
"Neptuak": (51.307604, -116.257772), | |
"Wenkchemna Peak": (51.328359, -116.275496) | |
] | |
if let terrainNode = terrainNode { | |
for location in locations { | |
let markerHeight = CGFloat(50.0) | |
let text = SCNText(string: location.key, extrusionDepth: 1) | |
let material = SCNMaterial() | |
material.diffuse.contents = UIColor.red | |
text.materials = [material] | |
let sphereMarker = SCNNode(geometry: text) | |
sphereMarker.geometry?.firstMaterial?.diffuse.contents = UIColor.red | |
let tempLocation = CLLocation(latitude: location.value.0, longitude: location.value.1) | |
sphereMarker.position = terrainNode.positionForLocation(tempLocation) | |
sphereMarker.position.y += Float(markerHeight / 2.0) + 200 | |
sphereMarker.scale = SCNVector3(x: 11, y: 11, z: 11) | |
terrainNode.addChildNode(sphereMarker) | |
} | |
} | |
} |
This code builds a dictionary of each peak using the peak name and it's GPS coordinates. We create a marker above the map, set the name to the name of the peak and set the text colour to red. We then set it's scale and height above the terrain so it floats above the map. Don't forget to call this method in the completion block of fetchTerrainHeights
//create terrain geometry based on mapbox elevation data | |
terrainNode.fetchTerrainHeights(minWallHeight: 100.0, enableDynamicShadows: true, progress: { progress, total in }, completion: { | |
NSLog("Terrain load complete") | |
//ADD THE POINTS OF INTEREST CALL HERE! | |
self.addValleyofTheTenPeaksPOIMarkers() | |
}) |
That's it! You've made a 3D Map! Let me know if you have any questions.
Final Code
// | |
// ViewController.swift | |
// ExploreOutdoors | |
// | |
// Created by Steven Rockarts on 2018-09-01. | |
// Copyright © 2018 Figure4Software. All rights reserved. | |
// | |
import UIKit | |
import Mapbox | |
import SceneKit | |
import MapKit | |
import MapboxSceneKit | |
class ViewController: UIViewController { | |
//Valley of the 10 Peaks | |
//NE & SW Corners | |
var minLat = 51.20 | |
var minLon = -116.28 | |
var maxLat = 51.32 | |
var maxLon = -116.12 | |
var sceneView: SCNView = SCNView() | |
var scene: SCNScene = SCNScene() | |
var terrainNode: TerrainNode? | |
var terrainNodeScale = SCNVector3(0.0003, 0.0003, 0.0003) // Scale down map (otherwise it's far too big) | |
var trackpoints:[CLLocationCoordinate2D]? | |
let cameraNode = SCNNode() | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
setupSceneView() | |
createTerrain() | |
} | |
func setupSceneView(){ | |
self.view.addSubview(sceneView) | |
sceneView.frame = self.view.bounds | |
sceneView.scene = scene | |
sceneView.allowsCameraControl = true | |
sceneView.isPlaying = true | |
cameraNode.camera = SCNCamera() | |
cameraNode.position = SCNVector3(x: 0, y: 0, z: 3) | |
scene.rootNode.addChildNode(cameraNode) | |
let lightNode = SCNNode() | |
let light = SCNLight() | |
light.type = .ambient | |
light.intensity = 1000 | |
lightNode.light = light | |
scene.rootNode.addChildNode(lightNode) | |
} | |
func createTerrain() { | |
terrainNode = TerrainNode(minLat: minLat, maxLat: maxLat, | |
minLon: minLon, maxLon: maxLon) | |
if let terrainNode = terrainNode { | |
terrainNode.scale = terrainNodeScale // Scale down map | |
terrainNode.geometry?.materials = defaultMaterials() | |
scene.rootNode.addChildNode(terrainNode) | |
//create terrain geometry based on mapbox elevation data | |
terrainNode.fetchTerrainHeights(minWallHeight: 100.0, enableDynamicShadows: true, progress: { progress, total in }, completion: { | |
NSLog("Terrain load complete") | |
self.addValleyofTheTenPeaksPOIMarkers() | |
}) | |
terrainNode.fetchTerrainTexture("mapbox/satellite-v9", progress: { progress, total in }, completion: { image in | |
NSLog("Texture load complete") | |
//apply the satellite image to the terrain mesh | |
terrainNode.geometry?.materials[4].diffuse.contents = image | |
}) | |
} | |
} | |
func addValleyofTheTenPeaksPOIMarkers() { | |
let locations : [String: (CLLocationDegrees, CLLocationDegrees)] = [ | |
"Moraine Lake": (51.320143, -116.185173), | |
"Mt. Fay": (51.298343, -116.163270), | |
"Mt Little": (51.295878, -116.183472), | |
"Mt Bowlen": (51.299772, -116.188203), | |
"Mt Tonsa": (51.296674, -116.201430), | |
"Mt Perren": (51.296779, -116.208143), | |
"Mt Allen": (51.291698, -116.219711), | |
"Mt Tuzo": (51.301836, -116.227780), | |
"Deltaform":(51.301585, -116.245475), | |
"Neptuak": (51.307604, -116.257772), | |
"Wenkchemna Peak": (51.328359, -116.275496) | |
] | |
if let terrainNode = terrainNode { | |
for location in locations { | |
let markerHeight = CGFloat(50.0) | |
let text = SCNText(string: location.key, extrusionDepth: 1) | |
let material = SCNMaterial() | |
material.diffuse.contents = UIColor.red | |
text.materials = [material] | |
let sphereMarker = SCNNode(geometry: text) | |
sphereMarker.geometry?.firstMaterial?.diffuse.contents = UIColor.red | |
let tempLocation = CLLocation(latitude: location.value.0, longitude: location.value.1) | |
sphereMarker.position = terrainNode.positionForLocation(tempLocation) | |
sphereMarker.position.y += Float(markerHeight / 2.0) + 200 | |
sphereMarker.scale = SCNVector3(x: 11, y: 11, z: 11) | |
terrainNode.addChildNode(sphereMarker) | |
} | |
} | |
} | |
// Create default materials for each side of the terrain node | |
func defaultMaterials() -> [SCNMaterial] { | |
let groundImage = SCNMaterial() | |
groundImage.diffuse.contents = UIColor.darkGray | |
groundImage.name = "Ground texture" | |
let sideMaterial = SCNMaterial() | |
sideMaterial.diffuse.contents = UIColor.darkGray | |
sideMaterial.isDoubleSided = true | |
sideMaterial.name = "Side" | |
let bottomMaterial = SCNMaterial() | |
bottomMaterial.diffuse.contents = UIColor.black | |
bottomMaterial.name = "Bottom" | |
return [sideMaterial, sideMaterial, sideMaterial, sideMaterial, groundImage, bottomMaterial] | |
} | |
} | |
Top comments (0)