Last active
February 21, 2024 07:50
-
-
Save 1ec5/30bf0e583b50f6d539399dc97355b5ca to your computer and use it in GitHub Desktop.
SceneKit ❤️ Mapbox Maps SDK
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import Cocoa; | |
@interface AppDelegate : NSObject <NSApplicationDelegate> | |
@end | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "AppDelegate.h" | |
#import "SceneStyleLayer.h" | |
@import Mapbox; | |
@interface AppDelegate () <MGLMapViewDelegate> | |
@property (weak) IBOutlet NSWindow *window; | |
@property (weak) IBOutlet MGLMapView *mapView; | |
@end | |
@implementation AppDelegate | |
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { | |
self.mapView.camera = [MGLMapCamera cameraLookingAtCenterCoordinate:CLLocationCoordinate2DMake(48.8582602, 2.29449905431968) | |
altitude:600 | |
pitch:60 | |
heading:210]; | |
} | |
- (void)mapView:(MGLMapView *)mapView didFinishLoadingStyle:(MGLStyle *)style { | |
// Hide the building footprint layer. | |
MGLFillStyleLayer *footprintLayer = (MGLFillStyleLayer *)[style layerWithIdentifier:@"building"]; | |
footprintLayer.visible = NO; | |
// Duplicate the building footprint layer but as an extruded layer. | |
MGLSource *streetsSource = [style sourceWithIdentifier:footprintLayer.sourceIdentifier]; | |
MGLFillExtrusionStyleLayer *extrusionLayer = [[MGLFillExtrusionStyleLayer alloc] initWithIdentifier:@"buildings" source:streetsSource]; | |
extrusionLayer.sourceLayerIdentifier = footprintLayer.sourceLayerIdentifier; | |
extrusionLayer.fillExtrusionColor = footprintLayer.fillColor; | |
// Extrude prismatic buildings. | |
extrusionLayer.predicate = [NSPredicate predicateWithFormat:@"type == 'building' AND extrude == 'true'"]; | |
extrusionLayer.fillExtrusionHeight = [NSExpression expressionForKeyPath:@"height"]; | |
extrusionLayer.fillExtrusionBase = [NSExpression expressionForKeyPath:@"min_height"]; | |
extrusionLayer.fillExtrusionOpacity = [NSExpression expressionForConstantValue:@0.75]; | |
// Insert the extruded building layer above the topmost layer that isn’t a symbol layer. | |
for (MGLStyleLayer *layer in style.layers.reverseObjectEnumerator) { | |
if (![layer isKindOfClass:[MGLSymbolStyleLayer class]]) { | |
[style insertLayer:extrusionLayer aboveLayer:layer]; | |
break; | |
} | |
} | |
// Insert a scene layer above the plain extruded building layer. | |
SceneStyleLayer *eiffelTowerLayer = [[SceneStyleLayer alloc] initWithIdentifier:@"eiffel"]; | |
[style insertLayer:eiffelTowerLayer aboveLayer:extrusionLayer]; | |
} | |
- (void)mapViewDidFinishLoadingMap:(MGLMapView *)mapView { | |
// Import a life-sized Eiffel Tower model. | |
// https://3dmr.eu/model/4 | |
SCNScene *towerScene = [SCNScene sceneNamed:@"eiffel.scn" inDirectory:@"Assets.scnassets" options:nil]; | |
SCNNode *towerNode = towerScene.rootNode.childNodes.firstObject; | |
// Rotate the model by 45 degrees to align with the map. | |
towerNode.rotation = SCNVector4Make(0, 1, 0, M_PI_4); | |
// Place the Eiffel Tower model on the map. | |
SceneStyleLayer *eiffelTowerLayer = (SceneStyleLayer *)[mapView.style layerWithIdentifier:@"eiffel"]; | |
[eiffelTowerLayer addChildNode:towerNode | |
centeredAtCoordinate:CLLocationCoordinate2DMake(48.8582602, 2.29449905431968)]; | |
} | |
@end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import Mapbox; | |
@import SceneKit; | |
NS_ASSUME_NONNULL_BEGIN | |
@interface SceneStyleLayer : MGLOpenGLStyleLayer | |
/** | |
The SceneKit scene rendered by the style layer. | |
*/ | |
@property (nonatomic, readonly) SCNScene *scene; | |
/** | |
Adds a node to the world scene, positioning the node at the given coordinate. | |
*/ | |
- (void)addChildNode:(SCNNode *)node centeredAtCoordinate:(CLLocationCoordinate2D)centerCoordinate; | |
@end | |
NS_ASSUME_NONNULL_END |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "SceneStyleLayer.h" | |
#if TARGET_OS_IOS | |
@import UIKit; | |
#else | |
@import Cocoa; | |
#endif | |
static const CGFloat FIELD_OF_VIEW = 0.6435011087932844; // TransformState::fov | |
static const double EARTH_RADIUS_M = 6378137; | |
static const double LATITUDE_MAX = 85.051128779806604; | |
static const double LONGITUDE_MAX = 180; | |
// GL intensity of 0.5 is equivalent to SCNLight.intensity of 1000 lumens. | |
static const CGFloat UNMODULATED_LIGHT_INTENSITY_FACTOR = 2000; | |
#define CONSTANT_EXPRESSION_VALUE(expr, default) (expr.expressionType == NSConstantValueExpressionType ? expr.constantValue : default) | |
static double clamp(double value, double min_, double max_) { | |
return MAX(min_, MIN(max_, value)); | |
} | |
// mbgl::Projection::projectedMetersForLatLng() | |
static CGVector ProjectedMetersForCoordinate(CLLocationCoordinate2D coordinate) { | |
const double constrainedLatitude = clamp(coordinate.latitude, -LATITUDE_MAX, LATITUDE_MAX); | |
const double constrainedLongitude = clamp(coordinate.longitude, -LONGITUDE_MAX, LONGITUDE_MAX); | |
const double m = 1 - 1e-15; | |
const double f = clamp(sin(MGLRadiansFromDegrees(constrainedLatitude)), -m, m); | |
const double easting = EARTH_RADIUS_M * MGLRadiansFromDegrees(constrainedLongitude); | |
const double northing = 0.5 * EARTH_RADIUS_M * log((1 + f) / (1 - f)); | |
return CGVectorMake(easting, northing); | |
} | |
@interface SceneStyleLayer () | |
@property (nonatomic, readwrite) SCNScene *scene; | |
@property (nonatomic) SCNNode *cameraNode; | |
@property (nonatomic) SCNNode *focusNode; | |
@property (nonatomic) SCNRenderer *renderer; | |
@end | |
@implementation SceneStyleLayer | |
- (instancetype)initWithIdentifier:(NSString *)identifier { | |
if (self = [super initWithIdentifier:identifier]) { | |
_scene = [SCNScene scene]; | |
_scene.background.contents = MGLColor.clearColor; | |
_focusNode = [SCNNode node]; | |
[_scene.rootNode addChildNode:_focusNode]; | |
// Configure a camera with parameters that match mbgl::TransformState. | |
SCNCamera *camera = [SCNCamera camera]; | |
camera.fieldOfView = MGLDegreesFromRadians(FIELD_OF_VIEW); | |
camera.zNear = 1.0; | |
_cameraNode = [SCNNode node]; | |
_cameraNode.camera = camera; | |
[_focusNode addChildNode:_cameraNode]; | |
// Apply the same ambient light as fill_extrusion.vertex.glsl. | |
SCNLight *ambientLight = [SCNLight light]; | |
ambientLight.type = SCNLightTypeAmbient; | |
ambientLight.intensity = 0.03 * UNMODULATED_LIGHT_INTENSITY_FACTOR; | |
SCNNode *ambientLightNode = [SCNNode node]; | |
ambientLightNode.light = ambientLight; | |
[_cameraNode addChildNode:ambientLightNode]; | |
// TODO: Clip the scene to a floor representing the ground. | |
// https://stackoverflow.com/questions/31082950/scenekit-clip-to-bounds-equivalent#comment50198970_31082950 | |
} | |
return self; | |
} | |
- (void)addChildNode:(SCNNode *)childNode centeredAtCoordinate:(CLLocationCoordinate2D)centerCoordinate { | |
// Wrap the node in a container node that transforms to a Spherical Mercator coordinate system. | |
SCNNode *containerNode = [SCNNode node]; | |
[containerNode addChildNode:childNode]; | |
CGVector centerProjectedMeters = ProjectedMetersForCoordinate(centerCoordinate); | |
containerNode.position = SCNVector3Make(centerProjectedMeters.dx, 0, -centerProjectedMeters.dy); | |
// The whole scene is laid out in projected meters, but imported models are | |
// in an unprojected physical coordinate system. | |
CLLocationDistance projectionScale = 1.0 / cos(MGLRadiansFromDegrees(centerCoordinate.latitude)); | |
containerNode.scale = SCNVector3Make(projectionScale, projectionScale, projectionScale); | |
[self.renderer.scene.rootNode addChildNode:containerNode]; | |
} | |
- (void)didMoveToMapView:(MGLMapView *)mapView { | |
[super didMoveToMapView:mapView]; | |
[self applyStyle:mapView.style]; | |
#if TARGET_OS_IOS | |
SCNRenderer *renderer = [SCNRenderer rendererWithContext:self.context options:nil]; | |
#else | |
NSOpenGLLayer *layer = (NSOpenGLLayer *)mapView.layer; | |
SCNRenderer *renderer = [SCNRenderer rendererWithContext:layer.openGLContext.CGLContextObj options:nil]; | |
#endif | |
renderer.scene = self.scene; | |
self.renderer = renderer; | |
} | |
/** | |
Adapts the scene to match the given style as closely as possible. | |
*/ | |
- (void)applyStyle:(MGLStyle *)style { | |
MGLLight *styleLight = style.light; | |
// TODO: Evaluate non-constant expressions in light properties. | |
SCNLight *omniLight = [SCNLight light]; | |
omniLight.type = SCNLightTypeOmni; | |
omniLight.color = CONSTANT_EXPRESSION_VALUE(styleLight.color, MGLColor.whiteColor); | |
CGFloat styleLightIntensity = [CONSTANT_EXPRESSION_VALUE(styleLight.intensity, @0.5) doubleValue]; | |
omniLight.intensity = styleLightIntensity * UNMODULATED_LIGHT_INTENSITY_FACTOR; | |
MGLSphericalPosition styleLightPosition = [CONSTANT_EXPRESSION_VALUE(styleLight.position, @(MGLSphericalPositionMake(1.15, 210, 30))) MGLSphericalPositionValue]; | |
CGFloat lightDirection = -MGLRadiansFromDegrees(styleLightPosition.azimuthal + 90); | |
CGFloat lightPitch = MGLRadiansFromDegrees(styleLightPosition.polar); | |
SCNNode *omniLightNode = [SCNNode node]; | |
omniLightNode.light = omniLight; | |
// Maximum texture size, used to calculate cos(θ) in fill_extrusion.vertex.glsl. | |
CLLocationDistance omniLightDistance = 16384 * styleLightPosition.radial; | |
SCNVector3 lightPosition = SCNVector3Make(sin(lightDirection) * sin(lightPitch) * omniLightDistance, | |
cos(lightPitch) * omniLightDistance, | |
-cos(lightDirection) * sin(lightPitch) * omniLightDistance); | |
omniLightNode.position = lightPosition; | |
if ([CONSTANT_EXPRESSION_VALUE(styleLight.anchor, @"viewport") isEqualToString:@"map"]) { | |
[self.cameraNode addChildNode:omniLightNode]; | |
} else { | |
[self.focusNode addChildNode:omniLightNode]; | |
} | |
} | |
- (void)drawInMapView:(MGLMapView *)mapView withContext:(MGLStyleLayerDrawingContext)context { | |
[super drawInMapView:mapView withContext:context]; | |
SCNNode *focusNode = self.focusNode; | |
SCNNode *cameraNode = self.cameraNode; | |
// Place the focus node at the center of the map. | |
CGVector projectedMeters = ProjectedMetersForCoordinate(context.centerCoordinate); | |
focusNode.position = SCNVector3Make(projectedMeters.dx, 0, -projectedMeters.dy); | |
// The whole scene is laid out uniformly by projected meters. | |
CLLocationDistance metersPerPoint = [mapView metersPerPointAtLatitude:0]; | |
// Calculate the distance from the camera viewpoint to the focal point | |
// (center of the view) and farthest visible point (top-center of the view), | |
// based on the calculations in mbgl::TransformState::getProjMatrix(). | |
double halfFov = FIELD_OF_VIEW / 2.0; | |
double groundAngle = M_PI_2 + context.pitch; | |
double cameraToCenterDistance = 0.5 * CGRectGetHeight(mapView.bounds) / tan(FIELD_OF_VIEW / 2.0); | |
double topHalfSurfaceDistance = sin(halfFov) * cameraToCenterDistance / sin(M_PI - groundAngle - halfFov); | |
double furthestDistance = cos(M_PI_2 - context.pitch) * topHalfSurfaceDistance + cameraToCenterDistance; | |
cameraNode.camera.zFar = furthestDistance * 1.01 * metersPerPoint; | |
// Offset the camera relative to the focus node, as if there’s a gimbal lock | |
// on the focus node. | |
CLLocationDistance viewingDistance = cameraToCenterDistance * metersPerPoint; | |
CGFloat direction = -MGLRadiansFromDegrees(context.direction); | |
cameraNode.position = SCNVector3Make(sin(direction) * sin(context.pitch) * viewingDistance, | |
cos(context.pitch) * viewingDistance, | |
cos(direction) * sin(context.pitch) * viewingDistance); | |
// By default, GL looks ahead at the map, while this scene looks down at the ground. | |
cameraNode.eulerAngles = SCNVector3Make(context.pitch - M_PI_2, direction, 0); | |
// TODO: Drive animation on a timer. | |
[self.renderer renderAtTime:CFAbsoluteTimeGetCurrent()]; | |
} | |
- (void)willMoveFromMapView:(MGLMapView *)mapView { | |
[super willMoveFromMapView:mapView]; | |
self.renderer = nil; | |
} | |
@end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import <UIKit/UIKit.h> | |
@interface ViewController : UIViewController | |
@end | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "ViewController.h" | |
#import "SceneStyleLayer.h" | |
@import Mapbox; | |
@import SceneKit; | |
@interface ViewController () <MGLMapViewDelegate> | |
@property (weak, nonatomic) IBOutlet MGLMapView *mapView; | |
@end | |
@implementation ViewController | |
- (void)viewDidLoad { | |
[super viewDidLoad]; | |
self.mapView.camera = [MGLMapCamera cameraLookingAtCenterCoordinate:CLLocationCoordinate2DMake(48.8582602, 2.29449905431968) | |
altitude:600 | |
pitch:60 | |
heading:210]; | |
} | |
- (void)mapView:(MGLMapView *)mapView didFinishLoadingStyle:(MGLStyle *)style { | |
// Hide the building footprint layer. | |
MGLFillStyleLayer *footprintLayer = (MGLFillStyleLayer *)[style layerWithIdentifier:@"building"]; | |
footprintLayer.visible = NO; | |
// Duplicate the building footprint layer but as an extruded layer. | |
MGLSource *streetsSource = [style sourceWithIdentifier:footprintLayer.sourceIdentifier]; | |
MGLFillExtrusionStyleLayer *extrusionLayer = [[MGLFillExtrusionStyleLayer alloc] initWithIdentifier:@"extrusions" source:streetsSource]; | |
extrusionLayer.sourceLayerIdentifier = footprintLayer.sourceLayerIdentifier; | |
extrusionLayer.fillExtrusionColor = footprintLayer.fillColor; | |
// Extrude prismatic buildings. | |
extrusionLayer.predicate = [NSPredicate predicateWithFormat:@"type == 'building' AND extrude == 'true'"]; | |
extrusionLayer.fillExtrusionHeight = [NSExpression expressionForKeyPath:@"height"]; | |
extrusionLayer.fillExtrusionBase = [NSExpression expressionForKeyPath:@"min_height"]; | |
extrusionLayer.fillExtrusionOpacity = [NSExpression expressionForConstantValue:@0.75]; | |
// Insert the extruded building layer above the topmost layer that isn’t a symbol layer. | |
for (MGLStyleLayer *layer in style.layers.reverseObjectEnumerator) { | |
if (![layer isKindOfClass:[MGLSymbolStyleLayer class]]) { | |
[style insertLayer:extrusionLayer aboveLayer:layer]; | |
break; | |
} | |
} | |
// Insert a scene layer above the plain extruded building layer. | |
SceneStyleLayer *eiffelTowerLayer = [[SceneStyleLayer alloc] initWithIdentifier:@"eiffel"]; | |
[style insertLayer:eiffelTowerLayer aboveLayer:extrusionLayer]; | |
} | |
- (void)mapViewDidFinishLoadingMap:(MGLMapView *)mapView { | |
// Import a life-sized Eiffel Tower model. | |
// https://3dmr.eu/model/4 | |
SCNScene *towerScene = [SCNScene sceneNamed:@"eiffel.scn" inDirectory:@"Assets.scnassets" options:nil]; | |
SCNNode *towerNode = towerScene.rootNode.childNodes.firstObject; | |
// Rotate the model by 45 degrees to align with the map. | |
towerNode.rotation = SCNVector4Make(0, 1, 0, M_PI_4); | |
// Place the Eiffel Tower model on the map. | |
SceneStyleLayer *eiffelTowerLayer = (SceneStyleLayer *)[mapView.style layerWithIdentifier:@"eiffel"]; | |
[eiffelTowerLayer addChildNode:towerNode | |
centeredAtCoordinate:CLLocationCoordinate2DMake(48.8582602, 2.29449905431968)]; | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment