@MaximeHeckel

Building a Vaporwave scene with Three.js

January 25, 2022 / 25 min read

Last Updated: December 31, 2023

After spending months in my backlog of things to explore, I finally made the jump and started learning Three.js 🎉. I've followed the Three.js journey course from @bruno_simon for a few weeks now, and it's been an eye-opener. It feels like it just unlocked a new realm of possibilities for me to spend time doing more creative coding.

While going through the course, there was a moment where I felt that I needed to explore and build something on my own to apply what I've learned.

Day 1: @0xca0a 's https://t.co/YCiA05AdL0 great intro to React Three Fiber Day 2-4: @bruno_simon 's Three.js Journey (50% done) Day 5: First case study: Rebuilding a scene that I like a lot by simply guessing, and applying what I learned ⚡️ Will write up about all that soon 👀

One project I had in mind was to reverse-engineer the WebGL animation from Linear's 2021 release page and try rebuilding it to see how close I could get from the source material. Since I saw this scene on my timeline last June, I've been a bit obsessed with it. I absolutely love the vaporwave/outrun vibe of this animation and I think the developers and designers involved in this project did an incredible job 👏✨. On top of that, this scene happens to touch upon a wide range of key Three.js concepts which was perfect as a first project!

In this blog post, we're going to take a look at the thought process and the steps I took to rebuild this vaporwave Three.js scene by using nothing but fundamental constructs that I recently learned. If you do not want to wait until the end of this article to see the result, you can head over to https://linear-vaporwave-three-js.vercel.app/ to get a nice preview 😛.

I added editable code snippets with their corresponding rendered scene (including comments) throughout the article for each key step of this project. You will be invited to modify them and observe how some of the changes impact the final render of the Three.js scene 😄.

Setting up the scene

First, we need to do some initial setup to have everything we need to build our scene. To render a Three.js scene, you need the following key elements:

  • ArrowAn icon representing an arrow
  • ArrowAn icon representing an arrow
    A Mesh, with both a material and a geometry.
  • ArrowAn icon representing an arrow
  • ArrowAn icon representing an arrow
  • ArrowAn icon representing an arrow
    Some event listeners for resizing and animations

Basic Three.js scene

1
import * as THREE from 'three';
2
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3
4
const canvas = document.querySelector('canvas.webgl');
5
6
// Scene
7
const scene = new THREE.Scene();
8
9
// Objects
10
/**
11
* Here I use a Plane Geometry of width 1 and height 2
12
* It's also subdivided into 24 square along the width and the height
13
* which adds more vertices and edges to play with when we'll build our terrain
14
*/
15
const geometry = new THREE.PlaneGeometry(1, 2, 24, 24);
16
const material = new THREE.MeshBasicMaterial({
17
color: 0xffffff,
18
});
19
20
const plane = new THREE.Mesh(geometry, material);
21
22
// Here we position our plane flat in front of the camera
23
plane.rotation.x = -Math.PI * 0.5;
24
plane.position.y = 0.0;
25
plane.position.z = 0.15;
26
27
scene.add(plane);
28
29
// Sizes
30
const sizes = {
31
width: window.innerWidth,
32
height: window.innerHeight,
33
};
34
35
// Camera
36
const camera = new THREE.PerspectiveCamera(
37
// field of view
38
75,
39
// aspect ratio
40
sizes.width / sizes.height,
41
// near plane: it's low since we want our mesh to be visible even from very close
42
0.01,
43
// far plane: how far we're rendering
44
20
45
);
46
47
// Position the camera a bit higher on the y axis and a bit further back from the center
48
camera.position.x = 0;
49
camera.position.y = 0.06;
50
camera.position.z = 1.1;
51
52
// Controls
53
// These are custom controls I like using for dev: we can drag/rotate the scene easily
54
const controls = new OrbitControls(camera, canvas);
55
controls.enableDamping = true;
56
57
// Renderer
58
const renderer = new THREE.WebGLRenderer({
59
canvas: canvas,
60
});
61
renderer.setSize(sizes.width, sizes.height);
62
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
63
64
// Event listener to handle screen resize
65
window.addEventListener('resize', () => {
66
// Update sizes
67
sizes.width = window.innerWidth;
68
sizes.height = window.innerHeight;
69
70
// Update camera's aspect ratio and projection matrix
71
camera.aspect = sizes.width / sizes.height;
72
camera.updateProjectionMatrix();
73
74
// Update renderer
75
renderer.setSize(sizes.width, sizes.height);
76
// Note: We set the pixel ratio of the renderer to at most 2
77
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
78
});
79
80
// Animate: we call this tick function on every frame
81
const tick = () => {
82
// Update controls
83
controls.update();
84
85
// Update the rendered scene
86
renderer.render(scene, camera);
87
88
// Call tick again on the next frame
89
window.requestAnimationFrame(tick);
90
};
91
92
// Calling tick will initiate the rendering of the scene
93
tick();

