denied.systems

field guide

Thrust vector from attitude angles

Mapping attitude angles to a body thrust vector in the NED frame - how a desired orientation becomes the direction the rotors push.

updated 2026-06-28 guidance-control kinematics

Given total thrust magnitude TT and attitude (roll ϕ\phi, pitch θ\theta, yaw ψ\psi), how do you find the inertial-frame components of the force vector perpendicular to the quadcopter’s plane?

The setup

The quadcopter’s “plane” is the body-fixed xy-plane — the arms form an X or + in this plane. The vector perpendicular to it is the body-fixed z-axis, pointing up through the centre of mass (by convention).

All four rotors spin to produce thrust along this axis. In body coordinates:

𝐅B=[00T] \mathbf{F}_B = \begin{bmatrix} 0 \\ 0 \\ T \end{bmatrix}

Total thrust TT is purely along body-z. There is no body-x or body-y thrust component — those come from tilting this vector via attitude.

To find the components in the world/inertial frame, we need to rotate 𝐅B\mathbf{F}_B by the quadcopter’s current orientation. That orientation is described by the Euler angles (ϕ,θ,ψ)(\phi, \theta, \psi) and the rotation matrix that maps body→world.

The rotation matri·x (derived)

The world frame is NED (north-east-down) or ENU (east-north-up) depending on your convention — the math is the same either way, the axes just swap labels.

We build the rotation matrix from body to world as a sequence of three intrinsic rotations, applied in ZYX order (yaw → pitch → roll). This is the standard aeronautical convention.

Step 1 — Yaw ψ\psi about body z-axis:

Rz(ψ)=[cψsψ0sψcψ0001] R_z(\psi) = \begin{bmatrix} c_\psi & -s_\psi & 0 \\ s_\psi & c_\psi & 0 \\ 0 & 0 & 1 \end{bmatrix}

This rotates the body’s x-y plane around the vertical axis — pointing the nose north/south/east/west.

Step 2 — Pitch θ\theta about the (now-rotated) body y-axis:

Ry(θ)=[cθ0sθ010sθ0cθ] R_y(\theta) = \begin{bmatrix} c_\theta & 0 & s_\theta \\ 0 & 1 & 0 \\ -s_\theta & 0 & c_\theta \end{bmatrix}

Tilts the nose up or down.

Step 3 — Roll ϕ\phi about the (now-rotated) body x-axis:

Rx(ϕ)=[1000cϕsϕ0sϕcϕ] R_x(\phi) = \begin{bmatrix} 1 & 0 & 0 \\ 0 & c_\phi & -s_\phi \\ 0 & s_\phi & c_\phi \end{bmatrix}

Banks left or right.

The full rotation is the product of these three matrices, applied right to left (the first rotation is rightmost):

RBW=Rz(ψ)Ry(θ)Rx(ϕ) R_{BW} = R_z(\psi) \, R_y(\theta) \, R_x(\phi)

Which expands to:

RBW=[cθcψsϕsθcψcϕsψcϕsθcψ+sϕsψcθsψsϕsθsψ+cϕcψcϕsθsψsϕcψsθsϕcθcϕcθ] R_{BW} = \begin{bmatrix} c_\theta c_\psi & s_\phi s_\theta c_\psi - c_\phi s_\psi & c_\phi s_\theta c_\psi + s_\phi s_\psi \\ c_\theta s_\psi & s_\phi s_\theta s_\psi + c_\phi c_\psi & c_\phi s_\theta s_\psi - s_\phi c_\psi \\ -s_\theta & s_\phi c_\theta & c_\phi c_\theta \end{bmatrix}

Applying to the thrust vector

𝐅W=RBW𝐅B=RBW[00T] \mathbf{F}_W = R_{BW} \cdot \mathbf{F}_B = R_{BW} \cdot \begin{bmatrix}0\\0\\T\end{bmatrix}

Since only the third column of RBWR_{BW} multiplies against the non-zero entry of 𝐅B\mathbf{F}_B, we get:

Fx=T(cϕsθcψ+sϕsψ)Fy=T(cϕsθsψsϕcψ)Fz=T(cϕcθ) \boxed{ \begin{aligned} F_x &= T \cdot (c_\phi s_\theta c_\psi + s_\phi s_\psi) \\ F_y &= T \cdot (c_\phi s_\theta s_\psi - s_\phi c_\psi) \\ F_z &= T \cdot (c_\phi c_\theta) \end{aligned}}

These are equations (15)–(17) from the quadcopter PID tuning paper in the vault.

Why derive the full matrix?

The third column is all you need right now — you only care about one body-axis vector (body-z → world). But the full matrix will reappear when you need to:

The rotation matrix RBWR_{BW} is how you implement the coordinate transform that rotating-coordinate-frames describes for any vector measured from a rotating platform. The RBW[0,0,T]TR_{BW} \cdot [0,0,T]^T operation is the discrete version of the carrying term 𝛚×𝐪\boldsymbol\omega \times \mathbf{q} applied to the body-z thrust vector — same geometry, matrix form.

So yes, deriving it once and keeping it in a reference note is worthwhile. You’ll use different columns/rows of it as the quadcopter code grows.

In code

void thrust_body_to_world(float T, float phi, float theta, float psi,
                          float *fx, float *fy, float *fz) {
    float c_phi = cosf(phi), s_phi = sinf(phi);
    float c_th  = cosf(theta), s_th = sinf(theta);
    float c_psi = cosf(psi), s_psi = sinf(psi);

    *fx = T * (c_phi * s_th * c_psi + s_phi * s_psi);
    *fy = T * (c_phi * s_th * s_psi - s_phi * c_psi);
    *fz = T * (c_phi * c_th);
}

Feed ϕ,θ,ψ\phi, \theta, \psi from your complementary filter (MPU-6050 → quaternion → Euler angles, or direct Madgwick/Mahony output) and TT from your control law. The resulting (fx,fy,fz)(f_x, f_y, f_z) goes into your position controller or state estimator as the applied force in world coordinates.

Common pitfalls