Hot Reloading Structs

To understand how we might use hot reloading of structures, let's create the skeleton for a simulation, as shown in Listing 3-13. The new_sim function constructs a SimContext, which maintains the simulation's state, and the sim_update function will be called every frame to update the state of SimContext. As Mun doesn't natively support logging, we'll use the extern function log_f32 to log values of the f32 type.

The subject of our simulation will be buoyancy; i.e. the upward force exerted by a fluid on a (partially) immersed object that allows it to float. Currently, all our simulation does it to log the elapsed time, every frame.

Filename: buoyancy.mun

extern fn log_f32(value: f32);

struct SimContext;

pub fn new_sim() -> SimContext {
    SimContext
}

pub fn sim_update(ctx: SimContext, elapsed_secs: f32) {
    log_f32(elapsed_secs);
}
Listing 3-13: The buoyancy simulation with state stored in `SimContext`

To be able to run our simulation, we need to embed it in a host language. Listing 3-14 illustrates how to do this in Rust.

extern crate mun_runtime;
use mun_runtime::{invoke_fn, RetryResultExt, RuntimeBuilder, StructRef};
use std::{env, time};

extern "C" fn log_f32(value: f32) {
    println!("{}", value);
}

fn main() {
    let lib_dir = env::args().nth(1).expect("Expected path to a Mun library.");

    let runtime = RuntimeBuilder::new(lib_dir)
        .insert_fn("log_f32", log_f32 as extern "C" fn(f32))
        .spawn()
        .expect("Failed to spawn Runtime");

    let ctx: StructRef = invoke_fn!(runtime, "new_sim").wait();

    let mut previous = time::Instant::now();
    const FRAME_TIME: time::Duration = time::Duration::from_millis(40);
    loop {
        let now = time::Instant::now();
        let elapsed = now.duration_since(previous);

        let elapsed_secs = if elapsed < FRAME_TIME {
            std::thread::sleep(FRAME_TIME - elapsed);
            FRAME_TIME.as_secs_f32()
        } else {
            elapsed.as_secs_f32()
        };

        let _: () = invoke_fn!(runtime, "sim_update", ctx.clone(), elapsed_secs).wait();
        previous = now;

        runtime.borrow_mut().update();
    }
}
Listing 3-14: The buoyancy simulation embedded in Rust

Now that we have a runnable host program, let's fire it up and see that hot reloading magic at work! First we need to start the build watcher:

mun build buoyancy.mun --watch

This will create the initial buoyancy.munlib that we can use to run our host program in Rust:

cargo run -- buoyancy.munlib

Your console should now receive a steady steam of 0.04... lines, indicating that the simulation is indeed running at 25 Hz. Time to add some logic.

Insert Struct Fields

Our simulation will contain a spherical object with radius r and density do that is dropped from an initial height h into a body of water with density dw. The simulation also takes the gravity, g, into account, but for the sake of simplicity we'll only consider vertical movement. Let's add this to the SimContext struct and update the new_sim function accordingly, as shown in Listing 3-15.

struct SimContext {
    sphere: Sphere,
    water: Water,
    gravity: f32,
}

struct Sphere {
    radius: f32,
    density: f32,
    height: f32,
    velocity: f32,
}

struct Water {
    density: f32,
}

pub fn new_sim() -> SimContext {
    SimContext {
        sphere: new_sphere(),
        water: new_water(),
        gravity: 9.81,
    }
}

fn new_sphere() -> Sphere {
    Sphere {
        radius: 1.0,
        density: 250.0,
        height: 1.0,
        velocity: 0.0,
    }
}

fn new_water() -> Water {
    Water {
        density: 1000.0,
    }
}
Listing 3-15: Struct definitions of the buoyancy simulation

Runtime Struct Field Initialization

Upon successful compilation, the runtime will hot reload the new structs. Memory of newly added structs will recursively be zero initialized. This means that all fundamental types of a newly added structs and its child structs will be equal to zero.

We can verify this by replacing the log_f32(elapsed_secs) statement with:

    log_f32(ctx.gravity);

Indeed the console now receives a stream of 0 lines. Luckily there is a trick that we can employ to still manually initialize our memory to desired values by using this behaviour to our advantage. Let's first add token: u32 to the SimContext:

    token: u32,

and set it to zero in the new_sim function:

        token: 0,

As before, the token value will be initialized to zero when the library has been hot reloaded. Next, we add a hot_reload_token function that returns a non-zero u32 value, e.g. 1:

fn hot_reload_token() -> u32 {
    1
}

