Skip to content

Instantly share code, notes, and snippets.

@AchrafKassioui
Last active April 15, 2024 23:26
Show Gist options
  • Save AchrafKassioui/bd835b99a78e9ce29b08ce406896c59b to your computer and use it in GitHub Desktop.
Save AchrafKassioui/bd835b99a78e9ce29b08ce406896c59b to your computer and use it in GitHub Desktop.
A setup to control SpriteKit camera with gestures

Intuitive camera control in SpriteKit

I'm implementing multi-touch camera control in SpriteKit using UIKit gesture recognizers, and I have troubles making pan and rotate work intuitively together.

From a user interface perspective, the goal is:

  • A pan gesture moves the scene as you'd expect, adjusting for camera rotation.
  • A pinch gesture scales the scene as you'd expect, with the camera zooming in and out from the midpoint of the gesture, not the view center.
  • A rotation gesture should rotate the scene as if it were a piece of paper that you manipulate with two fingers. The gesture midpoint at the start of the rotation defines the rotation pivot.

I have code for each of these gestures, and each one works well separately. Pan and pinch work well together. Pinch and rotate work well together. However, combining panning and rotating has been a frustrating challenge.

Here are screen recordings:

We can see the problem here:

Notice how the rotation pivot (red dot) drifts from the rotation gesture midpoint (blue disc). Instead, the red dot should be the point that is dragged around the screen.

In the rotateCamera logic, we define a rotation pivot when the gesture starts, then when the gesture changes, we rotate the camera around that point, and then we adjust the camera position to give the impression that it is rotating around that pivot. This logic works well if we do not pan.

In the panCamera logic, we get the translation from the gesture, and we adjust it according to the camera rotation. This logic also works well if we do not rotate.

The issue is when these two are combined together. How can we make it so panning drags the rotation pivot around, in order to get the impression that we are panning and rotating a solid piece of paper on screen?

Below is the code in full.

Gesture recognizers setup:

// MARK: - Gesture recognizers

func setupGestureRecognizers(in view: SKView) {
    let pinchRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
    let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
    let rotationRecognizer = UIRotationGestureRecognizer(target: self, action: #selector(handleRotationGesture(_:)))
    
    pinchRecognizer.delegate = self
    panRecognizer.delegate = self
    rotationRecognizer.delegate = self
    
    /// this prevents the recognizer from cancelling basic touch events once a gesture is recognized
    /// In UIKit, this property is set to true by default
    pinchRecognizer.cancelsTouchesInView = false
    panRecognizer.cancelsTouchesInView = false
    rotationRecognizer.cancelsTouchesInView = false
    
    panRecognizer.maximumNumberOfTouches = 2
    
    view.addGestureRecognizer(pinchRecognizer)
    view.addGestureRecognizer(panRecognizer)
    view.addGestureRecognizer(rotationRecognizer)
}

@objc func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) {
    scaleCamera(camera: navigationCamera, gesture: gesture)
}

@objc func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
    panCamera(camera: navigationCamera, gesture: gesture)
}

@objc func handleRotationGesture(_ gesture: UIRotationGestureRecognizer) {
    rotateCamera(camera: navigationCamera, gesture: gesture)
}

/// allow multiple gesture recognizers to recognize gestures at the same time
/// for this function to work, the protocol `UIGestureRecognizerDelegate` must be added to this class
/// and a delegate must be set on the recognizer that needs to work with others
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
    return true
}

/// Use this function to determine if the gesture recognizer should handle the touch
/// For example, return false if the touch is within a certain area that should only respond to direct touch events
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
    return true
}

Camera zoom:

// MARK: - Camera zoom

/// zoom settings
var cameraMaxScale: CGFloat = 100
var cameraMinScale: CGFloat = 0.01
var cameraScaleInertia: CGFloat = 0.75

/// zoom state
var cameraScaleVelocity: CGFloat = 0
var cameraScaleBeforePinch: CGFloat = 1
var cameraPositionBeforePinch = CGPoint.zero

func scaleCamera(camera: SKNode, gesture: UIPinchGestureRecognizer) {
    let scaleCenterInView = gesture.location(in: view)
    let scaleCenterInScene = convertPoint(fromView: scaleCenterInView)

    if gesture.state == .began {

        cameraScaleBeforePinch = camera.xScale
        cameraPositionBeforePinch = camera.position

    } else if gesture.state == .changed {

        /// calculate the new scale, and clamp within the range
        let newScale = camera.xScale / gesture.scale
        let clampedScale = max(min(newScale, cameraMaxScale), cameraMinScale)

        /// calculate a factor to move the camera toward the pinch midpoint
        let translationFactor = clampedScale / camera.xScale
        let newCamPosX = scaleCenterInScene.x + (camera.position.x - scaleCenterInScene.x) * translationFactor
        let newCamPosY = scaleCenterInScene.y + (camera.position.y - scaleCenterInScene.y) * translationFactor

        /// update camera's scale and position
        /// setScale must be called now and no earlier
        camera.setScale(clampedScale)
        camera.position = CGPoint(x: newCamPosX, y: newCamPosY)

        gesture.scale = 1.0

    } else if gesture.state == .ended {

        cameraScaleVelocity = camera.xScale * gesture.velocity / 100

    } else if gesture.state == .cancelled {

        camera.setScale(cameraScaleBeforePinch)
        camera.position = cameraPositionBeforePinch

    }
}

Camera pan:

// MARK: - Camera pan

/// pan settings
var cameraPositionInertia: CGFloat = 0.95

/// pan state
var cameraPositionVelocity: (x: CGFloat, y: CGFloat) = (0, 0)
var cameraPositionBeforePan = CGPoint.zero

func panCamera(camera: SKNode, gesture: UIPanGestureRecognizer) {
    if gesture.state == .began {

        /// store the camera's position at the beginning of the pan gesture
        cameraPositionBeforePan = camera.position

    } else if gesture.state == .changed {

        /// convert UIKit translation coordinates into SpriteKit's coordinate system for mathematical clarity further down
        let uiKitTranslation = gesture.translation(in: self.view)
        let translation = CGPoint(
            /// UIKit and SpriteKit share the same x-axis direction
            x: uiKitTranslation.x,
            /// invert y because UIKit's y-axis increases downwards, opposite to SpriteKit's
            y: -uiKitTranslation.y
        )

        /// transform the translation from the screen coordinate system to the camera's local coordinate system, considering its rotation.
        let angle = camera.zRotation
        let dx = translation.x * cos(angle) - translation.y * sin(angle)
        let dy = translation.x * sin(angle) + translation.y * cos(angle)

        /// apply the transformed translation to the camera's position, accounting for the current scale.
        /// we moves the camera opposite to the gesture direction (-dx and -dy), giving the impression of moving the scene itself.
        /// if we want direct manipulation of a node, dx and dy would be added instead of subtracted.
        camera.position = CGPoint(
            x: cameraPositionBeforePan.x - dx * camera.xScale,
            y: cameraPositionBeforePan.y - dy * camera.yScale
        )

    } else if gesture.state == .ended {

        /// at the end of the gesture, calculate the velocity to apply inertia. We devide by an arbitrary factor for better user experience
        cameraPositionVelocity.x = camera.xScale * gesture.velocity(in: self.view).x / 100
        cameraPositionVelocity.y = camera.yScale * gesture.velocity(in: self.view).y / 100

    } else if gesture.state == .cancelled {

        /// if the gesture is cancelled, revert to the camera's position at the beginning of the gesture
        camera.position = cameraPositionBeforePan

    }
}

Camera rotation:

// MARK: - Camera rotation

/// rotation settings
var cameraRotationInertia: CGFloat = 0.85

/// rotation state
var cameraRotationVelocity: CGFloat = 0
var cameraRotationWhenGestureStarts: CGFloat = 0
var cumulativeRotation: CGFloat = 0
var rotationPivot = CGPoint.zero

func rotateCamera(camera: SKNode, gesture: UIRotationGestureRecognizer) {
    let midpointInView = gesture.location(in: view)
    let midpointInScene = convertPoint(fromView: midpointInView)

    if gesture.state == .began {

        cameraRotationWhenGestureStarts = camera.zRotation
        rotationPivot = midpointInScene
        cumulativeRotation = 0

    } else if gesture.state == .changed {

        /// update camera rotation
        camera.zRotation = gesture.rotation + cameraRotationWhenGestureStarts

        /// store the rotation change since the last change
        /// needed to update the camera position live
        let rotationDelta = gesture.rotation - cumulativeRotation
        cumulativeRotation += rotationDelta

        /// Calculate how the camera should be moved
        let offsetX = camera.position.x - rotationPivot.x
        let offsetY = camera.position.y - rotationPivot.y

        let rotatedOffsetX = cos(rotationDelta) * offsetX - sin(rotationDelta) * offsetY
        let rotatedOffsetY = sin(rotationDelta) * offsetX + cos(rotationDelta) * offsetY

        let newCameraPositionX = rotationPivot.x + rotatedOffsetX
        let newCameraPositionY = rotationPivot.y + rotatedOffsetY

        camera.position = CGPoint(x: newCameraPositionX, y: newCameraPositionY)

    } else if gesture.state == .ended {

        cameraRotationVelocity = camera.xScale * gesture.velocity / 100

    } else if gesture.state == .cancelled {

        camera.zRotation = cameraRotationWhenGestureStarts

    }
}

My understanding is that panning and rotating are intimately linked, but I haven't managed to make them work together well enough.

Simultaneous pan and rotation

In the panning logic, the camera position should be directly updated with the last translation delta, instead of applying the cumulative translation since the gesture started.

This bit of logic:

var cameraPositionBeforePan = CGPoint.zero

func panCamera(camera: SKNode, gesture: UIPanGestureRecognizer) {
    if gesture.state == .began {

        /// store the camera position at the start of the gesture
        cameraPositionBeforePan = camera.position

    } else if gesture.state == .changed {

        /// calculate the translation
        /// apply it to the initial camera position
        camera.position = CGPoint(
            x: cameraPositionBeforePan.x - dx * camera.xScale,
            y: cameraPositionBeforePan.y - dy * camera.yScale
        )
    }
}

Should become like this:

func panCamera(camera: SKNode, gesture: UIPanGestureRecognizer) {
    if gesture.state == .changed {

        /// calculate the translation
        /// apply it to the current camera position
        camera.position = CGPoint(
            x: camera.position.x - dx * camera.xScale,
            y: camera.position.y - dy * camera.yScale
        )

        /// reset the translation
        gesture.setTranslation(.zero, in: view)
    }
}

By using the gesture translation, applying it to the current camera position, then reseting the translation value, we make sure that the camera positoning in panCamera does not interfere with the camera positoning in rotateCamera.

Screen recordings:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment