Swift (SCNCamera) lock Globe rotation around axis

210 Views Asked by At

Source: https://github.com/DominicHolmes/dot-globe

I am trying to make the 3d globe from the repo above lock about the z axis. I want the globe to only rotate horizontally and ignore unwanted rotations. If it is possible I'd like to allow up to -30 degrees of rotation to the bottom of the globe, and 30 degrees of rotation to the top of the globe.

I'm not very skilled with SCNScene or SCNCamera. Currently horizontal swipes also rotate the whole globe instead of spinning it.

In the repo the code below was added in the function setupCamera to prevent unwanted globe rotations. But this does not work.

constraint.isGimbalLockEnabled = true
cameraNode.constraints = [constraint]
sceneView.scene?.rootNode.addChildNode(cameraNode)

I also tried doing this but it also didn't work.

    let constraint = SCNTransformConstraint.orientationConstraint(inWorldSpace: true) { (_, orientation) -> SCNQuaternion in
        // Keep the same orientation around x and z axes, allow rotation around y-axis
        return SCNQuaternion(x: 0, y: orientation.y, z: 0, w: orientation.w)
    }

Here is the code to set up the camera (where these constraints should be added). The rest of the code is in the repository above. Everything relevant to this question and code is in the file linked above.

import SwiftUI
import SceneKit

typealias GenericControllerRepresentable = UIViewControllerRepresentable

@available(iOS 13.0, *)
private struct GlobeViewControllerRepresentable: GenericControllerRepresentable {
   var particles: SCNParticleSystem? = nil
   //@Binding public var showProf: Bool

   func makeUIViewController(context: Context) -> GlobeViewController {
       let globeController = GlobeViewController(earthRadius: 1.0)//, showProf: $showProf
       updateGlobeController(globeController)
       return globeController
   }
   
   func updateUIViewController(_ uiViewController: GlobeViewController, context: Context) {
       updateGlobeController(uiViewController)
   }
   
   private func updateGlobeController(_ globeController: GlobeViewController) {
       globeController.dotSize = CGFloat(0.005)
             
       globeController.enablesParticles = true
       
       if let particles = particles {
           globeController.particles = particles
       }
   }
}

@available(iOS 13.0, *)
public struct GlobeView: View {
   //@Binding public var showProf: Bool
   
   public var body: some View {
       GlobeViewControllerRepresentable()//showProf: $showProf
   }
}

import Foundation
import SceneKit
import CoreImage
import SwiftUI
import MapKit


public typealias GenericController = UIViewController
public typealias GenericColor = UIColor
public typealias GenericImage = UIImage

public class GlobeViewController: GenericController {
   public var earthNode: SCNNode!
   private var sceneView : SCNView!
   private var cameraNode: SCNNode!
   private var worldMapImage : CGImage {
       guard let path = Bundle.module.path(forResource: "earth-dark", ofType: "jpg") else { fatalError("Could not locate world map image.") }
       guard let image = GenericImage(contentsOfFile: path)?.cgImage else { fatalError() }
       return image
   }

   private lazy var imgData: CFData = {
       guard let imgData = worldMapImage.dataProvider?.data else { fatalError("Could not fetch data from world map image.") }
       return imgData
   }()
   
   public var particles: SCNParticleSystem? {
       didSet {
           if let particles = particles {
               sceneView.scene?.rootNode.removeAllParticleSystems()
               sceneView.scene?.rootNode.addParticleSystem(particles)
           }
       }
   }
   
   public init(earthRadius: Double) {
       self.earthRadius = earthRadius
       super.init(nibName: nil, bundle: nil)
   }
   
   public init(earthRadius: Double, dotCount: Int) {
       self.earthRadius = earthRadius
       self.dotCount = dotCount
       super.init(nibName: nil, bundle: nil)
   }
   
   required init?(coder: NSCoder) {
       fatalError("init(coder:) has not been implemented")
   }

   public override func viewDidLoad() {
       super.viewDidLoad()
       setupScene()
       
       setupParticles()
       
       setupCamera()
       setupGlobe()
       
       setupDotGeometry()
   }
   
   private func setupScene() {
       var scene = SCNScene()
       sceneView = SCNView(frame: view.frame)
   
       sceneView.scene = scene

       sceneView.showsStatistics = true
       sceneView.backgroundColor = .black
       sceneView.allowsCameraControl = true
       
       self.view.addSubview(sceneView)
   }
       
