3 releases (breaking)

0.4.0 Mar 7, 2023
0.3.0 Oct 21, 2022
0.1.0 Nov 22, 2018

#761 in Game dev

36 downloads per month

MIT license

68KB
615 lines

Modulator

CLICK HERE for a Video Introduction To The Modulator Crate And Play Application Check out this video for an introduction to the application and crate!

CLICK HERE to go to the Modulator Play application repository

A trait for abstracted, decoupled modulation sources. This crate includes:

  1. The Modulator<T> trait definition
  2. An environment (host) type for modulators ModulatorEnv<T>
  3. A number of ready to use types that implement the modulator trait

Changes in version 0.4.0

  • Revised behavior for ScalarSpring::undamp parameter -- Domain is now between 0.0 (full damping) and 1.0 (all damping removed) -- Undamped spring simulation is unconditionally stable for any size timestamp -- When undamp==1.0 spring can oscillate indefinitely -- Spring oscillation will lose energy proportional to the timestep duration

Changes in version 0.3.0

  • Updated to Rust 2021 edition
  • Update to latest version of rand, propagated API changes
  • Replaced the hash used by the modulator environment with metro - we don't really care about hash safety for the env, and metro is much faster than the default std hasher
  • (Repo only) Fixed a bug in Newtonian introduced by a merged PR; this change was never published
  • (Repo only) Removed dependencies and arena-based env, which were added by a merged PR but had issues; this change was never published

Introduction

Modulators are sources of change over time which exist independently of the parameters they affect, their destinations.

The architecture presented here was inspired, in part, by the world of audio synthesis, so let us introduce the main concepts by drawing a parallel to it.

A synthesizer is a musical instrument in which electronic waveform generators produce a basic sound, which is then filtered, amplified and output. While the method of waveform generation and the processing applied to it are key to the sonic result, by themselves they are not sufficient to produce interesting, lively results.

To increase complexity and depth, modulations can be added to mutate synthesis parameters over time and produce evolving, organic sounds. A synthesist can sculpt the output by connecting modulation sources to destinations.

Modulation sources include periodic functions, low frequency oscillators, noise generators, performance controls, etc.

Destinations are typically parameters that affect the amplitude, frequency, or harmonic structure of the sound. This results in effects such as tremolo, vibrato, spectral and timbric variations to waveforms over time.

Modulation sources and destinations should ideally be completely decoupled. A destination should be able to factor input from a compatible source by establishing a connection between the two.

This generic approach, which originated with modular synthesizers, adds enormous breadth to the range of sounds that can be programmed for an instrument.

The same modulation model that makes these electronic sounds rich can be used in other domains. Modulations can add life and variety to any set of parameters used by a computer program. Non-interactive visual elements can be animated, user feedback can be augmented, AI entity behavior can evolve over time, and much more.

Useful modulators included

When it comes to animating an attribute, be it visual, auditory or behavioral, it is often the case that we want the result to be:

  • Random, unscripted and without a scripted feel
  • Controllable, precisely bound
  • Dependably smooth, with no singularities
  • Physically correct, instinctively pleasing

This crate provides modulators such as ScalarSpring, Newtonian, ScalarGoalFollower and ShiftRegister which, by themselves and in combination, allow the creation of modulations that have some or all of the properties above.

How modulators work

A modulator needs to be able to do at least the following:

  • Return its value at the current moment in time
  • Evolve its status as a function of advancing time

Let m be a value of a type that implements the Modulator trait, then:

let value = m.value();

returns the current value of the modulator. To evolve the modulator by dt microseconds use:

m.advance(dt);

In practice, the latter is rarely done directly, as using an environment (a host for modulators) such as the included ModulatorEnv type, is much more convenient.

Modulator environments

The ModulatorEnv<T> type is an owning host for modulators. Generally, you create one or more environments in your application, such as:

// Somewhere in a struct...
m1: ModulatorEnv<f32>, // hosts modulators that give scalar f32 values

// Somewhere in constructor of that struct...
m1: ModulatorEnv::new(),

The above creates a modulator environment m1 in a struct, probably a modulation struct that collects all state/data related to modulation for the app.

Then, somewhere in the application, the environment must be ticked forward by the elapsed dt microseconds of the current frame, like this:

// Here st is the modulation data struct that contains m1, dt is elapsed micros
st.m1.advance(dt);

The environment advances all the enabled modulators it hosts. It is important to notice two things about ModulatorEnv:

  1. The environment owns the modulators it hosts
  2. The environment is generic in the same value T as its hosted modulators

Point 2 means that, since trait Modulator<T> is generic in T, the value type, then all modulators in an environment must have the same T. All modulator types provided with this crate are Modulator<f32>, that is: their value is a scalar of type f32.

Point 1 means that the lifetime of the modulator is managed by the environment, so you can "create and give" your modulators and let the environment drop them when it is dropped (ModulatorEnv provides methods to manually manage the lifetime of its modulators, if desired).

Here is an example using the Wave modulator. The Wave modulator is the simplest of the included types - it takes a closure/Fn to update its value, and it has amplitude and frequency values. Since it uses a closure it can actually make any signal: a waveform, a constant, a random number, etc. For example:

// Create a sine wave modulator, initial amplitude of 1 and frequency of 0.5Hz
let wave = Wave::new(1.0, 0.5).wave(Box::new(|w, t| {
        (t * w.frequency * f32::consts::PI * 2.0).sin() * w.amplitude
    }));

// Give the modulator to the environment
st.m1.take("wave_sin", Box::new(wave));

This creates a wave modulator that produces a sine with amplitude 1 and frequency of 0.5Hz. The closure receives the modulator w and elapsed time (t: f32) in seconds.

Once created, wave is given to host m1 which takes ownership of it and tags it with key "wave_sin".

Another example:

// Create a wave modulator, amplitude (2.0) here is used to define walk bounds,
// while frequency (0.1) is the random range the value moves each time it advances
let wave = Wave::new(2.0, 0.1).wave(Box::new(|w, _| {
    let n = w.value + thread_rng().gen_range(-w.frequency, w.frequency);
    f32::min(f32::max(n, -w.amplitude), w.amplitude)
}));

// Now give the modulator to the environment
st.m1.take("wave_rnd", Box::new(wave));

This closure offsets the modulator's current value each advance(dt) by a random offset (set by frequency) and caps it between -/+ amplitude. This creates a simple random walk.

Once the modulators above have been created and given to the host, their value can be read anytime as follows:

let v0 = st.m1.value("wave_sin"); // current value of sine modulator
let v1 = st.m1.value("wave_rnd"); // current value of random walk modulator

Modulator details

Notice that modulators should cache their value when they are advanced, which means that, even if advancing could be expensive, reading their value must always be fast. Furthermore, modulators are advanced by the environment all at once to ensure that reading of interdependent values is always consistent.

It is important to notice that modulators are not guaranteed to be reversible. Most will not be, in fact. They can only evolve forward in time.

The reason for this restriction is that, while modulators are generally expected to be frame rate independent (they should express their evolution as a function of time), they are also frequently going to have discrete state changes.

For example, the included modulator ScalarGoalFollower picks a random value, sets it as the goal for a contained sub-modulator, then observes it until it determines that the sub-modulator has arrived to its goal. Once it does, the follower makes a new goal and repeats the process.

This kind of discrete-state, randomized behavior would be costly to make reversible and would require caching of the randomly generated values, amongst other problems. Since being reversible is not critical in the vast majority of applications, the Modulator trait does not make it a part of its contract - versatility is preferred.

Modulators are generally expected to be frame-rate independent, but not required. All of the ones provided with the crate are, even those that include discrete events such as the ScalarGoalFollower described above, and they evolve consistently even with varying frame lengths.

The Wave modulator is a special case, since it uses a closure to compute its value it might or might not be time-based depending on the given function.

Recall the "sine wave" closure we gave to Wave earlier, its implementation is obviously a function of time (and, in this simple case, it would be reversible too). The "random walk" closure, on the other hand, is neither, as the rate at which the value is updated is a function of the number of times advance(dt) is called, rather than elapsed time. A random walk that changes in frequency depending on frame rate would be of limited use, and in production code we would implement a more sophisticated random walk with update rate expressed in changes per second.

Modulator lifetime and interaction

A ModulatorEnv host only knows two things about the modulators it owns:

  1. They implement Modulator<T>
  2. They have the same T (value type)

This means that the only operations the environment can perform on its modulators are the ones defined by the Modulator trait.

