Series: Rust Bevy gamedev

  1. Bevy gamedev part 1: ships and physics - July 27, 2024

Bevy gamedev part 1: ships and physics

Sid Meier’s Pirates, the remake, was one of my favorite games a long time ago. Every once in a while, I write down some notes on what if I made something similar? Then I started reading about the Rust game engine “Bevy” lately and I thought it’d be fun to see what I can do on a short game jam.

So here’s the running set of expectations and goals I had going in

  1. Perfection is the enemy of good enough.
  2. If there’s a Bevy plugin for something, try it first before rolling my own code.
  3. Either do simple or placeholder artwork. Don’t even know how much time I’ll spend on this anyway.

Version 0

I started with a blue screen, for the ocean, and added a few half-modeled ships that do nothing but go in a circle around the origin [0,0].

Version 0 of doing nothing

For completeness, the code is here below with Bevy 0.14 version used.

The important things to note are

  1. The ships on-screen are primitive polygons instead of any actual 3d model.
  2. The vast majority of the code so far is about building those ship’s shapes’.
  3. There’s no behavior or logic to the ships at all. They are all just shapes.
////  -- component.rs --
use bevy::prelude::{Circle, Component, Reflect, Vec2};

#[derive(Component, Reflect, Default, Debug, Clone)]
pub struct NavalPosition {
    pub position: Vec2,
    pub orientation: Vec2,
    pub bounds: Circle,
    pub velocity: Vec2,
    pub local_current: Vec2,
    pub local_wind: Vec2,
}

impl NavalPosition {
    pub fn new() -> Self {
        Self {
            // 62meters was what google said an 1800s frigate's length was.
            bounds: Circle::new(31.),
            position: Vec2::ZERO,
            orientation: Vec2::new(0., 1.),
            velocity: Vec2::ZERO,
            local_wind: Vec2::ZERO,
            local_current: Vec2::ZERO,
        }
    }
}

////  -- shapes.rs --
use bevy::math::vec2;
use bevy::prelude::{
    Component, CubicBezier, CubicGenerator, Mesh, MeshBuilder, Meshable, Reflect, Vec2,
};
use bevy::render::mesh::{Indices, PrimitiveTopology};
use bevy::render::render_asset::RenderAssetUsages;

#[derive(Component, Reflect, Debug, Clone, Copy)]
pub struct ShipShape {
    pub half_height: f32,
    pub stern_half_width: f32,
    pub control_point_0: Vec2,
    pub control_point_1: Vec2,
}

impl ShipShape {
    pub fn new(half_height: f32, stern_half_width: f32) -> Self {
        ShipShape {
            half_height,
            stern_half_width,
            control_point_0: Vec2::new(stern_half_width * 2., 0.),
            control_point_1: Vec2::new(stern_half_width * 2., 0.),
        }
    }
    pub fn with_control_point(&self, point: Vec2) -> Self {
        Self {
            control_point_0: point,
            control_point_1: point,
            ..*self
        }
    }
    pub fn with_control_points(&self, zero: Vec2, one: Vec2) -> Self {
        Self {
            control_point_0: zero,
            control_point_1: one,
            ..*self
        }
    }
}

impl Meshable for ShipShape {
    type Output = ShipShapeMeshBuilder;

    fn mesh(&self) -> Self::Output {
        ShipShapeMeshBuilder {
            ship_shape: *self,
            ..Default::default()
        }
    }
}

impl From<ShipShape> for Mesh {
    fn from(ship_shape: ShipShape) -> Self {
        ship_shape.mesh().build()
    }
}

/// A builder used for creating a [`Mesh`] with an [`ShipShape`] shape.
#[derive(Clone, Copy, Debug)]
pub struct ShipShapeMeshBuilder {
    /// The [`ShipShape`] shape.
    pub ship_shape: ShipShape,
    /// The number of vertices used for the bezier curves.
    /// The default is `16`.
    #[doc(alias = "vertices")]
    pub resolution: usize,
}

impl Default for ShipShapeMeshBuilder {
    fn default() -> Self {
        Self {
            ship_shape: ShipShape::new(31., 5.),
            resolution: 16,
        }
    }
}