Finally, we add this if statement to the sim_update function:

    if ctx.token != hot_reload_token() {
        let default = new_sim();
        ctx.sphere = default.sphere;
        ctx.water = default.water;
        ctx.gravity = default.gravity;
        ctx.token = hot_reload_token();
    }

This piece of code will be triggered every time the hot_reload_token function returns a different value, but only once - allowing us to initialize the value of SimContext.

Edit Struct Fields

Time to add the actual logic for simulating buoyancy. The formula for calculating the buoyancy force is force = submerged volume * water density * gravity.

fn calc_submerged_ratio(s: Sphere) -> f32 {
    let bottom = s.height - s.radius;
    let diameter = 2.0 * s.radius;
    if bottom >= 0.0 {
        0.0
    } else if bottom <= -diameter {
        1.0
    } else {
        -bottom / diameter
    }
}

fn calc_sphere_volume(radius: f32) -> f32 {
    let pi = 3.1415926535897;
    let r = radius;

    3.0/4.0 * pi * r * r * r
}

fn calc_buoyancy_force(s: Sphere, w: Water, gravity: f32, submerged_ratio: f32) -> f32 {
    let volume = calc_sphere_volume(s.radius);
    volume * submerged_ratio * w.density * gravity
}

Next we need to convert force into acceleration using acc = force / mass. We don't readily have the sphere's mass available, but we can derive it using the sphere's volume and density: mass = volume * density. Instead of doing this every frame, let's replace the sphere's density field with a mass field:

struct Sphere {
    radius: f32,
    mass: f32,      // density: f32,
    height: f32,
    velocity: f32,
}

and pre-calculate it on construction:

fn new_sphere() -> Sphere {
    let radius = 1.0;
    let density = 250.0;

    let volume = calc_sphere_volume(radius);
    let mass = density * volume;

    Sphere {
        radius,
        mass,
        height: 1.0,
        velocity: 0.0,
    }
}

To initialize the sphere's mass field, we can employ the same trick as before; this time only initializing the sphere and incrementing hot_reload_token to 2:

    if ctx.token != hot_reload_token() {
        let default = new_sphere();
        ctx.sphere = default;
        ctx.token = hot_reload_token();
    }

Editing a field's name is only one of three ways that you can edit struct fields in Mun. In order of priority, these are the changes that the Mun Runtime is able to detect:

  1. If an old field and new field have the same name and type, they must have remained unchanged. In this case, the field can be moved.
  2. If an old field and new field have the same name, they must be the same field. In this case, we accept a type conversion and the field can potentially be moved.
  3. If an old field and new field have different names but the same type, the field could have been renamed. As there can be multiple candidates with the same type, we accept the renamed and potentially moved field that is closest to the original index of the old field.

Some restrictions do apply:

  • A struct cannot simultaneously be renamed and its fields edited.
  • A struct field cannot simultaneously be renamed and undergo a type conversion.

In both of the above cases, the difference will be recognised as two separate changes: an insertion and a deletion of the struct/field.

Remove Struct Fields

We now have all of the building blocks necessary to finish our buoyancy simulation. If the sphere is (partially) submerged, we calculate and add the buoyancy acceleration to the velocity. We also always subtract the gravitational acceleration from the velocity to ensure that the sphere drops into the water.

One important thing to take into account when running simulations is to multiply the accelerations and velocities with the elapsed time, as we are working in discrete time.

Last but not least, let's log the sphere's height to the console, so we can verify that the simulation is running correctly.

    let submerged_ratio = calc_submerged_ratio(ctx.sphere);
    if submerged_ratio > 0.0 {
        // Accelerate using buoyancy
        let buoyancy_force = calc_buoyancy_force(
            ctx.sphere,
            ctx.water,
            ctx.gravity,
            submerged_ratio
        );
        let buoyancy_acc = buoyancy_force / ctx.sphere.mass;
        ctx.sphere.velocity += buoyancy_acc * elapsed_secs;
    }
    
    // Accelerate using gravity
    ctx.sphere.velocity -= ctx.gravity * elapsed_secs;

    // Apply velocity
    ctx.sphere.height += ctx.sphere.velocity * elapsed_secs;

    log_f32(ctx.sphere.height);

When the simulation has been hot reloaded, the console should now log height values of the ball that are indicative of a sphere bobbing on the waves.

Now that our simulation is completed, we no longer need the token field, hot_reload_token function, and if statement. The token field can be safely removed and the simulation hot reloaded without losing any state.

Well done! You've just had your first experience of hot reloading strucs.