Skip to content

Instantly share code, notes, and snippets.

@1ec5
Last active February 21, 2024 07:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save 1ec5/30bf0e583b50f6d539399dc97355b5ca to your computer and use it in GitHub Desktop.
Save 1ec5/30bf0e583b50f6d539399dc97355b5ca to your computer and use it in GitHub Desktop.
SceneKit ❤️ Mapbox Maps SDK
@import Cocoa;
@interface AppDelegate : NSObject <NSApplicationDelegate>
@end
#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
@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
#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
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@end
#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