From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
In this tutorial, we’ll explore how to create a Cyberpunk like Three.js scene, inspired by the background animation found on Pipe’s website. We’ll guide you through the process of coding a dynamic scene using Three.js, complete with post-processing effects and dynamic lighting, all without needing any shader expertise.
I’ve used this scene to create the interactive background of Monthly Talk.
Have a look at the source code. Check out the video of what we will be making today:
Table of Contents
- The 3D Model
- Creating the Three.js scene
- Let’s change the model
- Adding Post-processing
- Fixing the Alias
- Adding the Chromatic Aberration
- Color calibration with Hue and Saturation
- Adding camera Parallax
- Implementing light Parallax movement
- Final thoughts
Let’s start with having a look at how the model is created in Blender!
The 3D Model
The model was created using Blender3D and consists of basic rectangular shapes. These shapes were first solidified and subsequently duplicated numerous times, with adjustments made to their rotation and position in order to achieve a visually appealing circular pattern. It’s crucial to ensure that the center of mass for all objects aligns with the scene’s center since they will later be animated within Three.js.
First, let’s add a circular shape to our scene. This will be the starting point for our 3D model.
Next, you’ll want to change the parameters for the circle creation, which can be found in the bottom-left corner of the screen.
Now, it’s time to enter Edit Mode (press TAB), select some vertices, and delete them using the X key on your keyboard.
With the remaining vertices, go ahead and extrude them along the Z axis to give the shape some depth.
Once you’ve done that, exit Edit Mode and add a Solidify Modifier to give the shape some thickness.
Now, press SHIFT+D to duplicate the mesh, and press R then Z to lock the rotation on the Z axis and rotate the copy.
Keep repeating the process until you have a bunch of meshes. Don’t forget to apply materials with different colors to make it more interesting! You can always change some of these settings later inside Three.js, but it’s a good idea to set some parameters inside Blender. Also, I’ve added a bevel modifier on each mesh to give them a nice, soft edge.
Now it’s time to export your creation! Select everything by pressing A on your keyboard, then export your model as a GLB / GLTF file.
Lastly, it’s important to check a couple of things before you finalize the export. Make sure to include “Selected Objects” only and apply modifiers under Mesh Settings. You can also use compression to reduce the file size.
Creating the Three.js scene
With the model ready, it’s time to create a Three.js scene to load it. Start by using a Three.js Boilerplate. In this case, I’ll be utilizing my own boilerplate, which you’re welcome to use as well.
To save time and avoid building a scene from scratch, we’ll take advantage of my boilerplate. It’s quite straightforward and comes with line-by-line documentation to help you understand the purpose of each line.
It’s a common misconception that the only way to utilize Three.js is via a package manager. However, that’s not the case. By leveraging ES6 Modules, you can use Three.js without the need for a builder. My boilerplate is already set up to function in this manner.
As mentioned on the Three.js installation page, all you need to do is instantiate it correctly. Since the library relies on ES modules, any script referencing it must use type=”module”. Additionally, you need to define an import map that resolves to the simple module specifier ‘three’.
As import maps are not yet universally supported by browsers, it’s necessary to include the polyfill es-module-shims.js as well.
The core of Three.js focuses on the essential components of a 3D engine. Many other valuable components—such as controls, loaders, and post-processing effects—are part of the examples/jsm directory. These are referred to as “addons” because they can be used as-is or customized to suit your needs.
While addons don’t need to be installed separately, they do need to be imported individually. When using the CDN version, we can add the necessary items to the “import map.” It’s crucial to ensure all files use the same version; avoid importing different addons from various versions or using addons from a different version than the Three.js library itself.
In this instance, we’re importing some extra addons and additional libraries that we’ll need for our project.
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.145.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.145.0/examples/jsm/",
}
}
</script>
Next, make sure to include the scripts file with the “module” type in your HTML file.
<body>
<script type="module" src="./src/index.js"></script>
</body>
That’s it! As you can see, we now have a fully functioning Three.js project. You can take a look at the index.js file to understand what each line does. As previously mentioned, I won’t delve into the details here, as there are plenty of other tutorials available on how to create a scene like this one.
Let’s change the model
With our scene set up, it’s time to load the model we exported from Blender. All we need to do is upload the exported “.glb” model to the code sandbox and modify a single line of code to use the new model.
///// LOADING GLB/GLTF MODEL FROM BLENDER /////
loader.load('https://03fltx.csb.app/assets/model/cyberpunk_model.glb', function (gltf) {
scene.add(gltf.scene);
});
Since we’re using a static page without any bundler, it’s necessary to use the full path for the model. And just like that, our model is now part of the scene.
Adding Post-processing
Post-processing might be a new concept to some, but it’s what makes Three.js truly remarkable. It allows you to create an extra layer of effects in the Three.js rendering system. Think of it like applying an Instagram filter to your photos. There’s a wide range of effects to choose from, and the perfect combination is what sets Three.js scenes apart.
To begin, let’s install the required library to enable post-processing effects in our project. Head over to the index and add the library to the import parameters. Make sure to place it below the “Addons” line, as shown here:
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.145.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.145.0/examples/jsm/",
"postprocessing": "https://cdn.jsdelivr.net/npm/postprocessing@6.30.0/build/postprocessing.esm.js"
}
}
</script>
Now, go back to your index.js and add the import script.
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {TWEEN} from 'three/addons/libs/tween.module.min.js';
import {DRACOLoader} from 'three/addons/loaders/DRACOLoader.js';
import {RGBELoader} from 'three/addons/loaders/RGBELoader.js';
import {EffectComposer, RenderPass, EffectPass, BloomEffect } from 'postprocessing';
We are importing four things from this new library:
- EffectComposer – This component is responsible for creating a new renderer for our scene. It combines all the effects we add into a single “image” that will be rendered, bypassing the default Three.js rendering system.
- RenderPass – This component defines which scene will be rendered. It essentially represents the original result of the default Three.js render before any effects are applied.
- EffectPass – This component defines an effect pass that we want to add. Think of it as one of the Instagram filters applied to the rendered scene.
- BloomEffect – This is a type of effect that simulates the “glow” effect on luminous objects in the scene.
Now that we’ve imported all the necessary components for the effects, it’s time to create instances of them in our project. Head over to the index.js file and add the post-processing there.
//// POST PROCESSING ////
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new EffectPass(camera, new BloomEffect(
{
intensity: 3.2,
mipmapBlur: true
}
)));
What is happening here
- We create a new instance of EffectComposer and specify the original scene (renderer in this case).
- We add a new RenderPass so that the original scene serves as the foundation for applying effects.
- We add a new EffectPass to apply the desired filter. We include some additional parameters, such as the intensity of the effect, and activate mipMapBlur, a smoothing technique applied to textures in computer graphics. MipMapBlur uses different levels of texture resolution (mipmaps) to generate a more efficient blurred image, which can help reduce aliasing in textures at various distances and viewing angles.
We’re almost there. We now have everything needed to render the scene with post-processing. The final step is to actually render the scene. Comment out or remove the old renderer, “renderer.render(scene, camera)”, and replace it with “composer.render()” in the render loop, as shown below:
//// RENDER LOOP FUNCTION ////
function rendeLoop() {
controls.update();
composer.render() //render the scene with Post Processing Effects
//renderer.render(scene, camera); //render the scene without the composer
requestAnimationFrame(rendeLoop);
}
Voilà! We now have a post-processed scene. As you can see, the reflections of the lights create stunning lighting effects when they interact with the glass parts of the model.
We can take the effect even further. Let’s add more layers of glow to the post-processing system for an enhanced Bloom effect! All we need to do is create new effects by declaring some variables and then adding the effects sequentially in EffectPass, similar to how we would stack multiple layers of a photo filter in an image editing program.
//// POST PROCESSING ////
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new BloomEffect({intensity: 1.9, mipmapBlur: true, luminanceThreshold: 0.1, radius: 1.1});
const bloom2 = new BloomEffect({intensity: 3.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const bloom3 = new BloomEffect({intensity: 1.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
composer.addPass(new EffectPass(camera, bloom, bloom2, bloom3));
//// RENDER LOOP FUNCTION ////
function rendeLoop() {
// ...
Done. The result is much better now, isn’t it?
Fixing the alias
One of the side effects of using post-processing is that the anti-aliasing of the experience vanishes. The way the post-processing pipeline works creates a new render instance without the alias the scene originally had. This results in the jagged edges on the objects we’ve observed so far.
Fortunately, we can fix this by adding a new EffectPass dedicated to calculating the alias.
import {BloomEffect, EffectComposer, EffectPass, RenderPass, SMAAPreset, SMAAEffect} from 'postprocessing';
We’ve added two new component imports here.
One is the SMAAPreset, which contains a set of settings related to the quality of the SMAA effect we’re going to use. The other is the SMAA effect itself. SMAA (Subpixel Morphological Antialiasing) is an antialiasing technique used in computer graphics to smooth jagged and pixelated edges (aliasing) in real-time images such as games.
SMAA works by analyzing the image and applying an edge detection filter to determine which edge pixels need to be smoothed. It then uses a mathematical morphology algorithm to apply subtle corrections to those edges, smoothing them without compromising the sharpness and clarity of the image.
Thankfully, using this is much simpler than trying to understand how it works 🙂
//// POST PROCESSING ////
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new BloomEffect({intensity: 1.9, mipmapBlur: true, luminanceThreshold: 0.1, radius: 1.1});
const bloom2 = new BloomEffect({intensity: 3.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const bloom3 = new BloomEffect({intensity: 1.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const smaaAliasEffect = new SMAAEffect({preset: SMAAPreset.ULTRA});
composer.addPass(new EffectPass(camera, bloom, bloom2, bloom3, smaaAliasEffect));
As you can see, we’ve simply added one more effect layer for SMAA and utilized the SMAAPreset.Ultra option to achieve the best possible resolution for the edge smoothing effect.
Adding the Chromatic Aberration
Chromatic aberration is an optical phenomenon that occurs when different colors of light have varying wavelengths, causing them to deviate to different degrees as they pass through a lens. This can result in images with colored borders around objects, particularly high-contrast borders. We’ll use this effect to distort the edges of our image with one more post-processing effect available in the library, which is very simple to use. Let’s start by importing this new effect from the library:
import {
BloomEffect,
EffectComposer,
EffectPass,
RenderPass,
SMAAPreset,
SMAAEffect,
ChromaticAberrationEffect, //We added this
} from 'postprocessing';
// The formatting changed to multiple lines because we are doing a lot of imports. But we only add here the new effect that we want to use.
Now let’s add one more variable that will store the chromatic aberration. In it, we will create a new effect and pass some Offset, RadialModulation, and ModulationOffset parameters.
“offset”: is a two-dimensional vector that defines the displacement (offset) of the green and red color channels of the image. The higher the value, the more the offset, and therefore the color distortion increases.
“radialModulation”: indicates whether the chromatic aberration effect should have radial modulation or not. If set to “true”, the effect will be applied in the form of concentric circles around the center of the image.
“modulationOffset”: is a numerical value that defines the size of the radial modulation of the chromatic aberration effect. The larger the value, the larger the radius of the concentric circles.
Finally, we create a new pass in the effects pipeline, so that ChromaticAberration affects all previous “passes”.
//// POST PROCESSING ////
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new BloomEffect({intensity: 1.9, mipmapBlur: true, luminanceThreshold: 0.1, radius: 1.1});
const bloom2 = new BloomEffect({intensity: 3.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const bloom3 = new BloomEffect({intensity: 1.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const smaaAliasEffect = new SMAAEffect({preset: SMAAPreset.ULTRA});
const chromaticAberration = new ChromaticAberrationEffect({
offset: new THREE.Vector2(0.002, 0.02),
radialModulation: true,
modulationOffset: 0.7,
});
composer.addPass(new EffectPass(camera, bloom, bloom2, bloom3, smaaAliasEffect));
composer.addPass(new EffectPass(camera, chromaticAberration));
As you can see, the chromatic aberration effect distorts the edges of the image, as if a real camera were filming the scene.
Color Calibration with Hue and Saturation
So far, we’ve used the default Three.js colors. There are several ways to control colors in Three.js, and modifying the color system in the renderer with ToneMapping is one method.
Another method is to use one more post-processing pass to control the hue and saturation of the scene. This way, we can work with more familiar controls that we are used to using in other image editing software.
Let’s import one more component from the library:
import {
BloomEffect,
EffectComposer,
EffectPass,
RenderPass,
SMAAPreset,
SMAAEffect,
ChromaticAberrationEffect,
HueSaturationEffect,
} from 'postprocessing';
Let’s create a new variable to store the saturation and hue effect with the desired parameters. In this case, we will adjust the hue by a certain number of degrees to create a slightly bluer color tone. Additionally, we will increase the saturation to achieve a more “CyberPunk” appearance, which is the effect we are aiming for.
//// POST PROCESSING ////
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new BloomEffect({intensity: 1.9, mipmapBlur: true, luminanceThreshold: 0.1, radius: 1.1});
const bloom2 = new BloomEffect({intensity: 3.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const bloom3 = new BloomEffect({intensity: 1.2, mipmapBlur: true, luminanceThreshold: 0.1, radius: 0.5});
const smaaAliasEffect = new SMAAEffect({preset: SMAAPreset.ULTRA});
const chromaticAberration = new ChromaticAberrationEffect({
offset: new THREE.Vector2(0.002, 0.02),
radialModulation: true,
modulationOffset: 0.7,
});
const hueSaturationEffect = new HueSaturationEffect({
hue: -0.1,
saturation: 0.25,
});
composer.addPass(new EffectPass(camera, bloom, bloom2, bloom3, smaaAliasEffect));
composer.addPass(new EffectPass(camera, chromaticAberration, hueSaturationEffect));
As a result, we now have a more saturated scene with colors that are more intense and closer to our desired outcome.
Adding Camera Parallax
Adding movement to the camera based on mouse movement with inertia can add more dynamism to a Three.js scene. This creates a sense of interactivity and makes the scene feel more alive. Fortunately, implementing this effect is not difficult.
composer.addPass(new EffectPass(camera, chromaticAberration, hueSaturationEffect));
//// ON MOUSE MOVE TO GET MOUSE POSITION ////
const cursor = new THREE.Vector3(); // creates a new vector to store the mouse position
document.addEventListener(
'mousemove',
(event) => {
event.preventDefault();
cursor.x = event.clientX / window.innerWidth - 0.5;
cursor.y = event.clientY / window.innerHeight - 0.5;
},
false,
);
//// RENDER LOOP FUNCTION ////
// ...
I recommend placing this function before the render loop, immediately after the post-processing effects. Firstly, we declare a variable named “cursor” to store a two-dimensional vector, which will hold the X and Y coordinates of the mouse.
Next, we attach an event listener to the document to track mouse movement. When this event is triggered, we create a function that captures the mouse positions and stores them in our “cursor” variable. To prevent the default behavior, we use the event.preventDefault method.
As for the calculation, it is designed to shift the mouse position from the corner to the center of the screen, which is not the default. To achieve this, we divide the mouse position in the document by the screen size and then subtract half of that (-0.5). This gives us the mouse position in the middle of the screen. If you want to see the calculated result, you can create a console.log(cursor) inside this function.
However, this function alone does not affect anything in our scene. To achieve that, let’s proceed by moving the camera. To do this, we first create a group and place the camera inside it.
///// CAMERAS CONFIG /////
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 100);
scene.add(camera);
camera.position.set(0, 3, 9);
///// ADD A GROUP AND PUT THE CAMERA INSIDE OF IT /////
const cameraGroup = new THREE.Group();
cameraGroup.add(camera);
...
Since the camera is already being observed by OrbitControls, we cannot directly modify its position. However, we can create a group and place the camera inside it. This way, we can modify the position of the group, which will result in the camera’s movement.
To achieve this effect, we can simply modify the position of the camera group based on the value of the cursor variable, which is tracking the mouse position. In this case, we will use the Y coordinate of the cursor variable.
//// RENDER LOOP FUNCTION ////
function rendeLoop() {
controls.update();
composer.render();
cameraGroup.position.y = cursor.y * 8; //move the cameraGroup using the cursor position
requestAnimationFrame(rendeLoop);
}
Great! We have successfully implemented the camera movement based on the mouse position. However, the movement lacks inertia and feels rigid. Let’s improve it.
//// RENDER LOOP FUNCTION ////
const cursor = new THREE.Vector3();
const lerpedPosition = new THREE.Vector3(); //creates another vector to store the inertia mouse position
function rendeLoop() {
lerpedPosition.lerp(cursor, 0.01); //uses lerp function to create inertia movement
TWEEN.update();
controls.update();
composer.render();
cameraGroup.position.y = lerpedPosition.y * 8; // moves the camera group using the lerped position
requestAnimationFrame(rendeLoop);
}
rendeLoop();
//...
We can use a built-in threejs function called “lerp” to add inertia to the camera movement. Firstly, we declare a new Vector3 object named “lerpedPosition”. This object will be used to store the mouse position with an inertial effect.
Then, inside the renderLoop function, we calculate the intermediate position of the “cursor” Vector3 object in relation to the “lerpedPosition” Vector3 object. We accomplish this calculation by utilizing the lerp() function from the Three.js library, which linearly interpolates between two values. The second parameter (0.01) specifies the interpolation rate. In this case, the interpolation is set to occur at a rate of 1% (0.01) per frame.
Finally, we modify the position of the cameraGroup using the lerpedPosition, which smoothly updates the y position based on the current y position of the mouse interpolated with the lerp function. This creates a parallax motion effect that is relative to the mouse position in the scene.
Excellent! We have successfully implemented a beautiful parallax movement with inertia.
Implementing Light Parallax Movement
Now that we have successfully implemented the parallax movement with the mouse, we can also make the light present in the scene respond to mouse movement.
To achieve this effect, we simply need to add the position of the light inside the render loop and lock the horizontal position to the x-axis of the smoothed mouse position and the vertical position to the y-axis of the smoothed mouse position.
//// RENDER LOOP FUNCTION ////
const cursor = new THREE.Vector3();
const lerpedPosition = new THREE.Vector3();
function rendeLoop() {
lerpedPosition.lerp(cursor, 0.01);
controls.update();
composer.render();
cameraGroup.position.y = lerpedPosition.y * 8;
light.position.x = -lerpedPosition.x * 50; //moves the horizontal light position using the lerped position
light.position.y = lerpedPosition.y * 60; //moves the vertical light position using the lerped position
requestAnimationFrame(rendeLoop);
}
rendeLoop(); //start rendering
//...
As a result, you can see that the light also follows the mouse with inertia, creating a beautiful and dynamic effect in the scene’s lighting.
Final Thoughts
As you can see, creating beautiful and dynamic effects using Three.js can be a fun journey. Although the process may feel lengthy due to the need to explain each step, it’s impressive that you can create something this beautiful with just 100 lines of code.
If you have React knowledge, you might find my recently launched course on Udemy of particular interest. The course concentrates on using React Three Fiber to create configurators utilizing Three.js. The course link is available here: Beautiful React Three.js Fiber Configurator – Design & Code
I hope you enjoyed the tutorial and learned something new. If so, be sure to follow me on Twitter or YouTube. I regularly post new content and tutorials about Three.js, creativity, and technology.