Responsive Rig Breakdown

Here is the project file.

I learned how to solve problems with expressions from Dan Ebbert’s motionscript.com. His articles show you how to frame a problem, how to break it down, and explain the code for solutions line by line. They gave me the knowledge and skills to tackle technical problems in after effects. In that spirit, here’s my explanation of what’s going on in this project file.

The "responsive rig” problem, or How do you get object properties to respond to one another in a proportional, interrelated way? is a goldilocks puzzle. It’s visually accessible, requires no complex math, but isn’t as easy as it seems. And you learn basic programming in an interactive, graphic context.

My current setup is not as user friendly as I would like, especially without understanding what is going on under the hood. If you need something with a solid UI/UX, check out Zack and Vincent’s Flex.

This project started out as rnd for an audio brand. My team loved the motion for the San Francisco Symphony by Collins as inspiration for sound waves interacting with text.

How else could sound affect text?

Our culture’s main visual vocabulary for waves is the transverse wave.

But sound waves are longitudinal, or compression, waves. Think of compression traveling through a slinky’s coils. (The wifi icon and radio station animations look more like longitudinal waves, even though electromagnetic waves are transverse.)

This was my problem: how do I get the letters of a word to compress and expand together as one interrelated object?

Let’s look at the word “HUM.”

We want the scales and positions of U and M to respond to changes in the scale of H:

But we also want H and M to respond to U, and H and U to respond to M:

We want changes in each letter to affect every other letter:

Connecting each scale and position together would be a nightmare, even with just three letters. And it gets exponentially more complex for each additional letter.

When I’m stumped, I like to keep drawing. Abstract as much as possible.As a diagram or graph, it might look like this:

At the start, I could think only in percentages. Scale percentages. Percentages of the whole. Connecting all the scales (percentages) to one another. Finally I realized it’s first a ratios problem. Similar, but not the same. (I forgot grade school math, but also make rice occasionally and always have to read the ratio of rice to water. Sometimes you have to step away to see what’s in front of you.)

Each section of the whole gets “parts” or "shares” or “weights” of a whole. The whole is the total of all the parts. When you animate the part value of one section, all of the other sections respond proportionally.

This simple approach is the foundation for the “responsive” aspect.

We will do as much of our math as we can with the part values. Then convert them to percentages. Finally we will convert to actual layer property values.

There are two properties we want to be responsive: size and position. We have all the info we need for size, but there is another step for position.

So far we only know how many parts each section is. We don’t know where they are in relation to one another. We have this:

But for our text, we want each section (letter) lined up together like this:

The first section, red, starts at 0. Green starts at the end of red. And blue starts at the end of red plus green:

When you have a series of numbers and add the current number to all the numbers before, it’s called a cumulative sum, aka a running total. Each section’s position is equal to the cumulative sum of the preceding sections' lengths. (If you need the center of each section (we will), subtract half of the current section’s length as you assemble the sections.)

Putting all these steps together to get percentage values for positions:

  1. Get all the weights for each section
  2. Get their positions by getting the running total for each section.
  3. Divide their positions by the total parts

We can scale this line up to whatever length we want.

Let’s build upon our conceptual groundwork and see how it would work with actual After Effects layers and properties.

^Rig Data Flow

Our setup is going to use a “data layer” to calculate all the information about the sections we covered above.

We will use a layer for each object in the rig. An expression on the scale and position of each layer will look up their respective information from the data layer.

Most of the controls for the rig will go on a dedicated controller layer. Each layer will also get a weight slider. To use a field layer to drive those weights, they will need an expression, too.

^Master Controller

Before getting into any code, let’s first look at all the features we want in this rig as indicated by the master controller:

  1. Total number of objects to respond to one another
  2. Total length of all these objects when lined up
  3. Options for the behavior of the x and y scales
    1. Default: Scaling is only driven by the object weight
    2. Fit to Length: The length of the rig also affects scale. Specifically, each object always scales to match the size of its section.
    3. Disable: Scale is not affected by anything
    4. Gutter: Extra width scale to add space between objects by squishing them
  4. Use a field to drive the object weights. Or not. You can keyframe the weights manually.
  5. The position of the field ranges from 0-100 because our position calculations are in percentages. The position of the field in comp space is mapped to this range. The max is the length of the rig.
  6. Same for the size of the field
  7. The strength is a multiplier for the object weights