impl MeshBuilder for ShipShapeMeshBuilder {
    fn build(&self) -> Mesh {
        let resolution = self.resolution * 2 + 1;
        let mut indices = Vec::with_capacity((resolution) + 3);
        let mut positions = Vec::with_capacity(resolution);
        let normals = vec![[0.0, 0.0, 1.0]; resolution];
        let mut uvs = Vec::with_capacity(resolution);

        positions.push([0., self.ship_shape.half_height, 0.0]);
        uvs.push([0., 0.]);

        for curve_position in CubicBezier::new([[
            vec2(1., self.ship_shape.half_height),
            self.ship_shape.control_point_0,
            self.ship_shape.control_point_1,
            vec2(
                self.ship_shape.stern_half_width,
                -self.ship_shape.half_height,
            ),
        ]])
        .to_curve()
        .iter_positions(self.resolution - 1)
        {
            positions.push([curve_position.x, curve_position.y, 0.]);
            uvs.push([0., 0.]);
        }

        for curve_position in CubicBezier::new([[
            vec2(
                -self.ship_shape.stern_half_width,
                -self.ship_shape.half_height,
            ),
            self.ship_shape.control_point_1 * Vec2::new(-1., 1.),
            self.ship_shape.control_point_0 * Vec2::new(-1., 1.),
            vec2(-1., self.ship_shape.half_height),
        ]])
        .to_curve()
        .iter_positions(self.resolution - 1)
        {
            positions.push([curve_position.x, curve_position.y, 0.]);
            uvs.push([0., 0.]);
        }

        for i in 1..(resolution as u32 - 1) {
            indices.extend_from_slice(&[0, i, i + 1]);
        }

        Mesh::new(
            PrimitiveTopology::TriangleList,
            RenderAssetUsages::default(),
        )
        .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
        .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
        .with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs)
        .with_inserted_indices(Indices::U32(indices))
    }
}

////  -- main.rs --
use crate::components::NavalPosition;
use crate::shapes::ShipShape;
use bevy::prelude::*;
use bevy::sprite::{MaterialMesh2dBundle, Mesh2dHandle};
use bevy_inspector_egui::quick::WorldInspectorPlugin;

pub mod components;
mod shapes;

fn startup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2dBundle::default());

    commands.spawn((
        NavalPosition {
            position: Vec2::new(3., 50.),
            ..NavalPosition::new()
        },
        MaterialMesh2dBundle {
            mesh: Mesh2dHandle(meshes.add(ShipShape::new(31., 4.))),
            material: materials.add(Color::hsl(28., 0.6, 0.64)),
            ..default()
        },
    ));

    commands.spawn((
        NavalPosition {
            position: Vec2::new(3., 100.),
            ..NavalPosition::new()
        },
        MaterialMesh2dBundle {
            mesh: Mesh2dHandle(
                meshes.add(ShipShape::new(31., 2.).with_control_point(Vec2::new(10., 10.))),
            ),
            material: materials.add(Color::hsl(28., 0.6, 0.64)),
            ..default()
        },
    ));

    commands
        .spawn((
            NavalPosition {
                position: Vec2::new(200., 50.),
                orientation: Vec2::new(-25., 40.).normalize(),
                bounds: Circle::new(100.),
                ..NavalPosition::new()
            },
            MaterialMesh2dBundle {
                mesh: Mesh2dHandle(
                    meshes.add(
                        ShipShape::new(100., 5.)
                            .with_control_points(Vec2::new(22., 70.), Vec2::new(22., -80.)),
                    ),
                ),
                material: materials.add(Color::hsl(28., 0.6, 0.64)),
                ..default()
            },
        ))
        .with_children(|parent| {
            parent.spawn(MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(Circle { radius: 3.0 })),
                material: materials.add(Color::hsl(84., 1., 0.)),
                transform: Transform::default().with_translation(Vec3::new(0., 0., 1.)),
                ..default()
            });
            parent.spawn(MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(Circle { radius: 3.0 })),
                material: materials.add(Color::hsl(84., 1., 0.)),
                transform: Transform::default().with_translation(Vec3::new(0., 40., 1.)),
                ..default()
            });
            parent.spawn(MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(Circle { radius: 3.0 })),
                material: materials.add(Color::hsl(84., 1., 0.)),
                transform: Transform::default().with_translation(Vec3::new(0., -40., 1.)),
                ..default()
            });
            parent.spawn(MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(CircularSegment::new(100., 0.5))),
                material: materials.add(Color::hsl(53., 0.35, 0.9)),
                transform: Transform::default().with_translation(Vec3::new(0., -45., 2.)),
                ..default()
            });
            parent.spawn(MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(CircularSegment::new(130., 0.5))),
                material: materials.add(Color::hsl(53., 0.35, 0.9)),
                transform: Transform::default().with_translation(Vec3::new(0., -110., 2.)),
                ..default()
            });
            parent.spawn(MaterialMesh2dBundle {
                mesh: Mesh2dHandle(meshes.add(CircularSegment::new(100., 0.5))),
                material: materials.add(Color::hsl(53., 0.35, 0.9)),
                transform: Transform::default().with_translation(Vec3::new(0., -125., 2.)),
                ..default()
            });
        });
}

