ericchung.dev

WebGL Blobs

Introduction

I’ve run into my fair share of blobs on the internet, and every time I see one I have to figure out how it was made. There are so many ways to make blobs it can be overwhelming, and each has its pros and cons. People have made blobs using 2D canvas, SVG and even pure CSS using border-radius. Heck people have even built tools to make generating blobs even easier! This CSS Tricks article has heaps of great links and info if you want to learn more.

At some point though, I was introduced to the website of a Stockholm based creative design & development studio, 14islands. Blobs! So much blobbage! The blobs were subtle, but not too subtle, mostly appearing on scroll and when hovering certain elements. I could not get this website off my mind and had to know how it was made. I eventually discovered that this effect was made with WebGL.

I had no idea what WebGL was and how it was used. But since then, I’ve decided to learn more about it and finally decided to try and recreate the blob effects myself. In this post I’ll explain the process I took to get there.

Inspiration

14islands image blob effect

The Process

Research

I started by searching the web for examples of a similar effect and managed to find a codepen by @hrahimi270. Link: https://codepen.io/hrahimi270/pen/yLOeWxm

The part of the codepen that got me thinking was this:

const bubbleGeometry = new THREE.CircleGeometry(...);
const bubbleMaterial = new THREE.ShaderMaterial(...);
const bubble = new THREE.Mesh(bubbleGeometry, bubbleMaterial);
scene.add(bubble);
const update = () => {
  ...
  for (let i = 0; i < bubble.geometry.vertices.length; i++) {
    const b = bubble.geometry.vertices[i];
    b.normalize()
      .multiplyScalar(
        1 + 0.3 * simplex.noise4D(
          b.x * spikes,
          b.y * spikes,
          b.z * spikes + time,
          3
        )
      );
  }
}

They were creating a blob (bubble) by multiplying the vertices of the CircleGeometry by a noisy scale factor, while also moving through the noise space over time. Noise functions typically provide you a number between 0 and 1, and so this code is multiplying each vertex by a “noisy” value between 1 and 1.3.

Huh? Multiply a vertex by a single value? I got a bit confused here and did some research. Turns out all we’re doing is multiplying all the values in each vertex vector [x, y, z] by a single number s, where [x, y, z] becomes [sx, sy, sz]. The result of this is a vector that is scaled up or down in the same direction. This YouTube video might help you visualise it if you were as stumped as I was.

Once I understood what was happening I went off to a code sandbox to try creating this effect my own way.

First Attempt

I decided to use React Three Fiber (r3f), to make the ThreeJS part of the setup as easy as possible. I also hadn't used r3f before and wanted to try it out.

ThreeJS is built on top of WebGL and abstracts away all the WebGL code required to draw things to the screen. However, we're still able to write WebGL when we need more control.

By writing our own custom WebGL shaders, we're able to manipulate the vertices and pixels of objects. These shaders are programs written in a shader language called GLSL, which run on the GPU performing calculations in parallel for each pixel or vertex of the object. Efficient!

I started off replicating the codepen I had found, by doing the same thing except using a vertex shader to manipulate the vertices.

In a vertex shader we have access to each of the vertices through the position attribute, provided to us automatically by ThreeJS.