^Data Layer

The data layer is going to receive all of our inputs from the controller and the layer weights. Let’s get into the main expression that drives the rig.

thisName
We use layer names instead of layer indices so layer order doesn’t mess up the expression. split will split the layer name by the delimiter (an underscore in this case) and store each part as a string in an array (thisName). The responsive layers share a common word (“object”) in their layer name that we will reference later.

numObjects and dist
Get values from controller.

objectWeights and objectCenters
Initialize arrays that will store each object’s weight and position.

weightAccum
The accumulator variable for our cumulative sum calculation

for (var i=0; i < numObjects; i++)
For each object in the rig…

objectWeight = thisComp.layer(thisName[0] + "_" + (i+1)).effect("Object Weight")("Weight");
Get the weight of the current object (in the loop). Use the layer name and the current loop iteration (i) to target the correct layer.

objectWeights.push(objectWeight);
Add the object’s weight as the ith element to the array storing all the object weights.

weightAccum += objectWeight;
Increase the cumulative sum by the current object’s weight.

objectCenters.push(weightAccum - objectWeight/2)
The cumulative sum is equal to the farthest extent of all the weights combined, but we need to know where the center of each segment is. Subtract half the weight of the current object from the cumulative sum and store the result in an array.

weightsSum = objectWeights.reduce((a, b) => a + b);
Array method to get sum of all elements

objectPositions = objectCenters / weightsSum * dist;
Percentage value of each position. Multiplied by the total user defined length of the rig. These positions should equal pixel values in the comp to be used as actual layer positions.

[objectPositions, weightsSum]
Return the positions array and the grand total of all the weights. Text layers will return these values as one array, not a multidimensional array as written. So weightsSum will be the last element of this return value.

^Layer Position

i = parseInt(thisLayer.name.split("_")[1]);
Get the number part of this layer’s name.

p = thisComp.layer("object_data").text.sourceText.split(",")[i-1];
The data layer returns a single array whose elements are separated by commas as text. Split that array by commas and then get the element that matches this layer number (i). To make the layer numbering user friendly, layer names start at one, but arrays start at zero. So we subtract one.

value + [parseFloat(p), 540]
value - We can still add values to this position with user input (ie move this layer)
[parseFloat(p), - Convert the text value from the data layer to a number.
540] - arbitrary y position

^Layer Scale

widthScaling and heightScaling
Get controller values

w
Get this layer’s weight

