Skip to content

Instantly share code, notes, and snippets.

@ThoseGrapefruits
Last active July 12, 2021 17:43
Show Gist options
  • Save ThoseGrapefruits/15d5d8ca4415916373dd73d435a9e208 to your computer and use it in GitHub Desktop.
Save ThoseGrapefruits/15d5d8ca4415916373dd73d435a9e208 to your computer and use it in GitHub Desktop.
Swift PID controller for SpriteKit

README

A simple PID controller implementation in Swift. This is intended for use with SpriteKit, SceneKit, etc, where CGFloat is the usual type for the things you might want to PID.

Internally, calculations are done with Double. I've gotten in the habit of doing this without really knowing if it's necessary. In my experience, PID steps are being performed on slower tick intervals (like every 0.1 seconds) so the difference isn't going to be a huge performance burden, but YMMV. It's pretty straightforward to replace all Doubles with CGFloats and take out the explicit casts, if it does cause more harm than good.

One of the troubles I ran into was that the SpriteKit game framework doesn't make it easy to get the current time within SKAction completion handlers. This is where the step(error:deltaTime:) method comes in handy, where we can just assume that the amount of time that has passed since the last step is approximately the duration we gave to the SKAction. The camera tracking example below shows how that works.

What's a PID controller?

A PID (Proportion, Integral, Derivative) controller uses a bit of calculus to smoothly control some aspect of a physical (or simulated physical) system. It is used in situations where you are trying to maintain a measurement (e.g. temperature, speed) at a specific value over time in a chaotic environment, where your input to that environment (e.g. a heating element, a gas pedal) is limited or imperfect. It runs as a step function over time, on some interval. Each time it steps, it receives the current error: the difference between the desired value of the measurement, and the current value of the measurement; it returns the next value to apply to the input (e.g. how much energy to put into the heating element, how far to press the gas pedal, how much virtual force to apply). Over time, it seeks to minimise the error and maintain the value, regardless of changing conditions.

There were a lot of borderline-meaningless words in that paragraph. I like examples. Here's an example:

Steering a ship

A ship captain is the original PID controller. Observing ship captains was actually how engineers figured out how to make a mechanical one. In this example, a captain has a desired bearing and an actual bearing, both displayed on their ship compass. They want to make the desired bearing equal to the actual bearing, and keep it there. The difference between the desired and actual bearings is the error in this system.

However, the ocean (much like a physics simulation) is a world of chaos. The ship may be in a storm, with unpredictable waves slamming the side and pushing it off-course. The captain can't just hold the wheel at a constant angle, or apply a constant force, to keep the ship on course. They have to constantly adapt to conditions as they are changing.

This captain is a good, experienced captain and knows how to keep the ship on course in even the roughest of seas. They are able to see how far off course they are P. They see how long they've been on one side or the other of the true course I. They see how quickly they are approaching or leaving the true course D. In a human captain, these things all get boiled down into a sense of bodily coordination extended to the ship. In mechanics and programming, we have to make them a bit more explicit.

P I D, easy as 1 2 3 (sorry)

The 3 attributes of the error that it uses (the P, I, and D) work with and against each other to stabilise the error as close to 0 as possible, without undershooting or overshooting. It leads to very smooth and intuitive motion.

A simplified explanation of each of these is:

  • Proportion: How wrong are we right now? (How far is the error from 0?)
  • Integral: How long have we been wrong? (How long has the error been on this side of 0?)
  • Derivative: How rapidly are we becoming more right or less right? (How fast is the error changing towards/away from 0?)

Tuning

PID controllers have to be tuned, by giving it parameters that tell it how important each of the P, I, and D are. When the PID controller returns its step value, it multiplies each P, I, and D against its tuning parameter — named kP, kI, and kD, respectively.

Hopefully, through a brief introduction and by playing around with one, you'll get a sense of what controls what in a PID controller. I'm not going super in-depth in this section but tuning is very important and having some kind of starting place can be helpful.