I know..., it can feel a bit overwhelming. But don't worry! Let's take some time to break down each one of these elements.

How to define a Three.js scene

1
// Canvas code...
2
3
// Scene
4
const scene = new THREE.Scene();
5
6
// Objects code...

First, we have the scene. This is the container that holds the objects we will render.

How to define a Three.js mesh

1
// Scene code...
2
3
// Objects
4
const geometry = new THREE.PlaneGeometry(1, 2, 24, 24);
5
const material = new THREE.MeshBasicMaterial({
6
color: 0xffffff,
7
});
8
9
const plane = new THREE.Mesh(geometry, material);
10
11
// Sizes code...

Then we define the objects that will be added to our scene. For our project, we only have one: just a simple plane. I chose to start with a plane because we're working on a landscape. There are, of course, many other geometries available but we won't need any other for our vaporwave scene.

A Three.js object is always defined using 2 key elements:

  1. ArrowAn icon representing an arrow
    Geometry: the shape of our object. Here we use the Three.js PlaneGeometry which represents a plane. I gave it a width of 1 "unit", and a height of 2 "units" on purpose because I want this plane where our landscape will be rendered to feel "long". It's also subdivided in 24 segments on its width and height, this is to give us more vertices to play with and let us shape our plane with a bit more detail.
  2. ArrowAn icon representing an arrow
    Material: how the object looks. Here I used the MeshBasicMaterial which is the simplest material you can use in Three.js. In this case, I set the color to white so our plane will be white in our scene

By combining the geometry and the material you get our object which is also called a mesh.

How to define a Three.js camera

1
// Sizes code...
2
3
// Camera
4
const camera = new THREE.PerspectiveCamera(
5
// field of view
6
75,
7
// aspect ratio
8
sizes.width / sizes.height,
9
// near plane: it's low since we want our mesh to be visible even from very close
10
0.01,
11
// far plane: how far we're rendering
12
20
13
);
14
15
// Position the camera a bit higher on the y axis and a bit further back from the center
16
camera.position.x = 0;
17
camera.position.y = 0.06;
18
camera.position.z = 1.1;
19
20
// Controls code...

Here we define our camera, an object representing the point of view we have in our scene. I positioned it close to the ground camera.position.y = 0.06 and a bit further from the center of the scene camera.position.z = 1.1 to get a point of view similar to the one from the original scene.

How to define a Three.js renderer and handle resize

1
// Controls code...
2
3
// Renderer
4
const renderer = new THREE.WebGLRenderer({
5
canvas: canvas,
6
});
7
renderer.setSize(sizes.width, sizes.height);
8
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
9
10
// Event listener to handle screen resize
11
window.addEventListener('resize', () => {
12
// Update sizes
13
sizes.width = window.innerWidth;
14
sizes.height = window.innerHeight;
15
16
// Update camera's aspect ratio and projection matrix
17
camera.aspect = sizes.width / sizes.height;
18
camera.updateProjectionMatrix();
19
20
// Update renderer
21
renderer.setSize(sizes.width, sizes.height);
22
// Note: We set the pixel ratio of the renderer to at most 2
23
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
24
});
25
26
// Animate code...