   private func setupParticles() {
       guard let stars = SCNParticleSystem(named: "StarsParticles.scnp", inDirectory: nil) else { return }
       stars.isLightingEnabled = false
               
       if sceneView != nil {
           sceneView.scene?.rootNode.addParticleSystem(stars)
       }
   }
   
   private func setupCamera() {
       self.cameraNode = SCNNode()
       cameraNode.camera = SCNCamera()
       cameraNode.position = SCNVector3(x: 0, y: 0, z: 5)
       
       sceneView.scene?.rootNode.addChildNode(cameraNode)
   }

   private func setupGlobe() {
       self.earthNode = EarthNode(radius: earthRadius, earthColor: earthColor, earthGlow: glowColor, earthReflection: reflectionColor)
       sceneView.scene?.rootNode.addChildNode(earthNode)
   }

   private func setupDotGeometry() {
       let textureMap = generateTextureMap(dots: dotCount, sphereRadius: CGFloat(earthRadius))

       let newYork = CLLocationCoordinate2D(latitude: 44.0682, longitude: -121.3153)
       let newYorkDot = closestDotPosition(to: newYork, in: textureMap)

       let dotColor = GenericColor(white: 1, alpha: 1)
       let oceanColor = GenericColor(cgColor: UIColor.systemRed.cgColor)
       let highlightColor = GenericColor(cgColor: UIColor.systemRed.cgColor)

       let threshold: CGFloat = 0.03
       
       let dotGeometry = SCNSphere(radius: dotRadius)
       dotGeometry.firstMaterial?.diffuse.contents = dotColor
       dotGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
       
       let highlightGeometry = SCNSphere(radius: dotRadius * 5)
       highlightGeometry.firstMaterial?.diffuse.contents = highlightColor
       highlightGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
       
       let oceanGeometry = SCNSphere(radius: dotRadius)
       oceanGeometry.firstMaterial?.diffuse.contents = oceanColor
       oceanGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
       
       var positions = [SCNVector3]()
       var dotNodes = [SCNNode]()
       
       var highlightedNode: SCNNode? = nil
       
       for i in 0...textureMap.count - 1 {
           let u = textureMap[i].x
           let v = textureMap[i].y
           
           let pixelColor = self.getPixelColor(x: Int(u), y: Int(v))
           let isHighlight = u == newYorkDot.x && v == newYorkDot.y
           
           if (isHighlight) {
               let dotNode = SCNNode(geometry: highlightGeometry)
               dotNode.name = "NewYorkDot"
               dotNode.position = textureMap[i].position
               positions.append(dotNode.position)
               dotNodes.append(dotNode)

               highlightedNode = dotNode
           } else if (pixelColor.red < threshold && pixelColor.green < threshold && pixelColor.blue < threshold) {
               let dotNode = SCNNode(geometry: dotGeometry)
               dotNode.position = textureMap[i].position
               positions.append(dotNode.position)
               dotNodes.append(dotNode)
           }
       }
       
       DispatchQueue.main.async {
           let dotPositions = positions as NSArray
           let dotIndices = NSArray()
           let source = SCNGeometrySource(vertices: dotPositions as! [SCNVector3])
           let element = SCNGeometryElement(indices: dotIndices as! [Int32], primitiveType: .point)
           
           let pointCloud = SCNGeometry(sources: [source], elements: [element])
           
           let pointCloudNode = SCNNode(geometry: pointCloud)
           for dotNode in dotNodes {
               pointCloudNode.addChildNode(dotNode)
           }
    
           self.sceneView.scene?.rootNode.addChildNode(pointCloudNode)
           
           //this moves the camera to show the top of the earth
           DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
               if let highlightedNode = highlightedNode {
                   self.alignPointToPositiveZ(for: pointCloudNode, targetPoint: highlightedNode.position)
               }
           }
       }
   }

   func alignPointToPositiveZ(for sphereNode: SCNNode, targetPoint: SCNVector3) {
       
       // Compute normalized vector from Earth's center to the target point
       let targetDirection = targetPoint.normalized()
       
       // Compute quaternion rotation
       let up = SCNVector3(0, 0, 1)
       let rotationQuaternion = SCNQuaternion.fromVectorRotate(from: up, to: targetDirection)
       
       sphereNode.orientation = rotationQuaternion
       
   }
   
   typealias MapDot = (position: SCNVector3, x: Int, y: Int)
   
   private func generateTextureMap(dots: Int, sphereRadius: CGFloat) -> [MapDot] {

       let phi = Double.pi * (sqrt(5) - 1)
       var positions = [MapDot]()

       for i in 0..<dots {

           let y = 1.0 - (Double(i) / Double(dots - 1)) * 2.0 // y is 1 to -1
           let radiusY = sqrt(1 - y * y)
           let theta = phi * Double(i) // Golden angle increment
           
           let x = cos(theta) * radiusY
           let z = sin(theta) * radiusY

           let vector = SCNVector3(x: Float(sphereRadius * x),
                                   y: Float(sphereRadius * y),
                                   z: Float(sphereRadius * z))

           let pixel = equirectangularProjection(point: Point3D(x: x, y: y, z: z),
                                                 imageWidth: 2048,
                                                 imageHeight: 1024)

           let position = MapDot(position: vector, x: pixel.u, y: pixel.v)
           positions.append(position)
       }
       return positions
   }
   
   struct Point3D {
       let x: Double
       let y: Double
       let z: Double
   }

   struct Pixel {
       let u: Int
       let v: Int
   }

   func equirectangularProjection(point: Point3D, imageWidth: Int, imageHeight: Int) -> Pixel {
       let theta = asin(point.y)
       let phi = atan2(point.x, point.z)
       
       let u = Double(imageWidth) / (2.0 * .pi) * (phi + .pi)
       let v = Double(imageHeight) / .pi * (.pi / 2.0 - theta)
       
       return Pixel(u: Int(u), v: Int(v))
   }
   
   private func distanceBetweenPoints(x1: Int, y1: Int, x2: Int, y2: Int) -> Double {
       let dx = Double(x2 - x1)
       let dy = Double(y2 - y1)
       return sqrt(dx * dx + dy * dy)
   }
   
   private func closestDotPosition(to coordinate: CLLocationCoordinate2D, in positions: [(position: SCNVector3, x: Int, y: Int)]) -> (x: Int, y: Int) {
       let pixelPositionDouble = getEquirectangularProjectionPosition(for: coordinate)
       let pixelPosition = (x: Int(pixelPositionDouble.x), y: Int(pixelPositionDouble.y))

               
       let nearestDotPosition = positions.min { p1, p2 in
           distanceBetweenPoints(x1: pixelPosition.x, y1: pixelPosition.y, x2: p1.x, y2: p1.y) <
               distanceBetweenPoints(x1: pixelPosition.x, y1: pixelPosition.y, x2: p2.x, y2: p2.y)
       }
       
       return (x: nearestDotPosition?.x ?? 0, y: nearestDotPosition?.y ?? 0)
   }
   
   /// Convert a coordinate to an (x, y) coordinate on the world map image
   private func getEquirectangularProjectionPosition(
       for coordinate: CLLocationCoordinate2D
   ) -> CGPoint {
       let imageHeight = CGFloat(worldMapImage.height)
       let imageWidth = CGFloat(worldMapImage.width)

       // Normalize longitude to [0, 360). Longitude in MapKit is [-180, 180)
       let normalizedLong = coordinate.longitude + 180
       // Calculate x and y positions
       let xPosition = (normalizedLong / 360) * imageWidth
       // Note: Latitude starts from top, hence the `-` sign
       let yPosition = (-(coordinate.latitude - 90) / 180) * imageHeight
       return CGPoint(x: xPosition, y: yPosition)
   }

   private func getPixelColor(x: Int, y: Int) -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
       let data: UnsafePointer<UInt8> = CFDataGetBytePtr(imgData)
       let pixelInfo: Int = ((worldMapWidth * y) + x) * 4

       let r = CGFloat(data[pixelInfo]) / CGFloat(255.0)
       let g = CGFloat(data[pixelInfo + 1]) / CGFloat(255.0)
       let b = CGFloat(data[pixelInfo + 2]) / CGFloat(255.0)
       let a = CGFloat(data[pixelInfo + 3]) / CGFloat(255.0)

       return (r, g, b, a)
   }
}
1

There are 1 best solutions below

11
On BEST ANSWER

I see that your globe is centered in a SCNScene, where the camera is positioned to look at the globe. And you have constraints that are not functioning as expected.

SCNTransformConstraint](https://developer.apple.com/documentation/scenekit/scntransformconstraint) doesn't work in fixing the globes rotation still. And my code already uses an [SCNAction`. Adding it into a group of actions didn't cause it to animate.

After discussion, SNCAction.sequence() can help for the animation part.

If you want to move the camera to a new position and then change its orientation, you would create individual SCNAction instances for each step and then sequence them.

func animateCameraToPositionAndOrientation(targetPosition: SCNVector3, targetOrientation: SCNQuaternion) {
    // Create an action to move the camera to the target position
    let moveAction = SCNAction.move(to: targetPosition, duration: 1.0)

    // Create an action to rotate the camera to the target orientation
    let rotateAction = SCNAction.rotateTo(x: CGFloat(targetOrientation.x), 
                                          y: CGFloat(targetOrientation.y), 
                                          z: CGFloat(targetOrientation.z), 
                                          duration: 1.0)

    // Sequence the actions to move first, then rotate
    let sequenceAction = SCNAction.sequence([moveAction, rotateAction])

    // Run the sequence action on the camera node
    cameraNode.runAction(sequenceAction) {
        print("Camera move and rotate sequence completed")
    }
}

You need a targetPosition and targetOrientation as input. The function first moves the camera to the targetPosition and then rotates it to the targetOrientation. The actions are sequenced, so the rotation will only start after the movement is completed.


Regarding globe's rotation around the z-axis, since the SCNTransformConstraint approach did not work as expected, consider a manual approach to control the rotation. The idea is to intercept the rotation input and apply it within the desired constraints manually.

Assuming the globe's rotation is driven by user interaction (like touch gestures), you need to modify the gesture handling code to apply rotation constraints.
Then:

  • calculate the desired rotation based on user input.
  • apply constraints to make sure the rotation around the z-axis is as desired.
  • update the globe's rotation while keeping it fixed around the z-axis.
func handleRotation(gesture: UIRotationGestureRecognizer) {
    let rotation = Float(gesture.rotation)

    // Assuming `globeNode` is your SCNNode representing the globe
    var currentRotation = globeNode.rotation

    // Modify rotation around y-axis (horizontal rotation)
    currentRotation.y += rotation

    // Keep rotation around z-axis fixed
    currentRotation.z = 0

    // Apply rotation within constraints (e.g., +/- 30 degrees around x-axis)
    currentRotation.x = max(min(currentRotation.x, 0.261799), -0.261799) 

    // Update globe's rotation
    globeNode.rotation = currentRotation

    // Reset the gesture rotation
    gesture.rotation = 0
}

That assumes the rotation gesture changes the y-axis rotation. You will need to adapt it based on how your application interprets user gestures.
The max(min()) function is used to clamp the rotation around the x-axis (tilt) to +/- 30 degrees, represented in radians. The rotation around the z-axis is set to zero, keeping it fixed.
Make sure to reset the gesture's rotation after applying it to avoid cumulative effects.


I was not able to find a solution to lock the rotation. I've tried dozens of things to lock the rotation to horizontal only with +- 30 degrees of vertical rotation. Nothing's working. I wasn't able to implement your solution. Handling a gesture is too complicated. There has to be a way to use the constraints to fix this. I'll add in my full code.

Given the provided full code and the requirement to lock the globe's rotation around the z-axis with horizontal rotation allowed and vertical rotation limited to +/- 30 degrees, the SCNTransformConstraint approach should be revised to better fit the context of your globe setup.
You could try and apply the constraint to the earthNode to control its rotation.

The constraint should be applied directly to the globe node to control its rotation. That is based on the assumption that the earthNode is the node being rotated.
The logic within the constraint should allow rotation around the y-axis (horizontal) while restricting the rotation around the x-axis (vertical) to +/- 30 degrees. The z-axis rotation should be fixed.

For instance:

private func setupGlobe() {
    self.earthNode = EarthNode(radius: earthRadius, earthColor: earthColor, earthGlow: glowColor, earthReflection: reflectionColor)

    let constraint = SCNTransformConstraint.orientationConstraint(inWorldSpace: false) { node, currentOrientation in
        var orientation = currentOrientation
        
        // Clamping rotation around x-axis (vertical)
        orientation.x = max(min(orientation.x, 0.261799), -0.261799) // +/- 30 degrees in radians

        // Fixing rotation around z-axis
        orientation.z = 0

        return orientation
    }
    
    earthNode.constraints = [constraint]
    sceneView.scene?.rootNode.addChildNode(earthNode)
}

The constraint is applied to earthNode. The inWorldSpace parameter is set to false to apply the constraint relative to the node's parent, which is usually the preferred way to apply such constraints in a localized context.
The rotation around the x-axis is clamped to +/- 30 degrees. The rotation around the z-axis is fixed at 0, ensuring no rotation along this axis.

After implementing this constraint, test the behavior of the globe thoroughly. Rotate it manually or through any existing user interaction mechanisms to make sure it adheres to the desired constraints.

If the globe's rotation still does not behave as expected, carefully debug the values of orientation.x, orientation.y, and orientation.z within the constraint block to understand how they are being modified and how the constraint affects them.