My rule-of-thumb with tuning parameters is as follows:

  • kP: 0.1, 1, 10, 100, 1000. Try a range of values to narrow in on a value.
    • Once you've tuned one thing's kP in your physics environment, you'll probably have a sense of what a good starting value would be.
    • This should generally always be significantly higher (at least 2x) either of the other parameters.
    • If too much force is applied, go down. If not enough is applied, go up. This is usually the easiest parameter to tune.
  • kI: Don't set initially.
    • If you are never quite reaching the value you want in some cases, but your kP is already high enough for most circumstances, then bump this up.
    • If kI seems necessary, start at 1/2 your current kD value — and make sure you do have a kD value! These parameters balance each other.)
  • kD: 1/10th of the kP initially.
    • If you're overshooting (oscillating beforesettling in at the desired value) then increase it.
    • If you need to "reverse-follow" a bit (e.g. a camera that responds to changes in the velocity of the node its tracking) this can be bumped way up.

Clamping

This does not have clamping functionality available in some PID controllers, as I have found that

  1. Clamping can be done outside of the controller, and can be a bit easier to understand when it is.
  2. Clamping is less necessary in simulated-physics environments where you aren't as worried about overloading e.g. a motor by giving it too much power.
  3. It's a whole other big bag of complexity and can make debugging your PID-controlled entities even harder

There are lots of examples of clamped PID controllers online, and this one could be adapted to have clamping without too much additional work, if need be.

Examples

Track a node in the scene with the camera

Say you have a camera node, and you want it to smooth-follow your player as they move through the scene. We can use 2 PID controllers (for the x and y coordinates) to both smooth out camera motion and also make the camera stay ahead of the player a bit, if the player suddenly changes directions or has a high velocity.

// Set up camera physics
camera.physicsBody = SKPhysicsBody()
camera.physicsBody!.mass = 3.0
camera.physicsBody!.affectedByGravity = false
camera.physicsBody!.allowsRotation = false
camera.physicsBody!.linearDamping = 3

let TICK: CGFloat = 0.1 // seconds

// You would probably define this on a custom subclass of SKCameraNode
// and use `self.getPosition(within:)` instead of `camera.getPosition(within:)`
func track(_ node: SKNode,
           pidX: PIDController = PIDController(kP: 4, kI: 0, kD: 2),
           pidY: PIDController = PIDController(kP: 4, kI: 0, kD: 2)) {
  guard let scene = scene else {
    return
  }

  let cameraScenePosition = camera.getPosition(within: scene)
  let nodeScenePosition =   node.getPosition(within: scene)
  let errorX = nodeScenePosition.x - cameraScenePosition.x
  let errorY = nodeScenePosition.y - cameraScenePosition.y

  let force = SKAction.applyForce(
    CGVector(
      dx: pidX.step(error: errorX, deltaTime: TICK),
      dy: pidY.step(error: errorY, deltaTime: TICK)),
    duration: TICK)

  run(force) {
    self.track(node, pidX: pidX, pidY: pidY)
  }
}
MIT License
Copyright (c) 2021 Logan Moore
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
import Foundation
import SpriteKit
class PIDController {
var kProportion: Double;
var kIntegral: Double;
var kDerivative: Double;
var cProportion: Double = 0;
var cIntegral: Double = 0;
var cDerivative: Double = 0;
var lastError: Double = 0;
var lastTime: Optional<TimeInterval> = .none;
init(kP: CGFloat, kI: CGFloat, kD: CGFloat) {
self.kProportion = Double(kP);
self.kIntegral = Double(kI);
self.kDerivative = Double(kD);
}
func step(error: CGFloat, deltaTime: TimeInterval) -> CGFloat {
let time = deltaTime + (lastTime ?? TimeInterval.zero)
return step(error: error, currentTime: time)
}
func step(error errorFloat: CGFloat, currentTime: TimeInterval) -> CGFloat {
let error = Double(errorFloat);
let secondsElapsed = (lastTime ?? currentTime).distance(to: currentTime);
self.lastTime = currentTime;
let errorDerivative = error - self.lastError;
self.lastError = error;
self.cProportion = error
self.cIntegral += error * secondsElapsed
self.cDerivative = 0
if (secondsElapsed > 0) {
self.cDerivative = errorDerivative / secondsElapsed
}
return CGFloat(
(self.kProportion * self.cProportion) +
(self.kIntegral * self.cIntegral) +
(self.kDerivative * self.cDerivative)
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment