Introduction
This is part of a series of posts about my Bush 522 Game jam project.
The root post is here.
The version of the engine is Godot 4.5.
There are 3 main ways to implement flight mechanics for a game that I can think of:
- Simplify all interacting forces and apply them at a single point.
- Model the propeller, body and wing surfaces separately and apply forces locally.
- A hybrid approach, where some forces are simplified, and others are more detailed.
In this post, I’ll mainly focus on the first approach, which is what I used for Bush 522. My goal is good gameplay, so I don’t care about realistic modelling, I only care about how it feels to fly. Towards the end of this post, I’ll go over some potential improvements of my simplified model, using a more hybrid approach.
Aircraft Scene Components and Structure
As per best practices, I make a scene named plane. In retrospect it would be better
to name it something like aircraft to avoid naming conflicts with
Godot’s built-in Plane class.
The complete structure looks like this:
Aircraft (RigidBody3D)
├─ Mesh
│
├─ CollisionShape3D_1
├─ ... A bunch of collision shapes
├─ CollisionShape3D_N
│
├─ FrontSuspensionFrameLeft (RigidBody3D)
│ ├─ Mesh
│ ├─ CollisionShape3D
│ └─ HingeJoint3D
├─ FrontSuspensionFrameRight (RigidBody3D)
│ └─ ... Same as left ...
│
├─ FrontWheelLeft (RigidBody3D)
│ ├─ Mesh
│ ├─ CollisionShape3D
│ └─ HingeJoint3D
├─ FrontWheelRight (RigidBody3D)
│ └─ ... Same as left ...
│
├─ Pivot Camera
└─ Audio players
The core rigid body
I make the Aircraft’s weight 500kg and I change its center of mass to 0,0,0 to make calculations easier.
The suspension
The suspension’s purpose is to provide some cushion between the wheels and the aircraft. In the game, it’s a rigid body, containing a mesh, collision shape, and a hinge joint.
The collision shape is just a small sphere that looks nothing like the mesh. That’s because of where the suspension is located: between the wheels and the main body. This position makes it almost impossible to be hit, therefore there’s no point in spending time on making an accurate collision model.
The HingeJoint is more interesting. It builds the connection between the aircraft and the suspension.
It’s positioned right between the two. It’s configured as follows:
Params -> Bias = 0.99Angular Limit -> Enable = OnAngular Limit -> Upper = 5Angular Limit -> Lower = -5Angular Limit -> Bias = 0.5Angular Limit -> Softness = 0.1Angular Limit -> Relaxation = 0.5
I also tried using a SliderJoint, but it would jiggle around uncontrollably when maneuvering the aircraft.
The wheels
Each wheel is a rigid body attached to its suspension via a HingeJoint.
With default settings, the angular limits are disabled, and the wheel spins freely.
The collision shape is a very short cylinder.
Disabling Godot Default Damping
By default Godot applies physical damping to all rigid bodies. This means that it applies forces to reduce their movement, effectivelly acting like air resistance and friction for regular objects. Planes are no regular objects, they have a very specific behaviour. Besides gravity, we’ll be doing the complete physics modelling ourselves, so the default damping has to go. There are 2 ways to disable it.
Project level damping settings
The quick and easy way to disable damping is on project level. This is applicable in cases where the only rigid body in the game is the airplane.
To disable the damping for all rigid bodies in the project:
- Open
Project -> Project Settings. - Turn on the
Advanced Settingsat the top right. - Find
Physics -> 3D. - Set
Default Linear Dampto 0. - Set
Default Angular Dampto 0.
RigidBody damping override
A cleaner way to disable damping, that would allow for normal behaviour of other phyisical objects in the game, would be to disable damping for specific bodies. This setting is not inherited by child nodes, so it has to be applied to all of the rigid bodies that the plane has. For example, in Bush 522, the airplane has soft suspension and wheels, which are all rigid bodies. Overriding the default damping has to be done for all of them.
To disable the default damping for individual bodies, do this for the aircraft’s root rigid body and all rigid bodies that make it up:
- Select the rigid body node.
- Set
RigidBody3D -> Linear -> Damp ModetoReplace. - Make sure the
Dampvalue is0.0 - Set
RigidBody3D -> Angular -> Damp ModetoReplace. - Again, make sure the
Dampvalue is0.0 - Repeat this for all rigid body nodes that make up the aircraft.
This has the side effect of making the plane’s wheels spin infinitely, but it’s something that can be easily fixed by adding angular damp to each wheel’s rigid body.
Basic Controls
Before getting the aircraft up in the sky, it’s important to be able to control it and taxi it on the ground.
The Ctrl key is not yours (in the browser)
The Ctrl key is reserved for browser shortcuts and is not suitable for ingame
inputs. I used it in the first builds of the game and I would often close the tab
with the Ctrl + W shortcut.
Throttle and thrust
Unlike driving, where the car engine needs a responsive throttle for very frequent changes in acceleration, in flying the throttle rarely changes. Due to this, it’s better to have two buttons for shifting the throttle between 0% and 100%.
var throttle: float
const throttle_increment: float = 1.
func increase_throttle(delta: float) -> void:
throttle = min(1., throttle + throttle_increment * delta)
func decrease_throttle(delta: float) -> void:
throttle = max(0., throttle - throttle_increment * delta)
func _ready() -> void:
throttle = 0.
func _process(delta: float) -> void:
if Input.is_action_pressed("throttle_up"):
increase_throttle(delta)
if Input.is_action_pressed("throttle_down"):
decrease_throttle(delta)
In real life, when in action, a single propeller plane’s rotor, besides forward force, would also produce a slight torque along the plane’s forward facing axis, slightly rolling the plane to the opposite site. The pilot would add aileron trim to combat this.
I think this gameplay would be too realistic and not much fun. The player would have to constantly make small corrections to the roll, or I’d have to add a trim tab.
Thankfully, it’s actually easier to just model the forward forces produced by the propeller.
const engine_power: float = 5000.
func apply_engine_force() -> void:
apply_central_force(global_transform.basis.x * engine_power * throttle)
func _physics_process(delta: float) -> void:
apply_engine_force()
Angular controls
In a realistic model, the aircraft would be controllable due to the way control surfaces interact with the air stream. Being very short on time, I didn’t implement this approach. Instead I just applied angular force based on user input. I tuned the values based on what felt nice and didn’t research any real aircraft’s manuever capabilities.
const pitch_force: float = 5000.
const roll_force: float = 10000.
const yaw_force: float = 5000.
func _physics_process(delta: float) -> void:
if Input.is_action_pressed("pitch_up"):
apply_torque(global_transform.basis.z * pitch_force)
if Input.is_action_pressed("pitch_down"):
apply_torque(global_transform.basis.z * -pitch_force)
if Input.is_action_pressed("yaw_left"):
apply_torque(global_transform.basis.y * yaw_force)
if Input.is_action_pressed("yaw_right"):
apply_torque(global_transform.basis.y * -yaw_force)
if Input.is_action_pressed("roll_left"):
apply_torque(global_transform.basis.x * -roll_force)
if Input.is_action_pressed("roll_right"):
apply_torque(global_transform.basis.x * roll_force)
Achieving Flight
Now that the basic controls are ready, it’s time to model the aircraft’s behaviour in the sky.
Lift
The lift is the upwards force generated by the plane’s wings when moving forward. In this game I model it as a simple linear function of the plane’s absolute speed in its local forward axis.
const wing_lift_coef: float = 50
func get_forward_speed() -> float:
var vel: Vector3 = linear_velocity
var local_x_speed = vel.dot(global_transform.basis.x)
return local_x_speed
func get_lift_force() -> float:
return abs(get_forward_speed()) * wing_lift_coef
func apply_lift() -> void:
apply_central_force(global_transform.basis.y * (get_lift_force()))
func _physics_process(delta: float) -> void:
apply_lift()
In real life, I wouldn’t expect most wings to generate lift flying backwards. Here, I don’t care. I want lift to be generated when flying in either direction. That’s more fun, and games should be fun!
To get the wing_lift_coef, I just experiment with different values and see what feels
good in the game.
Now the plane will take off going forward… but any maneuver feels super weird. That’s because besides lift, the wing should also produce drag based on the incoming wind direction. It can also reach crazy speeds, as there’s no drag to counteract the forward propulsion.
Forward drag
To limit the aircraft’s forward speed, we can introduce drag. At some speed the drag should be equal to the propultion force: this is the maximum forward speed of the aircraft.
In my opinion it is best to use an exponential function as this would make the speed more fun to balance for the player. At low speeds there would be pretty much no drag, so this would allow for gliding in emergencies. A pretty high speed could be reached with medium throttle, allowing for good cruising mechanics. High throttle would only make sense when climbing.
func apply_forward_drag() -> void:
var fwd_speed = get_forward_speed()
var direction_multiplier = -1. if fwd_speed > 0 else 1.
var drag = direction_multiplier * ((fwd_speed/1.5) ** 2)
var drag_vector = global_transform.basis.x * drag
apply_force(drag_vector, x_forces_application_point.position)
Angle of attack drag
Relative to the wing, the angle of attack (short AoA) is the angle between the wing itself and the incoming air stream. In the simplified model for this game, the AoA is the angle between the aircraft and the incoming airstream.
As you can imagine, the closer a wing is to being perpendicular to the airflow, the more surface area will be exposed to the wind, producing more drag.
To make calculations easier, the angle of attack can be measured separately on
the xy and xz axes, one representing the wings and horizontal stabilizers,
the other representing the vertical stabilizer.
A simple sin function can be used to determine the direction and magnitude
of the induced drag. A drag coeficient can be used to tune how it feels in the game.
const wing_drag_coef: float = 25.
const vert_stabilizer_drag_coef = 20.
func get_wing_drag() -> float:
var vel: Vector3 = linear_velocity
var local_x_speed = vel.dot(global_transform.basis.x)
var local_y_speed = vel.dot(global_transform.basis.y)
var xy_velocity = Vector2(local_x_speed, local_y_speed)
return -sin(xy_velocity.angle()) * wing_drag_coef * xy_velocity.length_squared()
func apply_lift() -> void:
apply_central_force(global_transform.basis.y * (get_lift_force() + get_wing_drag()))
func get_vertical_stabilizer_drag() -> float:
var vel: Vector3 = linear_velocity
var local_x_speed = vel.dot(global_transform.basis.x)
var local_z_speed = vel.dot(global_transform.basis.z)
var xz_velocity = Vector2(local_x_speed, local_z_speed)
return -sin(xz_velocity.angle()) * vert_stabilizer_drag_coef * xz_velocity.length_squared()
func apply_vertical_stabilizer_force() -> void:
apply_central_force(global_transform.basis.z * get_vertical_stabilizer_drag())
func _physics_process(delta: float) -> void:
apply_lift()
apply_vertical_stabilizer_force()
Now the aircraft should feel good to control.
Potential improvements
This model was perfect for the 2-week game jam. It allowed me to quickly get past the flight mechanics and focus on more important gameplay elements like the procedural terrain and objectives.
However, there are key improvements that can be made.
The following are ideas that have a lot of potential to make the gameplay feel more consistent and the development easier, but I haven’t implemented yet.
Wing surfaces
Instead of thinking about the physical forces acting upon the aircraft as a whole, we can go one level deeper and think about the forces acting on each individual wing surface. This would localize the calculation of drag and lift for each individual wing.
Using a composition of abstract wing surfaces to build the aircraft would actually simplify the code, as the wing surface can be moved into a separate scene, with its own calculation script.
Control surface joints
Going one step further, wing surfaces can be attached to joints, which can move depending on player input. This would effectively create realistic control surfaces.
This would also solve the problem of the aircraft being able to maneuver at any speed. Stalls could become much more dangerous and would require actual piloting skills to get out of.