The renderer will render/draw our scene onto an HTML canvas. It's a tool that uses the camera set up earlier to get snapshots of our scene and display it on the page. It needs to be updated on window resize so the scene can render properly no matter the size of the viewport.

How to define a tick function to handle animations in Three.js

1
// Renderer and resize handler code...
2
3
// Animate: we call this tick function on every frame
4
const tick = () => {
5
// Update controls
6
controls.update();
7
8
// Update the rendered scene
9
renderer.render(scene, camera);
10
11
// Call tick again on the next frame
12
window.requestAnimationFrame(tick);
13
};
14
15
// Calling tick will initiate the rendering of the scene
16
tick();

The tick function will handle animations and camera movements in our scene. It's executed on every frame thanks to the use of requestAnimationFrame. Right now, it only handles our OrbitControls: a Three.js utility that lets us use the mouse to grab and move the scene around, which I use a lot when building a scene to look at it from any angle. We'll use it later to handle anything related to animation ✨.

Building the terrain

We now have the base for our scene: a flat plane. Before we start working with it, we have to take a closer look at the Linear release page WebGL animation and deconstruct the scene to know what we'll need to do to achieve a similar render.

Deconstructing the original scene

My original annotations for key elements of the Linear WebGL scene

Above, you can see the annotations I wrote down when I started working on this project. Most of the decisions I've made regarding the implementation details have been made from my own observations of this scene, so the following is just here to illustrate my train of thought:

  • ArrowAn icon representing an arrow
    The plane will need a texture to draw the grid on top of it
  • ArrowAn icon representing an arrow
    The plane will need to have some displacement to shape the terrain on the sides
  • ArrowAn icon representing an arrow
    The terrain is very low-poly and seems to match with the grid texture. Thus, we can have as many "squares" in our grid as subdivisions of our plane (I counted 24, but this might be very wrong 😅). So, no matter how we shape our terrain, the intersections of the segments of our grid texture will match the position of the vertices of the plane giving it its distinct vaporwave look
  • ArrowAn icon representing an arrow
    The surface is a bit shiny in some areas so we'll need to put a red-ish light behind the camera and tweak the material of our mesh
  • ArrowAn icon representing an arrow
    The terrain moves towards us (the viewer), so we'll animate the position of our plane along the z-axis

Now that we've analyzed our scene we can start building 🤘.

Texture

First and foremost, let's make our PlaneGeometry look more like the final render. We can see from the Linear scene that the terrain is mostly some kind of grid. To achieve that effect we will need to do 3 things:

  1. ArrowAn icon representing an arrow
    Draw the grid and export it as a .jpg or `` on a software like Figma for example
  2. ArrowAn icon representing an arrow
    Load this file as a texture in our scene
  3. ArrowAn icon representing an arrow
    Put that texture on our plane, and voilà ✨ we'll have our vaporwave grid effect!

It may sound complicated at first, but Three.js makes it very easy to do so in just a few lines of code with the textureLoader class.

How to load a texture with Three.js

1
// Instantiate the texture loader
2
const textureLoader = new THREE.TextureLoader();
3
// Load a texture from a given path using the texture loader
4
const gridTexture = textureLoader.load(TEXTURE_PATH);

After loading the texture, we then apply it on the plane by assigning the texture to the map property of the material, and we get something like this:

Terrain

We can now focus on the terrain. We want to create some steep mountains on each side of the plane but keep the middle of the plane flat. How can we do that?

First, we need to change our material. So far we only used the MeshBasicMaterial which is, like its name indicates, basic. We need a more advanced material such as MeshStandardMaterial which allows us to play a bit more with it:

  • ArrowAn icon representing an arrow
    it's physically based, meaning it's more realistic and can interact with light
  • ArrowAn icon representing an arrow
    we can edit the different vertices, thus changing the "shape" of the Mesh. This is the property we need now to make our terrain.

However, if you go to the playground above and change the material and refresh the preview, you might notice that the scene becomes all of a sudden dark. This is because, unlike the MeshBasicMaterial, the MeshStandardMaterial needs light to show up on the screen.

To fix this, I added a white ambientLight, a simple light that emits in every direction in the playground below. Try to comment in and out the code for the light of this scene to see the effect:

Now that we have our material set up we need to shape the terrain by displacing the vertices of the material of our mesh. With Three.js we can do that by providing another texture: a displacement map. Once applied to the displacementMap property of a material, this texture will tell our renderer at which height the points of our material are.

Here's the displacement map (also called "heightmap") I provided to this scene:

Displacement Map used for our terrain. The lighter the area, the higher the vertices will appear, the darker the lower.

We can import our displacement map the same way we previously imported our grid texture: using a textureLoader. On top of that, Three.js lets you specify a displacementScale: the intensity with which the displacement map affects the mesh. I used a value of 0.4, which I got by simply tweaking until it felt right.

We can now see the terrain for our scene taking shape ✨:

Animating the scene

We're getting closer! We now have a scene containing our terrain with the proper texture. It's now time to look into some Three.js animation patterns to make our scene move.

Animation patterns and Frame Rate

When we deconstructed the Linear WebGL animation we saw that the terrain is moving towards us. Thus to get that effect in our own scene we'll need to move our mesh along the z-axis. You will see, it's actually pretty simple 😄!

We talked earlier when setting the scene about the tick function. This is the function that gets called again and again, on every frame. To make our terrain move, we'll increment the position of our mesh along the z-axis on every frame.

So to make our terrain move, we need to increment our Mesh z position relative to the elapsed time like below:

Making our terrain move along the z-axis in the tick function

1
// Renderer and resize handler code...
2
// Instantiate the Three.js Clock
3
const clock = new THREE.Clock();
4
5
// Animate
6
const tick = () => {
7
// Get the elapsedTime since the scene rendered from the clock
8
const elapsedTime = clock.getElapsedTime();
9
10
// Update controls
11
controls.update();
12
13
// Increase the position of the plane along the z axis
14
// (Multiply by 0.15 here to "slow down" the animation)
15
plane.position.z = elapsedTime * 0.15;
16
17
// Render
18
renderer.render(scene, camera);
19
20
// Call tick again on the next frame
21
window.requestAnimationFrame(tick);
22
};

Making the scene endless

You will notice that there's one issue with our scene now: the plane moves towards us, but since its length is finite, we don't see anything after a few seconds 😅:

We have to find a way to give the user the impression that this terrain goes on forever. For obvious reasons, we can't make our terrain infinite, it's just impossible, but we can use a few tricks!

Diagram showcasing how to trick the viewer to think the animation is endless by using a copy of the plane, and swap its position at the right moment
  • ArrowAn icon representing an arrow
    We can add a second copy of our plane, put it behind the first one and make it move towards us as well
  • ArrowAn icon representing an arrow
    Once the first plane has gone past our camera (just behind it), the second plane will be at the same position as the first one was at the beginning of the transition
  • ArrowAn icon representing an arrow
    We can now reset both planes to their original position, respectively z=0 and z=-2, without the viewer noticing.
  • ArrowAn icon representing an arrow
    Our animation will thus feel infinite. Plus our terrain looks organic enough that it's not too obvious that we keep reusing the same plane 😄

Implementing this effect requires just a few lines of code (and some math):

Animating our terrain to make it look endless

1
// Renderer and resize handler code...
2
3
const clock = new THREE.Clock();
4
5
// Animate
6
const tick = () => {
7
const elapsedTime = clock.getElapsedTime();
8
// Update controls
9
controls.update();
10
11
/**
12
* When the first plane reaches a position of z = 2
13
* we reset it to 0, its initial position
14
*/
15
plane.position.z = (elapsedTime * 0.15) % 2;
16
/**
17
* When the first plane reaches a position of z = 0
18
* we reset it to -2, its initial position
19
*/
20
plane2.position.z = ((elapsedTime * 0.15) % 2) - 2;
21
22
// Render
23
renderer.render(scene, camera);
24
25
// Call tick again on the next frame
26
window.requestAnimationFrame(tick);
27
};

Let's add this code to our tick function to see the magic happen ✨:

We did it! 🎉 We managed to animate our scene in an infinite loop and we're slowly getting closer to Linear's original scene. However, there're still a few details to add.