fn update_ship_velocity(mut query: Query<(&mut NavalPosition, &mut Transform)>) {
    for (mut naval_position, mut transform) in &mut query {
        let local_wind = naval_position.position.perp().normalize();
        naval_position.local_wind = local_wind;

        naval_position.velocity = naval_position.local_wind / 2.;
        naval_position.position = naval_position.position + naval_position.velocity;

        transform.translation = naval_position.position.extend(0.);
        if naval_position.velocity != Vec2::ZERO {
            naval_position.orientation = naval_position.velocity;
            transform.rotation = Quat::from_rotation_arc_2d(
                Vec2::new(0., 1.),
                naval_position.orientation.normalize(),
            );
        }
    }
}

fn debug_ship_view(query: Query<&NavalPosition>, mut gizmos: Gizmos) {
    for naval_position in query.iter() {
        gizmos.circle_2d(
            naval_position.position,
            naval_position.bounds.radius,
            Color::srgb(1., 1., 1.),
        );
    }
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: String::from("Topgallant"),
                ..Default::default()
            }),
            ..default()
        }))
        .insert_resource(ClearColor(Color::hsl(210., 0.97, 0.37)))
        .register_type::<NavalPosition>()
        .add_plugins(WorldInspectorPlugin::new())
        .add_systems(Startup, startup)
        .add_systems(
            Update,
            (
                debug_ship_view,
                update_ship_velocity.before(debug_ship_view),
            ),
        )
        .run();
}

Version 1, Rapier2d

Bring in Bevy Rapier 2d as a physics engine.

Changes mostly were around

  1. Adding physics properties to the ship, including collision detection based on the mesh, mass, removing gravity, and adding some friction.
  2. naval_position component changed from being the source of velocity to a mere readonly shell.

In order to see the physics collision detection, I gave each ship a max turning radius, a fairly constant derived velocity, and set the direction that ships would turn to be the origin [0,0]. Because each ship’s velocity doesn’t change, they would go past [0,0] and then around again to reach it.

For the most part the results shown here are great until the end. Collision detection and resolution works well at first, but then starts finding cases where the ships get stuck on each other. I couldn’t figure it out in 3 hours, so I had moved on to try another option.

Version 1 of ship's heading to 00

Code.

