Into Vertex Shaders Part 4: Form Follows Function

Szenia Zadvornykh
8 min readAug 1, 2017

This is the fourth and final in a series of articles about advanced WebGL animation using Three.js and Three.bas, my extension for complex and highly performant animation systems.

In the previous post we created a utilitarian animation to demonstrate how Three.bas operates. In this post we will continue down the rabbit hole and explore how we can use vertex shaders to create more pleasing animation systems.

Easing

First, lets take a look at easing. This is a topic most of you should at least be casually familiar with. The standard easing functions, first formalized by Robert Penner, have made their way into practically every animation tool and library. Simply put, easing functions change the curve of how a value changes over time. These functions also integrate smoothly with GLSL.

The simplest easing function is linear, or no easing at all.

function easeLinear(t, b, c, d) {
return b + (t / d) * c;
}

The four arguments are as follows:

  • time: The current time in the animation (relative to duration).
  • begin value: The initial value of the property being animated.
  • change in value: The delta of the property being animated.
  • duration: The total duration of the animation (relative to time).

These four arguments are the same for all other easing functions. Using a different easing function will change the animation curve while keeping the same signature, making them easy to swap in and out.

Below you can see the quad-in ease function and its GLSL equivalent.

// js
function easeQuadIn(t, b, c, d) {
return b + (t / d) * (t / d) * c;
}
// glsl
float easeQuadIn(float t, float b, float c, float d) {
return b + (t / d) * (t / d) * c;
)

Other than the differences in syntax, the GLSL function is just as simple and compact as the JavaScript version.

Easing functions can be simplified further if both the change in value and the animation duration are in the range of 0.0 to 1.0 (the value changes from 0.0 to 1.0 over 1 second).

// original quad-in formula
return b + (t / d) * (t / d) * c;
// begin = 0.0, change = 1.0
return 0.0 + (t / d) * ( t / d) * 1.0;
// 0.0 and 1.0 can be removed safely
return (t / d) * (t / d);
// d = 1.0
return (t / 1.0) * (t / 1.0);
// 1.0 can be removed safely
return t * t;

The same process can be applied to all other easing functions, giving us functions that take a single argument (time) and return an eased value. In the previous post, we determined the animation state based on a progress value between 0.0 and 1.0. This will match up nicely.

Three.bas comes with GLSL implementations of all the standard Penner easing functions in both the four and the single argument variants. It also comes with a Bézier curve based easing implementation similar to CSS.

Now let’s add some easing to the cube animation we created in the previous post. This is easy; all we have to do is add a few lines in our material definition.

material = new THREE.BAS.PhongAnimationMaterial({
vertexFunctions: [
// add the easeCubicInOut function
THREE.BAS.ShaderChunk['ease_cubic_in_out']
],
vertexPosition: [
// calculate progress between 0.0 and 1.0
'float p = clamp(time - startTime, 0.0, d) / d;',

// apply easing function
'progress = easeCubicInOut(progress);',
// ... rest of animation logic
]
});

Below you can see the animation with easing applied to it, which makes the motion look more natural. Feel free to switch up the easing functions to see how the motion is effected.

Scale & Rotation

So far we have only animated position, but that’s not the only (affine) transformation we can use.

Scale is pretty easy to implement. All we have to do multiply the vertex position by either a number (for the same scale across all axes) or a vector (for different scale across the axes).

float scale = 0.5 // or calculate with progress and/or attributes// apply the scale scale to x, y and z
transformed *= scale;

Implementing rotation is a little more involved. The most obvious way may be to use a matrix, and fold translation and scale into it. However, this means storing 16 numbers per vertex. It also removes the ability to calculate position, scale, and rotation independently.

After looking at possible solutions, I settled on using quaternions. A quaternion defines rotation based on a normalized axis (x, y, z) and a rotation around that axis in radians (w). Four numbers in total, which fit neatly into a vec4. The quaternion can then be applied to a vector to rotate it.

// create a quaternion from an axis and angle
vec4 quatFromAxisAngle(vec3 axis, float angle) {
float halfAngle = angle * 0.5;
return vec4(axis.xyz * sin(halfAngle), cos(halfAngle));
}
// apply the quaternion (q) to a vector (v)
vec3 rotateVector(vec4 q, vec3 v) {
return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);
}

Please don’t ask me how or why any of this works. Just think of a quaternion as a kind of pseudo-matrix for rotation alone.

The two functions above are included in Three.bas. They can be used the same way as the easing functions. To animate the rotation, you can calculate the axis and/or angle based on our trusty progress between 0.0 and 1.0.