Adding post-processing effects

As you can see from the previous playground, our terrain looks a bit off compared to what the Linear team came up with. I didn't really know what it was at first, it was almost as if our terrain looked too sharp. However, after looking at the original scene very closely I noticed the following:

Screenshot of the Linear WebGL animation with a portion zoomed in on the grid showcasing the grid is in fact composed of RGB stripes

At first glance, it looks like we got our texture kind of wrong right? It's actually a bit more subtle than this. Trust me I tried to rebuild a grid with RGB lines, the result was complete garbage 🤮.

The Linear WebGL scene actually leverages some Three.js post-processing effects. In this specific case, it uses an RGBShift effect. Or at least I think so 😄. It's the only effect that brings our scene closer to the result the Linear team got. So we'll use that going forward.

Below, you can find the code I came up with to include the RGBShift effect in our scene:

Applying post-processing effect to our Three.js scene

1
// Renderer code...
2
3
// Post Processing
4
// Add the effectComposer
5
const effectComposer = new EffectComposer(renderer);
6
effectComposer.setSize(sizes.width, sizes.height);
7
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
8
9
/**
10
* Add the render path to the composer
11
* This pass will take care of rendering the final scene
12
*/
13
const renderPass = new RenderPass(scene, camera);
14
effectComposer.addPass(renderPass);
15
16
/**
17
* Add the rgbShift pass to the composer
18
* This pass will be responsible for handling the rgbShift effect
19
*/
20
const rgbShiftPass = new ShaderPass(RGBShiftShader);
21
rgbShiftPass.uniforms['amount'].value = 0.0015;
22
23
effectComposer.addPass(rgbShiftPass);
24
25
// Resize handler code...
26
27
// Animate code...
28
const tick = () => {
29
//...
30
31
// Render
32
/**
33
* We don't need the renderer anymore, since it's taken care of
34
* in the render pass of the effect composer
35
*/
36
// renderer.render(scene, camera);
37
/**
38
* We use the render method of the effect composer instead to
39
* render the scene with our post-processing effects
40
*/
41
effectComposer.render();
42
43
// Call tick again on the next frame
44
window.requestAnimationFrame(tick);
45
};

You can see some new elements were introduced here:

  • ArrowAn icon representing an arrow
    the EffectComposer: the class that manages all the post-processing effects to eventually produce the final result
  • ArrowAn icon representing an arrow
    theRenderPass: the pass responsible of the first render of the scene.
  • ArrowAn icon representing an arrow
    our rGBShiftPass: the post-processing pass responsible to apply the RGBShift effect.

When I applied this effect for the first time, the colors ended up looking... quite off:

Screenshot showcasing our Three.js scene without color correction

After some investigation, I found out that after applying certain effects, Three.js scenes might get darker because the renderer's output encoding is not working anymore. To fix this we need to add another post-processing effect pass named GammaCorrectionShader which will act as a kind of color correction layer to our scene.

In the playground below you'll find our rendered scene with our post-processing-effects looking simply fabulous ⚡️. In it you can try to:

  • ArrowAn icon representing an arrow
    Comment out the gammaCorrectionPass and see how the colors end up a bit messed up
  • ArrowAn icon representing an arrow
    Tweak the value of the rgbShiftPass to make our RGB shift more or less intense!

Let there be light!

We're now missing the most important aspect of our scene: the light! The original scene has some kind of a red-ish light being reflected on some (not all) squares of the grid with some kind of a brushed metal effect. How do we achieve that?

I had to look for hints to figure out what to do here. By looking at the reflective squares on the grid, I figured that there should be two lights pointing at the sides of the scene (not the floor). After a bit of research, it seemed that spotlights were the only lights that were fit for that so I defined them as follows:

Diagram showcasing the position of the two spotlights relative to the first plane that light up our scene at specific targets

Which would be equivalent to the following code:

Adding and positioning spotlights in our Three.js scene