While the modulator types provided in sources.rs are all designed specifically for their role as modulators, other types can implement the modulator trait and acquire modulation capabilities (although in such cases they probably won't be stored in an owning environment).

It is clear that ModulatorEnv contents are heterogeneous - the only thing they are known to have in common is that they impl Modulator<T> for the same T as the environment. This is a proper use case for Rust's trait objects, and in fact that's how ModulatorEnv stores the modulators it owns.

Often modulators are created, added to an environment and then factored into calculations at destination points, addressed by the symbolic name that was given to the host when added. For example:

// Here we are updating some value by scaling it with a modulator, source
// is the name of the modulator in environment m1
self.height = self.base + self.range * st.m1.value(source);

Still, at times you will want to access a modulator out of an environment and modify something about it, perhaps to modulate one of its settings by another modulator.

Since ModulatorEnv stores its contents as trait objects, borrowing a modulator back requires knowing its type and downcasting it. Suppose we want to modulate the amplitude of our previous "wave_sin" modulator by another modulator, in the same environment, called "amp_mod":

let ampmod = st.m1.value("amp_mod"); // amplitude modulation value
if let Some(sw) = st.m1.get_mut("wave_sin") { // borrow trait object
    if let Some(ss) = sw.as_any().downcast_mut::<Wave>() { // safely cast it
        ss.amplitude = 1.0 + ampmod; // modify its amplitude attribute
    }
}

Here, we read the current value of "amp_mod" then we mutably borrow a reference to the "wave_sin" trait object. The as_any() method is part of the Modulator trait, so all modulators must implement this conversion, typically just like this:

fn as_any(&mut self) -> &mut Any {
    self
}

Once the trait object has been converted into an Any we use the downcast_mut method to safely convert it to its original type, which of course must be known. In the case above, we downcast to Wave and then modulate the amplitude of "wave_sin" by the current value of "amp_mod".

Notice that, while the ModulatorEnv type is convenient and useful in a large number of cases, it is not required. Countless alternative approaches to hosting modulators are possible, including not having a dedicated host at all. Modulators only need to be accessible and be advanced appropriately, and ModulatorEnv is just one approach to doing so.

Other methods of the Modulator trait

Besides value(), advance() and as_any() the Modulator crate defines several other methods. Mostly these are optional and modulators are not required to implement them in a meaningful manner. See the trait methods for details, and then the implementation for each of the included modulators.

Finally, notice the modulator enabled status methods:

/// Check if the modulator is disabled
fn enabled(&self) -> bool;

/// Toggle enabling/disabling the modulator
fn set_enabled(&mut self, enabled: bool);

Notice that ModulatorEnv checks the enabled status of its modulators and will not advance them if they are disabled. This allows the pausing/unpausing of modulators.

The included modulators

Several modulators are provided in sources.rs. Each is documented locally, but we will provide a summary here.

  1. Wave

Simple modulator using a value closure/Fn, with frequency and amplitude. The closure receives self, elapsed time (in seconds) and returns a new value.

  1. ScalarSpring

Critically damped spring modulator. Moves towards its set goal with smooth seconds of delay, critically damping its arrival so it slows down and stops at the goal without overshooting or oscillation.

If overshooting is desired, positive values of undamp can be set to add artificial overshoot/oscillations around the goal.

  1. Newtonian

A modulator that uses classical mechanics to move to its goal - it guarantees smooth acceleration, deceleration and speed limiting regardless of settings.

The goal calculation computes an analytical solution to the motion equation. When a new goal is set, speed_limit, acceleration and deceleration values are picked from their respective ranges, then movement begins with the value starting from current value with 0 velocity, accelerating at the selected rate up to the speed limit, then decelerating at the selected rate of deceleration so that it is guaranteed to come to a stop at the goal.

The analytical solution to the motion equation ensures that, regardless of input, the value always accelerates and decelerates at the picked rates, and never exceeds the speed max. If there is not enough time to reach peak speed, the value accelerates as much as it it can while ensuring that it will decelerate and come to a stop (0 speed) exactly at goal.

  1. ScalarGoalFollower

A programmable goal follower. Picks a goal within one of its regions for its owned follower modulator, then monitor its progress until the follower gets to threshold distance to the goal and has velocity of vel_threshold or less, at which point it considers it arrived.

Once a goal has been reached, it picks a pause duration microseconds from pause_range, waits for the pause to elapse, then picks a new goal and repeats the process.

This modulator can be given any other modulator type as its owned follower, but a type that is unable to pursue and arrive to its given goal is, of course, never going to satisfy the conditions for arrival.

  1. ShiftRegister

Inspired by classic analog shift registers like those used in Buchla synthesizers, this modulator has a vector of values buckets containing values selected from value_range.

A period of the register is the length of time, in seconds, that the value takes to visit all the buckets in the register. Once a period is over, the value moves back to the first bucket and continues to move.

If interp is ShiftRegisterInterp::None then the value returned corresponds to the current bucket being visited. If it is ShiftRegisterInterp::Linear then it is the linear interpolation of the current bucket and the next. If it is ShiftRegisterInterp::Quadratic then the value is the result of polynomial interpolation of the values of the previous, current and next bucket.

Every time the value leaves a bucket (it is done visiting it for the period) it has odds chances of replacing the value in the bucket it just left, where odds ranges from 0.0 (value never changes) to 1.0 (value always changes).

Parameter age_range can be used to specify an age (in periods) over which the odds of a value changing increase linearly. For example: if odds is set to 0.1 (10%) and age_range is set to [200, 1000) then for the first 200 periods a value's odds of changing are 10%, and between 200 and 1000 periods they increase from 10% to 100%. By default age_range is set to [u32::MAX, u32::MAX] so the odds never change.

The result is that the shift register is periodic and exhibits a pattern (given low enough odds), but still evolves over time in an organic way.

Copyright© 2018-22 Ready At Dawn Studios

Dependencies

~335KB