Search code examples
math3dhtml5-canvas

How does a banking motion affect the pitch and yaw?


I'm trying to model the turning of an airplane, specifically banking. So, I want a pitching motion to change the actual pitch and yaw of the airplane based on the level of roll. For example, if the plane has not rolled, pitching it will turn it directly up, but if there is some degree of roll, the plane will turn somewhat up and somewhat to the side.

Right now I'm using a matrix transformation to rotate the model, and the order I'm using makes pitch and yaw independent of roll - so, I need to figure out how roll will affect the pitch and yaw when the plane banks.

Here is what I have tried:

if (keys["arrowleft"]) {
  plane.roll -= 0.1;
}
if (keys["arrowright"]) {
  plane.roll += 0.1;
}
if (keys["arrowup"]) {
  plane.pitch -= 0.1*Math.cos(plane.roll);
  plane.yaw -= 0.1*Math.sin(plane.roll);
}
if (keys["arrowdown"]) {
  plane.pitch -= 0.1*Math.cos(plane.roll);
  plane.yaw -= 0.1*Math.sin(plane.roll);
}

In other words, is there any way to convert a pitching motion while rolling (given current orientation) to proper Euler angles with an elementary formula? Or do I need to use a different matrix transformation?


Solution

  • The (not so useful) normal way to rotate things

    All pitch, roll, and yaw operations rotate your plane's orientation vector, so left/right shouldn't add a fixed value to the plane's roll, they should instead roll the plane by some angle, which you apply to your orientation vector by multiplying it with a 3D rotation matrix.

    Now, "normally" if your plane orientation vector is ordered as p, r, y then rolling is a rotation over r:

    |  cos(a)  0  sin(a) |   |p|
    |    0     1    0    | x |r|
    | -sin(a)  0  cos(a) |   |y|
    

    And, similarly, pitching means rotating over p, so

    |  1     0       0    |   |p|
    |  0   cos(a) -sin(a) | x |r|
    |  0  -sin(a)  cos(a) |   |y|
    

    And then you can update the plane's position by storing and applying rotation values as you're flying along... but this approach means you're going to run into the extrinsic vs. intrinsic problem, where the order of operations can change the final orientation, and doing things like applying a change over more than one axis (which is approximately "all the time" in a flying game) can be a right pain in the butt.

    The much more useful way to rotate things

    We can solve problem by, instead, tracking the plane's "current local system of axes" rather than tracking angles, and rotating those axes based on key input (where we perform a roll as "a rotation in the current pitch/yaw 2d plane", and perform pitching as "a rotation in the current roll/yaw 2d plane")

    That's a little more work, but not a crazy amount of work. We implement the following matrix instead:

    | x * x * (1-cos(a)) +     cos(a),  x * y * (1-cos(a)) - z * sin(a), x * z * (1-cos(a)) + y * sin(a) |
    | x * y * (1-cos(a)) + z * sin(a),  y * y * (1-cos(a)) +     cos(a), y * z * (1-cos(a)) - x * sin(a) |
    | x * z * (1-cos(a)) - y * sin(a),  y * z * (1-cos(a)) + x * sin(a), z * z * (1-cos(a)) +     cos(a) |
    

    Where x, y, and z are the axis in our current local coordinate system that we want to rotate over. E.g. if we want the plane to roll:

    [x, y, z] = normalize(localFrame.roll)
    

    And of course, that looks lengthy, but it's programming so there's a whole bunch of repeated terms that we can just precompute to save some cycles and make the code easier to read:

    sa = sin(a), ca = cos(a), mc = 1 - ca
    xsa = x * sa, ysa = y * sa, zsa = z * sa
    xmc = x * mc, ymc = y * mc, zmc = z * mc
    
        | x * xmc +  ca,  x * ymc - zsa, x * zmc + ysa |
    Q = | y * xmc + zsa,  y * ymc +  ca, y * zmc - xsa |
        | z * xmc - ysa,  z * ymc + xsa, z * zmc +  ca |
    

    So let's see that in action: the following runnable snippet lets you focus on the interactive graphic it sets up, and your arrow keys let you roll and pitch the plane's local coordinate system (without drawing the plane to make things as clear as possible).

    function sourceCode() {
      let step = 0.05;
      let localFrame = {
        roll: [-1, 0, 0],
        pitch: [0, 1, 0],
        yaw: [0, 0, 1],
      };
    
      function setup() {
        setSize(400, 150);
        setBorder(1, `black`);
        noGrid();
      }
    
      function draw() {
        setCursor(`none`);
        clear(`white`);
        translate(0.5 * width, 0.7 * height);
    
        // draw our local axes
        const f = 100;
        const scale = v => ([v[0] * f, v[1] * f, v[2] * f]);
        const p0 = project(...[0, 0, 0]);
        const px = project(...scale(localFrame.roll));
        const py = project(...scale(localFrame.pitch));
        const pz = project(...scale(localFrame.yaw));
    
        setStroke(`red`);
        line(p0, px);
        setStroke(`green`);
        line(p0, py);
        setStroke(`blue`);
        line(p0, pz);
      }
    
      // matrix * vector function
      function mul(M, v) {
        let x, y, z;
        if (v.length === 3) {
          x = v[0];
          y = v[1];
          z = v[2];
        } else {
          x = v.x;
          y = v.y;
          z = v.z;
        }
        return [
          M[0] * x + M[1] * y + M[2] * z,
          M[3] * x + M[4] * y + M[5] * z,
          M[6] * x + M[7] * y + M[8] * z,
        ];
      }
    
      // "update our local frame" function
      function update(a, name) {
        const pv = localFrame[name];
        const m = pv.reduce((t, e) => t + e ** 2, 0) ** 0.5;
        pv.forEach((v, i, pv) => (pv[i] = v / m));
    
        // rotation matrix for new pitch axis
        const [x, y, z] = pv;
    
        const sa = sin(a), ca = cos(a), mc = 1 - ca;
        const xsa = x * sa, ysa = y * sa, zsa = z * sa;
        const xmc = x * mc, ymc = y * mc, zmc = z * mc;
    
        const Q = [
          x * xmc + ca,  x * ymc - zsa, x * zmc + ysa,
          y * xmc + zsa, y * ymc + ca,  y * zmc - xsa,
          z * xmc - ysa, z * ymc + xsa, z * zmc + ca,
        ];
    
        // apply rotation
        localFrame.roll = mul(Q, localFrame.roll);
        localFrame.pitch = mul(Q, localFrame.pitch);
        localFrame.yaw = mul(Q, localFrame.yaw);
      };
    
      // key handler
      function keyDown() {
        if (keyboard.ArrowLeft) {
          update(-step, `roll`);
        }
        if (keyboard.ArrowRight) {
          update(step, `roll`);
        }
        if (keyboard.ArrowUp) {
          update(-step, `pitch`);
        }
        if (keyboard.ArrowDown) {
          update(step, `pitch`);
        }
        redraw();
      }
    }
    
    
    // load the code once the custom element loader is done:
    customElements.whenDefined(`graphics-element`).then(() => {
      document.getElementById(`graphics`).loadFromFunction(sourceCode);
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/graphics-element/5.0.0/graphics-element.js" type="module"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/graphics-element/5.0.0/graphics-element.min.css" />
    
    <graphics-element id="graphics" title="Manipulating a 3D coordinate system"></graphics-element>

    Just remember that your flight surfaces incur a bit of an angle too, so when you're rolling, you generally need to add a bit of global (rather than local) yaw every frame, too.

    Adding realistic banking: progressive global yaw

    We can add a simple "value transform" that adds some global yaw based on how much we're rotating over the roll axis in a turn() function that we run as part of our game loop:

    function sourceCode() {
      let step = 0.05;
      let rotation = 0;
    
      let localFrame = {
        roll: [-1, 0, 0],
        pitch: [0, 1, 0],
        yaw: [0, 0, 1],
      };
    
      const aLen = 100;
    
      function d(v) {
        return ([v[0] * aLen, v[1] * aLen, v[2] * aLen]);
      }
    
      function updateLocalFrame(M) {
        localFrame.roll = mul(M, localFrame.roll);
        localFrame.pitch = mul(M, localFrame.pitch);
        localFrame.yaw = mul(M, localFrame.yaw);
      }
    
      function setup() {
        setSize(400, 120);
        setBorder(1, `black`);
        play();
      }
    
      function draw() {
        clear(`white`);
        translate(0.5 * width, 0.7 * height);
        drawAxes();
        drawPlane();
        turn();
      }
    
      // Apply some global yaw based on how much we're rolling:
      function turn() {
        const a = rotation / 15;
        const sa = sin(a);
        const ca = cos(a);
        const R = [
           ca, sa, 0,
          -sa, ca, 0,
            0,  0, 1
        ];
        // apply global rotation
        updateLocalFrame(R);
      }
    
      function drawAxes() {
        const p0 = project(...[0, 0, 0]);
        setStroke(`red`);
        const px = project(...d(localFrame.roll));
        line(p0, px);
        setStroke(`green`);
        const py = project(...d(localFrame.pitch));
        line(p0, py);
        setStroke(`blue`);
        const pz = project(...d(localFrame.yaw));
        line(p0, pz);
      }
    
      function drawPlane() {
        const {
          roll: x,
          pitch: y,
          yaw: z
        } = localFrame;
        const Q = invertMatrix([x, y, z]).flat();
    
        let poly = [
          [-50, -10, 0],
          [50, 0, 0],
          [-50, 10, 0],
        ].map(v => project(...mul(Q, v)));
        plotData(poly, 'x', 'y');
    
        poly = [
          [10, -40, 0],
          [10, 40, 0],
          [-10, 50, 0],
          [-10, -50, 0],
        ].map(v => project(...mul(Q, v)));
        plotData(poly, 'x', 'y');
      }
    
      // "update our local frame" function
      function update(a, name) {
        const pv = localFrame[name];
        const m = pv.reduce((t, e) => t + e ** 2, 0) ** 0.5;
        pv.forEach((v, i, pv) => (pv[i] = v / m));
    
        // rotation matrix for new pitch axis
        const [x, y, z] = pv;
    
        const sa = sin(a), ca = cos(a), mc = 1 - ca;
        const xsa = x * sa, ysa = y * sa, zsa = z * sa;
        const xmc = x * mc, ymc = y * mc, zmc = z * mc;
    
        const Q = [
          x * xmc + ca,  x * ymc - zsa, x * zmc + ysa,
          y * xmc + zsa, y * ymc + ca,  y * zmc - xsa,
          z * xmc - ysa, z * ymc + xsa, z * zmc + ca,
        ];
    
        // apply local rotation
        updateLocalFrame(Q);
      };
    
      // key handler
      function keyDown() {
        if (keyboard.ArrowLeft) {
          update(-step, `roll`);
          rotation -= step;
        }
        if (keyboard.ArrowRight) {
          update(step, `roll`);
          rotation += step;
        }
        if (keyboard.ArrowUp) {
          update(-step, `pitch`);
        }
        if (keyboard.ArrowDown) {
          update(step, `pitch`);
        }
        if (!playing) redraw();
      }
    
      // matrix * vector function
      function mul(M, v) {
        let x, y, z;
        if (v.length === 3) {
          x = v[0];
          y = v[1];
          z = v[2];
        } else {
          x = v.x;
          y = v.y;
          z = v.z;
        }
        return [
          M[0] * x + M[1] * y + M[2] * z,
          M[3] * x + M[4] * y + M[5] * z,
          M[6] * x + M[7] * y + M[8] * z,
        ];
      }
    }
    
    // load the code once the custom element loader is done:
    customElements.whenDefined(`graphics-element`).then(() => {
      document.getElementById(`graphics`).loadFromFunction(sourceCode);
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/graphics-element/5.0.0/graphics-element.js" type="module"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/graphics-element/5.0.0/graphics-element.min.css" />
    
    <graphics-element id="graphics" title="banking a plane using local roll/pitch and global yaw"></graphics-element>

    Putting it all together

    And of course, if we want to do something useful with that, we can turn the update into one that checks the plane's local coordinate system with an update in both position (lat, long, elevation), linear momentum (forward and vertical speed), angular momentum (heading and turn rate), as well as global orientation (updating its global yaw if we're not flying perfectly level):

    const knots_in_feet_per_s = 1.68781;
    const knots_in_kmph = 1.852;
    const ms_per_s = 1000;
    const ms_per_hour = 3600 * ms_per_s;
    
    const speed = 100;    // let's say we're flying at 100 knots
    let lat = 0;          // in degrees of arc
    let long = 0;         // in degrees of arc
    let elevation = 1000; // in feet
    let oldHeading = 0;   // in degrees of arc
    
    function update() {
      // We assume there is something that tells us how
      // many milliseconds passed since the last frame,
      // as a variable called frameDelta, so that we're
      // updating our plane irrespective of frame rate.
      // Whether we're running at 10, 30, 60, or 144 fps,
      // our plane's going to approximately behave the same.
    
      const [x, y, z] = localFrame.roll;
    
      // Our vertical speed is purely based on the z-component
      // of our local coordinate system:
      const vspeed = z * speed * knots_in_feet_per_s;
    
      // And for our forward speed we naively calculate a value based on
      // a triangle where the units... don't match. That's fine: if we
      // wanted realism we'd need to make the plane pick up speed until
      // its forward momentum was counteracted by its drag coefficient,
      // which is *way* more work than we care about here. Instead we're
      // just going to lower the speed the more we pitch up, and increase
      // the speed the more we pitch down:
      const s = sign(vspeed);
      const fspeed = (speed ** 2 - s * vspeed ** 2) ** 0.5;
    
      // The bank angle of our plane is determined by the z component
      // of our pitch axis, which we use to determine the angle of
      // that axis with the XY plane. Normally that means applying
      // the "soh" rule, i.e. angle = asin(o/h), but in this case our
      // "h" is, by definition, 1, so we get:
      const bankAngle = degrees(asin(localFrame.pitch[2]));
    
      // Note that this also allows us to play with the step size for
      // our rotation when the user presses left or right: ideally
      // we follow the ICAO "standard turn", where a 25 degree bank
      // angle corresponds to a 3 degrees per second rate of turn.
    
      // Then, we update our heading based on the angle of our roll
      // axis in the XY plane, with a modulo applied so we're only
      // ever using numbers in the interval [0, 360).
      const heading = (degrees(atan2(y, x)) + 360) % 360;
      const turnRate = ((heading - oldHeading) * ms_per_s) / frameDelta;
      oldHeading = heading;
    
      // And then we can update our plane's position:
      const km = (knots_in_kmph / ms_per_hour) * frameDelta * speed;
      const pos = getPointAtDistance(lat, long, km, heading);
      lat = pos[0];
      long = pos[1];
      elevation += (vspeed / ms_per_s) * frameDelta;
    }
    

    With the getPointAtDistance function calculating GPS coordinates along great circles on Earth (or really, on a sphere with a radius approximately that of Earth. In reality, Earth is not a sphere, of course):

    /**
     * Calculate the GPS coordinate [km] kilometers away from the
     * position {lat1, long1} (in radians) when flying a heading of
     * [a] degrees (also in radians), on a sphere with radius [R] in km,
     * and return the result as a new {lat2,long2} (still in radians).
     */
    function getPointAtDistance(lat1, long1, km, a, R = 6371) {
      const lat2 = asin(sin(lat1) * cos(km / R) + cos(lat1) * sin(km / R) * cos(a));
      const dx = cos(km / R) - sin(lat1) * sin(lat2);
      const dy = sin(a) * sin(km / R) * cos(lat1);
      const long2 = long1 + atan2(dy, dx);
      return [lat2, long2];
    }