/// main
fn main() {
    App::new()
...
        .add_plugins(RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(1.0))
        .add_plugins(RapierDebugRenderPlugin::default())
        .insert_resource(RapierConfiguration {
            gravity: Vect::ZERO,
            ..RapierConfiguration::new(1.)
        })
...

//// update ship's positions
const TURNING_180_SECONDS: f32 = 10.0;
const TURNING_SPEED_RADIANS: f32 = PI / TURNING_180_SECONDS;
fn update_ship_velocity(
    mut query: Query<(
        &mut NavalPosition,
        &mut Transform,
        &mut Velocity,
        &mut ExternalForce,
    )>,
) {
    for (mut naval_position, mut transform, mut velocity, mut external_force) in &mut query {
        let local_wind = transform.translation.xy().normalize().perp();
        let local_current = Vec2::new(-1., 0.);

        let local_y = transform.local_y().xy();
        velocity.linvel = local_y * naval_position.bounds.radius / 3. + local_current + local_wind;
        velocity.angvel = local_y
            .angle_between(-transform.translation.xy().normalize())
            .clamp(-TURNING_SPEED_RADIANS, TURNING_SPEED_RADIANS);

        naval_position.local_wind = local_wind;
        naval_position.local_current = local_current;
        naval_position.orientation = transform.rotation.xyz().xy();
        naval_position.position = transform.translation.xy();
        naval_position.velocity = velocity.linvel;
    }
}
//// Combining components into a bundle.
#[derive(Bundle)]
pub struct ShipBundle {
    rendered: MaterialMesh2dBundle<ColorMaterial>,
    naval_position: NavalPosition,
    collider: Collider,
    rigid_body: RigidBody,
    forces: ExternalForce,
    mass: ColliderMassProperties,
    damping: Damping,
    velocity: Velocity,
    friction: Friction,
    restitution: Restitution,
}


impl ShipBundle {
    pub fn new(
        ship_shape: ShipShape,
        naval_position: NavalPosition,
        mesh: Handle<Mesh>,
        color_material: Handle<ColorMaterial>,
    ) -> Self {
        let ship_mesh = ship_shape.mesh().build();
        let naval_position = naval_position.with_bounds(ship_shape.half_height);

        if let Some(VertexAttributeValues::Float32x3(positions)) =
            ship_mesh.attribute(Mesh::ATTRIBUTE_POSITION)
        {
            let mesh_madness = positions
                .iter()
                .map(|&xyz| Vect::new(xyz[0], xyz[1]))
                .collect::<Vec<Vect>>();

            Self {
                rendered: MaterialMesh2dBundle {
                    mesh: Mesh2dHandle(mesh),
                    material: color_material,
                    transform: Transform::from_translation(naval_position.position.extend(0.)),
                    ..default()
                },
                collider: Collider::round_convex_hull(&mesh_madness, 2.).unwrap(),
                mass: ColliderMassProperties::MassProperties(MassProperties {
                    mass: naval_position.bounds.radius * 5.,
                    local_center_of_mass: Vect::ZERO,
                    principal_inertia: 1.,
                }),
                naval_position,
                damping: Damping::default(),
                forces: ExternalForce::default(),
                rigid_body: RigidBody::Dynamic,
                velocity: Velocity::zero(),
                friction: Friction::default(),
                restitution: Restitution::new(0.1),
            }
        } else {
            panic!("Expected a viable mesh.")
        }
    }
}

Version 2, Avian2d

Then I tried Avian2d and I got results that looked closer to what I wanted with less fiddling and this is where I ended up.

  1. The collisions haven’t overlapped so far.
  2. Less components to configure.

Version 2 of ship's heading to 00

//// -- main --
fn main() {
    App::new()
...
        .add_plugins(PhysicsPlugins::default())
        .add_plugins(PhysicsDebugPlugin::default())
...

//// update
const TURNING_180_SECONDS: f32 = 10.0;
const TURNING_SPEED_RADIANS: f32 = PI / TURNING_180_SECONDS;
fn update_ship_velocity(
    mut query: Query<(
        &mut NavalPosition,
        &mut Transform,
        &mut LinearVelocity,
        &mut AngularVelocity,
    )>,
) {
    for (mut naval_position, mut transform, mut linear_velocity, mut angular_velocity) in &mut query
    {
        let local_wind = transform.translation.xy().normalize().perp() * 2.;
        let local_current = Vec2::new(-1., 0.) * 2.;

        let local_y = transform.local_y().xy() * 10. + local_wind + local_current;
        linear_velocity.0 = local_y;

        angular_velocity.0 = local_y
            .angle_between(-transform.translation.xy().normalize())
            .clamp(-TURNING_SPEED_RADIANS, TURNING_SPEED_RADIANS);

        naval_position.local_wind = local_wind;
        naval_position.local_current = local_current;
        naval_position.orientation = transform.rotation.xyz().xy();
        naval_position.position = transform.translation.xy();
        naval_position.velocity = linear_velocity.0;
    }
}

Old CD of Sid Meier’s Pirates

Old Copy of Sid Meier's Pirates

Series: Rust Bevy gamedev

  1. Bevy gamedev part 1: ships and physics - July 27, 2024