Case Study: Chang Liu Portfolio V4

Get insight on how to add 3D to a website using Three.js and make a wavy distortion effect in this case study of Chang Liu’s new portfolio.

Recently, I rehauled my personal website in 3D using Three.js. In this post, I’ll run through my design process and outline how I achieved some of the effects. Additionally, I will explain how to achieve the wavy distortion effect that I use on a menu.

Objective

The goal was to highlight my work in a logical way that was also creative enough to stand as a portfolio piece itself. I started coding the site in 2D, deriving concepts from its previous version. Around the time however, I was also starting my first Three.js project under UCLA’s Creative Labs while passively admiring 3D projects during my time at Use All Five. So several months later, after I already finished the bulk of the 2D work, I decided to make the leap to 3D.

The site in 2D, then the first iteration in 3D

Challenges

3D animations were not exactly easy to prototype. Coupled with my own inexperience in 3D programming, the biggest challenge was finding a middle ground between what I wanted and what I was capable of making i.e. being ambitious but also realistic.

I also discovered that my creative process was very ad-hoc and collage-like; whenever I came across something I fancied, I tried to incorporate that into the website. What resulted was a jumble of different interactions that I needed to somehow unify.

The last challenge was a matter of wanting to depart from my previous style of design but also to stay minimalistic and clean.

1. Cohesiveness & Unification

Vincent Tavano’s portfolio heavily inspired me in the way that it unified a series of very disjointed projects. I applied the same concept by making each project page a unique experience, unified by a common description section. This way, I was able to experiment and add different interactions to each page while maintaining a thematic portfolio.

Project pages with a common header and varying interactive content

Another pivotal change was consolidating two components on the homepage. Originally, I had a vertical carousel as well as a vertical menu that both displayed the same links. I decided to cut this redundancy out and combine them into one component that transforms from a carousel to a menu and vice versa.

2. Contrast & Distortion

My solution to creating experimental yet minimalistic UI was to utilize contrast and distortion. I was able to keep the clean look of sharp planes but also achieve experimental looks by applying distortion effects on hover. The contrast of sharp, rigid planes to wavy, flowy planes, sans-serif to serif types, straight arrows to circular loading spinners and white text to negative colored text also helped me distinguish this version from the homogeneously designed previous site.

Rectangular planes on the home and about pages that distort on mouse events to add an experimental feel

Using blend modes to add contrast in color in an otherwise monochromatic site

Creating the Wavy Menu Effects

Now I will go over how I achieved the wavy distortion effect on my planes. For the sake of simplicity, we will use just one plane for the example instead of a carousel of planes. I am also assuming basic knowledge of the Three.js library and GLSL shader language so I will skip over commonly used code like scene initialization.

1. Measuring 3D Space Dimensions

To begin with, we need to be comfortable converting between pixels and 3D space dimensions. There is a simple way to calculate the viewport size at a given z-depth for a scene using PerspectiveCamera:

const getVisibleDimensionsAtZDepth = (depth, camera) => {
  const cameraOffset = camera.position.z;

  if (depth < cameraOffset) depth -= cameraOffset;
  else depth += cameraOffset;

  const vFOV = (camera.fov * Math.PI) / 180; // vertical fov in radians

  // Math.abs to ensure the result is always positive
  const visibleHeight = 2 * Math.tan(vFOV / 2) * Math.abs(depth);
  const visibleWidth = visibleHeight * camera.aspect;

  return {
    visibleHeight,
    visibleWidth
  };
};

Our scene is a fullscreen canvas so the pixel dimensions would be window.innerWidth × window.innerHeight. We place our plane at z = 0 and the 3D dimensions can be calculated by getVisibleDimensionsAtZDepth(0, camera). From here, we can get the visibleWidthPerPixel by calculating window.innerWidth / visibleWidth, and likewise for the height. Now if we wanted to make our plane appear 300 pixels wide in the 3D space, we would initialize its width to 300 × visibleWidthPerPixel.

2. Creating the Plane

For the wavy distortion effects, we need to apply transformations to the plane’s vertices. This means when we initialize the plane, we need to use THREE.ShaderMaterial to allow for shader programs and THREE.PlaneBufferGeometry to subdivide the plane into segments. We will also use the standard THREE.TextureLoader to load an image to map to our plane.

One more thing to note is preserving the aspect ratio of our image. When you initialize a plane and texture it, the texture will stretch or shrink accordingly depending on the dimensions. To achieve a CSS background-size: cover like effect in 3D, we can pass in a ratio uniform that is calculated like so:

const ratio = new Vector2(
  Math.min(planeWidth / planeHeight / (textureWidth / textureHeight), 1.0),
  Math.min(planeHeight / planeWidth / (textureHeight / textureWidth), 1.0)
);

Then inside the fragment shader we will have:

uniform sampler2D texture;
uniform vec2 ratio;
varying vec2 vUv;

void main(){
  vec2 uv = vec2(
    vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );

  gl_FragColor = texture2D(texture, uv);
}

I recommend setting a fixed aspect ratio and dynamic plane width to make the scene responsive. In this example I am setting planeWidth to half the visibleWidth and then calculating the height by multiplying that by my fixed aspect ratio of 9/16. Also note that when we initialize the PlaneBufferGeometry, we are passing in whole numbers that are proportional to the plane dimensions for the 3rd and 4th argument. These arguments specify the horizontal and vertical segments respectively; we want the number to be large enough to allow the plane to bend smoothly but not too large that it will impact performance – I am using 30 horizontal segments.

3. Passing in Other Uniforms

We have the fragment shader all set up now but there are several more uniforms we will need to pass to the vertex shader:

  1. hover – A float value in the range [0, 1] where 1 means we are hovering over the plane. We will use GSAP to tween the uniform so that we can have a smooth transition into the wavy effect.
  2. intersect – A 2D vector representing the uv coordinates of the texture that we are hovering over. To get this value, we first need to store the user’s mouse position as normalized device coordinates in the range [-1, 1] and then raycast the mouse position with our plane. The THREE.js docs on raycasting includes all the code we need to set that up
  3. time – A continuously changing float value that we update every time in the requestAnimationFrame loop. The wavy animation is just a sine wave so we need to pass in a dynamic time parameter to make it move. Also, to save on potentially large computations, we will clamp the value of this uniform from [0, 1] by setting it like: time = (time + 0.05) % 1 (where 0.05 is an arbitrary increment value).

4. Handling Mouse Events

As linked above, the THREE.js Raycaster docs give us a good outline of how to handle mouse events. We will add an additional function, updateIntersected, in the mousemove event listener with logic to start our wave effect and small micro animations like scaling and translating the plane.

Again, we are using the GreenSock library to tween values, specifically the TweenMax object which tweens one object and the TimelineMax object which can chain multiple tweens

The Raycaster intersectObject function returns an array of intersects, and in our case, we just have one plane to check so as long as the array is non-empty then we know we are hovering over our plane. Our logic then has two parts:

  1. If we are hovering over the plane, set the intersect uniform to the uv coordinates we get from the Raycaster and translate the plane in the direction of the mouse (since normalized device coordinates are relative to the center of the screen, it’s very easy to translate the plane by just setting the x and y to our mouse coordinates). Then, if it’s the first time we’re hovering over the plane (we track this using a global variable), tween the hover uniform to 1 and scale the plane up a bit.
  2. If there is no intersection, we reset the uniforms, scale and position of the plane

5. Creating the Wave Effect

The wave effect consists of two things going on in the shader:

1. Applying a sine wave to the z coordinates of the plane’s vertices. We can incorporate the classic sine wave function y = A sin(B(x + C)) + D into our own shader like so:

float _wave = hover * A * sin(B * (position.x + position.y + time));

A is the wave’s amplitude and B is a speed factor that increases the frequency. By multiplying the speed by position.x + position.y + time, we make the sine wave dependent on the x & y texture coordinates and the constantly changing time uniform, creating a very dynamic effect. We also multiply everything by our hover uniform so that when we tween the value, the wave effect eases in. The final result is a transformation that we can apply to our plane’s z position.

2. Restricting the wave effect to a certain radius around the mouse

Since we already pass in the mouse location as the intersect uniform, we can calculate whether the mouse is in a given hoverRadius by doing:

float _dist = length(uv - intersect);
float _inCircle = 1. - (clamp(_dist, 0., hoverRadius) / hoverRadius);
float _distort = _inCircle * _wave;

The inCircle variable ranges from 0 to 1, where 1 means the current pixel is at the center of the mouse. We multiply this by our final effect variable so we get a nice tapering of the wavyness at the edge of the radius.

Experiment with different values for amplitude, speed and radius to see how they affect the hover effect.

Tech Stack

  • React – readable component hierarchy, easy to use but very hard to handle page transitions and page load animations
  • DigitalOcean / Node.js – Linux machine to handle subdomains, rather than using static Github Pages
  • Contentful – very friendly CMS that is API only, comes with image formatting and other neat features
  • GSAP / Three.js – GSAP is state of the art for animations as it comes with so many optimizations for performance; Three.js on the other hand is a 500kb library and if I were to do things differently I would try to just use plain WebGL to save space

Chang Liu

Frontend developer and freelance photographer passionate about the intersection of art and tech. UCLA '17, currently at Houzz.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!