1
// Ambient light code...
2
3
// Right Spotlight aiming to the left
4
const spotlight = new THREE.SpotLight('#d53c3d', 20, 25, Math.PI * 0.1, 0.25);
5
spotlight.position.set(0.5, 0.75, 2.2);
6
// Target the spotlight to a specific point to the left of the scene
7
spotlight.target.position.x = -0.25;
8
spotlight.target.position.y = 0.25;
9
spotlight.target.position.z = 0.25;
10
scene.add(spotlight);
11
scene.add(spotlight.target);
12
13
// Left Spotlight aiming to the right
14
const spotlight2 = new THREE.SpotLight('#d53c3d', 20, 25, Math.PI * 0.1, 0.25);
15
spotlight2.position.set(-0.5, 0.75, 2.2);
16
// Target the spotlight to a specific point to the right side of the scene
17
spotlight2.target.position.x = 0.25;
18
spotlight2.target.position.y = 0.25;
19
spotlight2.target.position.z = 0.25;
20
scene.add(spotlight2);
21
scene.add(spotlight2.target);
22
23
// Sizes...

Now, what about the reflective parts of our terrain? When we introduced our MeshStandardMaterial earlier, we mentioned that it is a physical-based material. This means we can tweak its properties to make it interact with light and its environment like a real material such as:

  • ArrowAn icon representing an arrow
    metalness: How much the material is like metal. 0 being non-metallic and 1 being purely metallic.
  • ArrowAn icon representing an arrow
    roughness: How rough the material is. 0 being smooth, almost mirror-like, and 1 being diffuse.

In our case, however, our material doesn't behave consistently:

  • ArrowAn icon representing an arrow
    some squares diffuse some light so they will be rougher and less metallic
  • ArrowAn icon representing an arrow
    some other squares diffuse no light so they will be purely metallic

To achieve this we can set the metalnessMap property of our material: a texture to indicate the parts of our mesh should be metallic and the ones that should not.

Metalness Map used for our terrain. The lighter the area, the more metallic the material will appear in that area, the darker the rougher.

By adding this metalnessMap, tweaking the metalness and roughness values of our material (I chose respectively 0.96 and 0.5, again by tweaking a lot), and finally adding the right light pointing at the right spot on our scene we get our final result that is pretty spot on 🎉!

Conclusion

From a simple plane geometry, we managed to build with just a few lines of code and a bit of tweaking a sleek, animated, vaporwave Three.js scene 🎉 . We could spend a ton of time trying to tweak this scene even further to improve:

  • ArrowAn icon representing an arrow
    the light: I didn't nail that one quite well 😅
  • ArrowAn icon representing an arrow
    the texture: the grid appears to be a bit too thick. Maybe the original team didn't use a texture after all and instead relied on shaders?
  • ArrowAn icon representing an arrow
    probably performance
  • ArrowAn icon representing an arrow
    add some sick tracks as background music to go with the vibe of the scene

but without the original scene, it will be quite hard to get exactly the same result. This entire project was purely done by guessing and applying the things I learned through the Three.js journey course so I feel the result looks already pretty cool!

I hope you liked this project as much as I did. I feel like it's a great first project to get a bit more hands-on with some of the fundamental concepts of Three.js such as:

  • ArrowAn icon representing an arrow
    anything dealing with meshes: textures, geometries, materials and their properties
  • ArrowAn icon representing an arrow
    light and post-processing effects that can, if tweaked properly, give the perfect mood to your scene
  • ArrowAn icon representing an arrow
    animations and frame rate

and not get stuck in tutorial hell. If you wish to further improve your Three.js skills I highly encourage taking a simple scene you like and start reverse-engineering it/rebuild it as I did for this one: you will learn a lot!

If you want to hack on top of it and have some fun, or simply use it as a base for your next Three.js creation you can head over to the Github repository of this project 😄. I also took the time to write this scene in React-three-fiber. It can serve as a great example if you're looking to learn how to build reusable React components from your Three.js objects.

Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.

Have a wonderful day.

– Maxime

A step-by-step tutorial documenting my attempt at reverse-engineering the vaporwave WebGL scene from the Linear 2021 release page using solely fundamental concepts of Three.js like textures, lights, animations, and post-processing effects.