if(widthScaling == 2 || heightScaling == 2){
We only need the following information if the user selects option two for either the width or the height.

thisWidth = thisLayer.sourceRectAtTime().width;
The width of this layer

rigLength
Get controller value

totalWeights
Get the sum of all object weights

sectionSize = w/totalWeights * rigLength;
Divide this object’s weight by the total weights and multiply by the rig length to get the pixel value length of this object.

Note: Option one for the scaling options is named “Default” to indicate to the user that it’s the default value. Not to be confused with a switch’s default case, which is set to zero for debugging purposes.

switch (widthScaling) {
Start of the switch block for each option of the drop down selector effect control

case 1:
        scaleX = value[0] * w;
      

This layer’s x scale is only affected by the layer’s weight. Retains user defined value as offset.

case 2:
        gutter = thisComp.layer("RIG CONTROLS").effect("Responsive Rig Controls")("Fit to Length Gutter");
        scaleX = sectionSize/(thisWidth+gutter) * 100;
      

This layer’s x scale is stretched or squished to match the length of its section. The length of this section is affected by the layer’s weight and by the rig length. Squish the layers to add gutter spacing between them.

case 3:
        scaleX = value[0];
      

This layer’s x scale is only defined by the user. This option disconnects the layer’s x scale from the rig.

switch (heightScaling) { Start of the switch block for each option of the height drop down selector.

case 1:
        scaleY = value[1] * w;
      

This layer’s y scale is only affected by the layer’s weight. Retains user defined value as offset.

case 2:
          scaleY = sectionSize/thisWidth * 100;
          break;
      

This layer’s y scale is affected by the layer’s weight and the rig length. This option keeps the y scale in proportion to the x scale when “Width Options” is set to “Fit to Length.”

case 3:
          scaleY = value[1];
          break;
      

This layer’s y scale is only defined by the user. This option disconnects the layer’s y scale from the rig.

^Layer Weight

enableField
Get controller value.

if (enableField == true){
Provides on/off option for field. If the field is enabled…

numObjects
Get controller value.

thisObject
We are going to calculate this object’s position in the rig in one shot. We assume all weights are 1.
parseInt(thisLayer.name.split("_")[1]) - Get the index (+1) of this object from the layer’s name.
/numObjects - Divided by the total number of objects gives us the extent of the object (relative to the total, not to itself).
- 1/numObjects/2 - Get the center by subtracting half of an object’s length. When all weights are 1, the length of any object is 1 divided by the total weights.

The position of this object will range from 0-100.

fieldPos
Get controller value. Location of the field, 0-100.

fieldStrength
Get controller value. The strength value will be a multiplier of the the weight.

fieldSize
Get controller value. Where the field begins/ends its effect on the object.

d = Math.abs(fieldPos - thisObject);
Get the absolute value of the distance between the field and the object.

We want the field to have its full effect on the weight when the distance between them is 0. But we also want that effect to “fall off” as this distance increases. And at some distance, the field should have no effect.

if (d < fieldSize/2){
If the field is in range of the object…

multiplyWeight = ease(d, 0, fieldSize/2, fieldStrength, 1);
Map the falloff range to a strength range.

When the field is close to the object (d = 0), the strength will equal the user defined strength of the field (fieldStrength). As the field moves away from the object (d > 0), the strength approaches 1. The strength will equal 1 (have no effect when multiplied) when the distance is equal to or greater than the radius of the field (d >= fieldSize/2).

We want d to affect the strength “softly” at the start and end of the mapping, so we use ease().

} else {multiplyWeight = 1}
If the field is not in range, the multiplier equals 1 (the field will not have an effect on the object’s weight).

value * multiplyWeight
multiply this weight (value) by the strength.

} else {value}
If the field is not enabled, return the current (user defined) weight.

^Field Position

Unlike the rest of parameters in the master controller, we want to control the field position by moving the field in the comp viewer. We need an expression on this property to do this.

dist
Get controller value. Total length of the rig.

fieldSize = effect("Responsive Rig Controls")("Field Size");
Get controller value.

extendRange = fieldSize/2;
Our number line only ranges from 0-100. This is a problem if the field position is outside this range. We need to extend the number line on each end by the radius of the field.

posX = thisComp.layer("FIELD").transform.position[0];
Get the x position of the field (pixel value).

linear(posX, -extendRange/100*dist, extendRange/100*dist + dist, -extendRange, extendRange + 100)
Map the x position of the field to a “number line” position.

The field has an x position in the comp. We only care about x values between 0 and the length of the rig, plus and minus the extensions. We want to map that range to our extended number line. This is a 1:1 mapping, so we use linear().

^Conclusion

We have a bunch of layers and we want them to all respond to one another in position and scale. We give each layer a weight or portion of a whole. Changing the weight of a single layer changes the ratio of all the layers. That is exactly the kind of responsive behavior we want.

We broke this concept down into steps and math. We wrote an expression on a data layer that returns the positions of each object in this rig on a number line. We wrote more expressions on each layer’s position and scale to fetch their respective data from the data layer.

The weight of each layer can be keyframed manually and/or driven by a field. We gave the field a position on the number line so it behaves consistently with the number line positions of the objects, even though it ultimately modifies their positions.