material = new THREE.BAS.PhongAnimationMaterial({
vertexFunctions: [
// add quatFromAxisAngle and rotateVector
THREE.BAS.ShaderChunk['quaternion_rotation']
],
vertexParameters: [
'attribute vec4 rotation;'
],
vertexPosition: [
// rotation angle based on progress
'float angle = rotation.w * progress;',
// quaternion based on axis and current angle
'vec4 quat = quatFromAxisAngle(rotation.xyz, angle);'
// apply quaternion to vector to rotate it
'transformed = rotateVector(quat, transformed);'
]
});

Let’s add some scale and rotation to our animation. Check out the JavaScript for more details about the implementation.

Interpolations

Up until now have mostly used linear interpolation to animate movement. This works quite well, but it may get boring after a while. Fortunately there are many, many functions and equations we can use instead.

Bézier Curves

Bézier curves are one step up from linear interpolation. They are ubiquitously used in many types of graphics software and graphics libraries. A Bézier curve is defined by a start point, end point and two control points.

Positions along the curve can be interpolated based on a value between 0.0 and 1.0. The GLSL implementation of this can be seen below.

// p0: start position
// c0: control point 1
// c1: control point 2
// p1: end position
vec3 cubicBezier(vec3 p0, vec3 c0, vec3 c1, vec3 p1, float t) {
float tn = 1.0 - t;
return
tn * tn * tn * p0 +
3.0 * tn * tn * t * c0 +
3.0 * tn * t * t * c1 +
t * t * t * p1;
}

This formula may be imposing, but once you define it you can simply use it over and over again. The cubic Bézier is also included in Three.bas. To use it, we have to include it the same way as the functions above. We also need to define two additional vertex attributes for the control points.

material = new THREE.BAS.PhongAnimationMaterial({
vertexFunctions: [
// add cubicBezier function
THREE.BAS.ShaderChunk['cubic_bezier']
],
vertexParameters: [
'attribute vec3 controlPoint0;',
'attribute vec3 controlPoint1;'
],
vertexPosition: [
// names abbreviated to fit on one line
// s = startPosition
// c0 = controlPoint0
// c1 = controlPoint1
// e = endPosition
'transformed += cubicBezier(s, c0, c1, e, progress);'
]
});

Below you can see our animation with the Bézier curve used to interpolate position. Check out the JavaScript for the full code. The logic to define the control points is completely arbitrary, so feel free to experiment.

Catmull-Rom Spline

A Catmull-Rom spline defines a smooth curve through four or more points. This is similar to a Bézier curve, but the line actually goes through the points instead of only being pulled towards them. Since we are not limited to just four points, we can create complex and smooth motion paths. This does however make splines more difficult to implement.

We will store the spline as an array of points. We could use an attribute for this, but if the spline is long, storing (and duplicating) the data per vertex will become a bottleneck of its own. Instead I tend to store the spline as a uniform, a master spline if you will. Then I use attributes to store offsets from this spline, giving each prefab its own path to follow. Since we only need 3 components (x, y, z) to store the path itself, I often use the 4th component (w) to scale this offset at each point of the spline. Spline interpolation can be applied in any number of dimensions.

In the next example you can see our brave little cubes following a spline using the approach outlined above. The master spline and the offset scales are shown in dark gray. Please refer to the JavaScript for the full implementation (this setup is a little different from what we had before).

Parametric functions

Another wealth of interpolation options can be found in parametric functions. Parametric functions take a one dimensional input (time or progress) and return a multi-dimensional output (position). A basic example of this is using sine and cosine to calculate a position on circle with a given radius.

uniform float radius;vec2 sample(float progress) {
vec2 position;
float angle = progress * PI * 2.0;
position.x = cos(angle) * radius;
position.y = sin(angle) * radius;
return pos;
}

Using this as a basis you can define an endless variety of curves an knots that once again can be interpolated based on a progress value. In the example below we use sine and cosine to create a spiral on the x and z axis, while using linear interpolation along the y axis.

Again, this is a very basic example, but it should illustrate the principle. Parametric functions can get very complex, creating beautiful shapes and motion paths. Do note however that trigonometric functions (like sine and cosine) tend to be on the expensive side.

I hope that this post, along with the ones before it, have given you a better understanding of the inner workings of WebGL and Three.js, along with the tools to help you harness their power. We (myself included) have only scratched the surface of what is possible here. To all of you who want to venture deeper, I wish good luck. And thanks for reading.

For more examples, documentation, and other tidbits, check out the Three.bas github page or my CodePen profile.

--

--

Szenia Zadvornykh

Creative coder with an interest in art and game design. Tech Lead @dpdk NYC.