Article cover image

Crafting Interactivity: How Monogram Built the Engaging Animations for Angular.dev's Homepage (Part 2)

  • Patrick
    Patrick
    Senior Interactive Engineer

Here we’ll cover the specifics of how the View base class applies its properties to the WebGL scene graph using TRSA values, and a deep dive into the GLSL shader code of the various WebGL elements.

TRSA values

The View base class itself is really just a number of stub methods for theme(), resize(), update(), and ready(), plus a userData object property to mirror the convention from three.js’ userData.

The userData object is where GSAP updates all the values needed for the animation, and then each of the views that extend the View base class applies the values to itself, each with their own update() method implementation.

The reason why it’s setup this way, is both to give full control to each of the views on what it needs, and to keep the code minimal, only what the view needs should be in the update() method.

In general though there are two similar patterns needed, for a regular Mesh and an InstancedMesh (a bit more complicated but we’ll cover that below).

override update(): void {
  this.position.x = this.userData['x'];
  this.position.y = -this.userData['y']; // Y flipped

  this.rotation.z = -toRadians(this.userData['rotation']);

  this.scale.set(this.userData['scale']);

  this.mesh.program.uniforms['uAlpha'].value = this.userData['opacity'];
}

Transform and origin

The transform part of this method is where additional code for setting a transform origin can be applied.

this.position.x = this.origin.x + this.userData['x'];
this.position.y = -this.origin.y + -this.userData['y']; // Y flipped

Note that the Y axis is flipped because the object coordinates are a Y up system, so positive values go up from the center.

Y up system

A 3D Cartesian coordinate system, from: https://discoverthreejs.com/book/first-steps/transformations/

Rotation

Here we convert from degrees to radians on the Z axis because we’re moving 2D plane geometries that are already facing forward.

this.rotation.z = -toRadians(this.userData['rotation']);

Note that rotation in radians is a negative value. This may seem counter intuitive, because we think of rotation like a clock, but the math for degrees and radians is actually the opposite direction, a positive value rotates counter-clockwise, and a negative value rotates clockwise.

Image for Crafting Interactivity: How Monogram Built the Engaging Animations for Angular.dev's Homepage (Part 2)

Counterclockwise rotations, from: https://en.wikipedia.org/wiki/Turn_(angle)

Scale

The geometry should already scale from the center of the transform origin above, no additional math is needed for this one.

this.scale.set(this.userData['scale']);

Alpha

Here we’re instead updating the Program (or Material for three.js) where the transparency of the geometry is applied, taking into account the parent’s transparency if it’s a child view.

this.mesh.program.uniforms['uAlpha'].value =
 this.userData['opacity'] * this.parent!.userData['opacity'];

GLSL shaders

Other than the MSDF shaders and gradient texture covered in the previous article, there are few additional techniques we’ll cover here, all of-which are simple shaders for the mask effect, moving line gradients and "Build for everyone" reveal transition.

Mask effect

[video]

The shader for this effect is very simple, there are a number of ways this can be achieved with GLSL and in this case we’re simply making the transparency of the gradient (tMap) equal the green channel of the mask (tMask):

gl_FragColor = texture2D(tMap, vUv);
gl_FragColor.a = texture2D(tMask, vUv).g;

Why green you may ask, it’s a common convention in video games, graphics programming and applications to use the green channel for sampling transparency, and for comparison the alphaMap property in three.js.

So you can think of tMask as an alpha map for specifying which parts of the gradient should be transparent or opaque, where the Angular logo is output as the opaque parts of the gradient.

The full code for the mask shader.

Line gradients

[video]

It’s a very subtle detail, though there are multiple gradients in the lines, randomly selected for each instance, and slowly animated in a loop.

To loop each of the gradients, the beginning and end colors must be the same, then the UV position for the line is tiled/wrapped-around and slowly moved horizontally.

vec2 uv = vUv;
uv.x += vInstanceRandom * uTime * 0.5;

uv.x = fract(uv.x); // Wrap around 1.0

// Linear gradient, mirrored for wrapping
vec3 color = mix(vColor[0], vColor[1], smoothstep(0.0, 0.3333, uv.x));
color = mix(color, vColor[1], smoothstep(0.3333, 0.6666, uv.x));
color = mix(color, vColor[0], smoothstep(0.6666, 1.0, uv.x));

Line instances

The most complicated part of this animation is arguably the lines animation, both because the geometry is an InstancedMesh where all the lines are rendered in a single draw call, and then animating each of the instances separately, like they are all separate meshes, but they are not.

As mentioned above the update() method for an InstancedMesh is a little different, where each of the instances are animated with LineObject children views:

override update(time: number): void {
  this.position.x = this.userData['x'];
  this.position.y = -this.userData['y']; // Y flipped

  this.rotation.z = -toRadians(this.userData['rotation']);

  this.scale.set(this.userData['scale']);

  this.mesh.program.uniforms['uTime'].value = time;

  this.children.forEach((node: Transform) => {
    if (node instanceof View) {
      node.update();
    }
  });

  this.mesh.geometry.attributes['instanceMatrix'].needsUpdate = true;
  this.mesh.geometry.attributes['instanceOpacity'].needsUpdate = true;
}

LineObject instance update() method:

override update(): void {
  this.position.x = this.origin.x + this.userData['x'];
  this.position.y = this.origin.y + -this.userData['y']; // Y flipped

  this.rotation.z = -toRadians(this.userData['rotation']);

  this.scale.set(this.userData['scale']);

  this.updateMatrix();

  this.matrix.toArray(this.mesh.geometry.attributes['instanceMatrix'].data, this.index * 16);

  this.mesh.geometry.attributes['instanceOpacity'].data!.set(
    [this.userData['opacity'] * this.parent!.userData['opacity']],
    this.index,
  );
}

Note we can get away with an alpha transparency on an instanced mesh because the lines don’t really overlap each-other. Instanced meshes are rendered in a single draw call, and any overlapped parts of the geometry would appear transparent, in other words they aren’t blended together like separate draw calls of different meshes, if that makes sense.

The full code for the line glyph shader.

The "Build for everyone" transition

[video]

Finally, there’s another subtle detail with the reveal of the "Build for everyone" headline, that transitions from an unfilled gray color, to the gradient mask, with an angle.

if (uProgress > 0.0) {
	// Anti-aliased gray unfilled angle
	float theta = radians(20.0);
	uv = vec2(cos(theta) * vUv.x - sin(theta) * vUv.y,
						sin(theta) * vUv.x + cos(theta) * vUv.y);

	float progress = 2.0 * uProgress - 1.0;
	float d = 0.001;
	float angle = smoothstep(uv.x - d, uv.x + d, progress);

	gl_FragColor.rgb = mix(uGrayColor, gl_FragColor.rgb, angle);
}

The code for the transition is part of the gradient shader used throughout the animation.

Conclusion

Overall, what seems like a simple animation has a lot of little details, and applies a number of WebGL and GLSL techniques. Hopefully you’ve found this technical case study interesting and can apply some of the techniques here in your own work!

We love open source

Checkout the code yourself in the Angular GitHub repo, and our CodeSandbox!

Other articles in the series

Make sure to check out Crafting Interactivity: How Monogram Built the Engaging Animations for Angular.dev's Homepage (Part 1).