Modulator
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:
- The
Modulator<T>
trait definition - An environment (host) type for modulators
ModulatorEnv<T>
- 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 -- Whenundamp==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 defaultstd
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
:
- The environment owns the modulators it hosts
- 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:
- They implement
Modulator<T>
- 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.
Modulator
trait
Other methods of the 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.
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.
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.
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
.
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.
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