uniform float uTime;
void main() {
  float n = snoise(vec3(uv.x * 0.5, uv.y * 0.5, uTime * 1.0));
  vec3 pos = position * (1.0 + 0.3 * n);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

So what's going on here? I'm importing a GLSL 3D noise function, and doing the same thing as the codepen; multiplying each vertex (position) by a noisy scale factor.

The problem with this method was that once I applied an image texture to the material, the image started getting morphed. It was a cool effect, but not we were looking for. We wanted the blob to instead act as a mask that would expand to reveal the entire image.

New Approach

Rather than manipulating the vertices of the geometry, I thought if we could draw the blob in the fragment shader and use it as a mask on the alpha channel, we might achieve the result we're looking for.

First I replaced the circleGeometry with a planeBufferGeometry.

<planeBufferGeometry args={[5, 5, 1, 1]} />;
{/* <circleGeometry args={[2.5, 128, 128]} /> */}

Then made some changes in the fragment shader.

uniform vec2 uPlaneSize;
uniform vec2 uImageSize;
uniform float uTime;
uniform sampler2D uTexture;
uniform float uSpikes;
uniform float uRadius;

varying vec2 vUv;

void main() {
  vec2 uv = vUv;
  vec2 coverUv = getCoverUv(uv, uPlaneSize, uImageSize);

  // apply image texture
  vec4 texture = texture2D(uTexture, coverUv);
  vec4 color = texture;

  // use circle blob as mask on the color's alpha channel (black === alpha of 0)
  float imageBlobNoise = snoise(vec3(uv.x * uSpikes, uv.y * uSpikes, uTime * 1.0));
  float radius = map(imageBlobNoise, 0.0, 1.0, uRadius*0.9, uRadius);
  float mask = circle(uv, radius);
  color.a = mask;

  gl_FragColor = color;
}

It works! What have we done?

We're now masking the image texture using a blob that we've drawn on the fragment shader. The circle function returns 0 or 1, based on the current pixel's distance from the center of the plane [0.5, 0.5]. And by applying noise to the radius of the circle, we've drawn the same blob, except in the fragment shader.

We can visualise the blob by adding color = vec4(vec3(mask), 1.0); just before gl_FragColor = color;. You'll see a white blob gets drawn, in place of the image texture.

We're able to use this number as the current pixel's alpha value to create the mask effect (color.a = mask;). Anything outside the blob shape now becomes completely transparent, and anything inside remains the same.

vec2 coverUv = getCoverUv(uv, uPlaneSize, uImageSize);

The code for the getCoverUv function comes from a gist that I found. It mimics the background-size: cover css effect for an image texture in GLSL. This allows us to use any size image, ensuring that it fills the plane and doesn't stretch.

float radius = map(imageBlobNoise, 0.0, 1.0, uRadius*0.9, uRadius);

The map function comes from a gist I found, which re-maps a number from one range to another. In this case we are remapping the noise value to a range of uRadius*0.9 to uRadius.

This just made more sense to me, than using float radius = uRadius * (1.0 + 0.3 * n);, and allowed me to be more explicit about the range.


Mouse Interaction

Okay, now for that interactive goodness.

The key to this was GSAP. The gsap.to() method allows us to transition any arbitrary value to another value, smoothly.

By transitioning the uniform values that I pass into the shaders on mouseenter and mouseleave, we automatically get smooth animations in the shader effect.

Blob Expand

I decided to trigger the blob expand effect on mouseenter, rather than as it scrolls onto the screen.

To achieve the effect I transitioned uRadius up from 0.5 to 1.5 so that the blob completely fills the plane. I transitioned the uTime speed from 0.006 to 0.02 to speed up the blob jiggle as it expands. And finally I transitioned the uSpikes value from 1.5 to 2.5 to create more waviness as it expands.

I made the transition out durations a little slower so we can see those values gradually transition back to their default state.

Blob Cursor

My approach to drawing a blob under the cursor was to use a raycaster. A raycaster casts an invisible ray from the camera origin to wherever the cursor is pointing. If it intersects anything, we're able to access to those objects, and with them a bunch of properties. One of these properties is the exact uv coordinate that the cursor is hovering over, which is exactly what we need!

Passing this uv into the shader as uMousePos, we are now able to draw another smaller blob wherever our cursor, is in the same fashion as the blob mask.

One thing I struggled with was finding a way to give the cursor blob a color. The way I managed to do it was using the built in GLSL mix function.

vec3 cursorColor = mix(color.rgb, uColor, circle(uv, cursorRadius, uMousePos));
color = vec4(cursorColor, 1.0);

Another subtle touch that 14islands added was making the cursor blob larger toward the edges of the image. The way I achieved this was by increasing the mouse radius we passed into the shader based on the mouse position's distance from the center [0.5, 0.5].

float mouseRadius = uMouseRadius;
...
mouseRadius = mouseRadius * (1.0 + smoothstep(0.15, 0.45, distance(uMousePos, vec2(0.5))));

Tilt (just for fun)

The tilt happens on the React side of the code. We calculate a tilt for the x and y axis on mousemove and apply this to the rotation property of the plane; of course transitioning this with GSAP.

const handlePointerMove = (e) => {
  tilt.x = -1.0 * (((e.clientX / window.innerWidth) * 2 - 1) * 0.5);
  tilt.y = -1.0 * (((e.clientY / window.innerHeight) * 2 - 1) * 0.5);
}
...
useFrame(({ scene, camera }) => {
  if (ref.current) {
    gsap.to(ref.current.rotation, {
      x: tilt.y,
      y: tilt.x,
      duration: 0.4,
    });
  }
}

Final Result

Obviously the end result doesn't exactly replicate the effect 14islands has, but I think it's pretty close!

Thanks for reading!