Create live snow effect for your website
Smitter
Posted on July 10, 2022
Isn't it fascinating how Threejs can create websites that turn into guns and blow your mind off🤯? Wait, do you know Threejs?
Sections
Introduction
Project structure
Getting started
Creating a scene
Creating particles
Animate the particles
Introduction
Let's start here. Threejs is a lightweight 3D library with a rich feature set that abstracts complexities of WebGL and makes it super simple to get started with 3D programming on the web. With that said, let's see if this example of 3D website will blow off your mind! https://bruno-simon.com
We are going to cover steps on how to create a particle system with Threejs. It is beginner friendly and requires bit of acquaintance with javascript.
You can see the final result here
I implemented this in my portfolio. You should visit it if you wanna see a mindblowing website😜.
Project structure
You can git clone
the starterpack of our project structure and resources here, so that we can focus on the fun stuff.
If you prefer to follow every step, then let's get going by following the steps below:
- Create a directory called
three-particles
. - Change into the newly created directory(
three-particles
). - Create an
index.html
file. - Create a
script.js
file in the folder structure:./assets/js/
- Create
textures
andsprites
folder inside of assets folder in the structure of./assets/textures/sprites
. This is where we will put our images of snowflakes. You can find these images in repository starter pack shared above.
Getting started
In our index.html
, we are going to load in the Threejs Library and a little css styles reset.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Threejs particles</title>
<style>
body {
margin: 0;
background-color: #000;
color: #fff;
font-size: 13px;
overscroll-behavior: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.142.0/build/three.module.js",
"three/": "https://unpkg.com/three@0.142.0/"
}
}
</script>
<script type="module" src="./assets/js/script.js"></script>
</body>
</html>
We have simply included Threejs
into our project via Content Delivery Network(CDN).
The <style>
tags add basic styling to our web page to override default styling along with changing body background to dark.
We have used HTML Import maps on <script type="importmap">
to alias the path of our import to just the term three
. I have an article explaining more about import maps here. We have also defined a key three/
which will allow us to import other utilities we may require from other directory(folder) locations of Threejs.
Below the <script type="importmap">
, we have included our script tag loading our script.js
file. This is where we will write our code.
Creating a scene
Let's head to our script.js
file and start off.
I am going to create a scene that will contain our particles and simulate the particles to give a visual effect of a snow fall.
Below is the starting code:
import * as THREE from "three";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
let camera, scene, renderer, stats, parameters;
let mouse = { X: 0, Y: 0 };
let windowHalfX = window.innerWidth / 2;
let windowHalfY = window.innerHeight / 2;
const materials = [];
init();
animate();
function init() {
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
1,
2000
);
camera.position.z = 1000;
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.0008);
const particleSystems = createParticleSystems();
// Add particleSystems to scene
for (let i = 0; i < particleSystems.length; i++) {
scene.add(particleSystems[i]);
}
// rendering output to the browser
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio = window.devicePixelRatio;
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Showing stats
stats = new Stats();
document.body.appendChild(stats.dom);
// Modify the 3D Object rendered with GUI
// We toggle the texture to see results
// when texture(image) is added and not
const gui = new GUI();
const params = {
texture: true,
};
gui.add(params, "texture").onChange(function (value) {
for (let i = 0; i < materials.length; i++) {
materials[i].map = value === true ? parameters[i][1] : null;
materials[i].needsUpdate = true;
}
});
gui.open();
document.body.style.touchAction = "none";
// Update pointer locations on the screen
document.body.addEventListener("pointermove", onPointerMove);
// update the dom element with change in viewport
window.addEventListener("resize", onWindowResize);
}
function onWindowResize() {
windowHalfX = window.innerWidth / 2;
windowHalfY = window.innerHeight / 2;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function onPointerMove(event) {
if (event.isPrimary === false) return;
mouse.X = event.clientX - windowHalfX;
mouse.Y = event.clientY - windowHalfY;
}
You may see function calls that are not defined. We shall get to them. Rather than splitting these functions into separate modules, in this tutorial I decided to use the hoisting behavior of Javascript functions.
The windowHalfX
and windowHalfY
variables are meant to map the browser coordinate system into the Threejs coordinate system. For instance in browser, the more left of the window you get to, the closer you get to the value of zero. In Threejs, the x-axis is 0 at the middle and negative leftwards and so, positive rightwards.
Think of Threejs 3D like a scene in movies. In the scene, you can add objects just like in a movie's scene; obstacles or buildings or cars...e.t.c can be added. Then there is a camera that will view the scene and it can be moved to different angles to get variations of points of view.
As you see here:
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
1,
2000
);
camera.position.z = 1000;
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.0008);
We have created a camera, given it an angle(Field Of View), aspect ratio, near and far parameters. We have set the camera.position.z
to a value so that it draws the camera out on the z-axis
which is the axis coming towards you from the screen.
This step is important so that our camera can be able to capture object(s) added to the scene in its field of view. By default, when we call scene.add()
, the object(s) we add will be added to the coordinates (0,0,0). This would cause both the camera and the object(s) to be inside each other. So you will see nothing but all black.
We then created our scene, scene = new THREE.Scene();
and added scene.fog
to make object(s) appear faded as they move further away from the camera.
You can visit the Threejs docs to have a proper glimpse of the type of paramers taken by these methods.
Creating Particles
Let's get to these line const particleSystems = createParticleSystems();
in our init()
function.
In the snippet below we add the definition of createParticleSystems()
function:
function createParticleSystems() {
// Load the texture that will be used to display our snow
const textureLoader = new THREE.TextureLoader();
const sprite1 = textureLoader.load(
"./assets/textures/sprites/snowflake1.png"
);
const sprite2 = textureLoader.load(
"./assets/textures/sprites/snowflake2.png"
);
const sprite3 = textureLoader.load(
"./assets/textures/sprites/snowflake3.png"
);
const sprite4 = textureLoader.load(
"./assets/textures/sprites/snowflake4.png"
);
const sprite5 = textureLoader.load(
"./assets/textures/sprites/snowflake5.png"
);
// Create the geometry that will hold all our vertices
const geometry = new THREE.BufferGeometry();
const vertices = [];
const particleSystems = [];
// create the vertices and add store them in our vertices array
for (let i = 0; i < 10000; i++) {
const x = Math.random() * 2000 - 1000; // generate random number between -1000 to 1000
const y = Math.random() * 2000 - 1000;
const z = Math.random() * 2000 - 1000;
vertices.push(x, y, z);
}
// Add the vertices stored in our array to set
// the position attribute of our geometry.
// Position attribute will be read by threejs
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices, 3)
);
parameters = [
[[1.0, 0.2, 0.5], sprite2, 20],
[[0.95, 0.2, 0.5], sprite3, 15],
[[0.9, 0.2, 0.5], sprite1, 10],
[[0.85, 0.2, 0.5], sprite5, 8],
[[0.8, 0.2, 0.5], sprite4, 5],
];
for (let i = 0; i < parameters.length; i++) {
const color = parameters[i][0];
const sprite = parameters[i][1];
const size = parameters[i][2];
// Create the material that will be used to render each vertex of our geometry
materials[i] = new THREE.PointsMaterial({
size,
map: sprite,
blending: THREE.AdditiveBlending,
depthTest: false,
transparent: true,
});
materials[i].color.setHSL(color[0], color[1], color[2]);
// Create the particle system
const particleSystem = new THREE.Points(geometry, materials[i]);
/* Offset the particle system x, y, z to different random points to break
uniformity in the direction of movement during animation */
particleSystem.rotation.x = Math.random() * 6;
particleSystem.rotation.y = Math.random() * 6;
particleSystem.rotation.z = Math.random() * 6;
particleSystems.push(particleSystem);
}
return particleSystems;
}
Here const textureLoader = new THREE.TextureLoader();
we instantiate a
THREE.TextureLoader()
class that we then use to load our images of snow.Three js will extract the texture from our images and map it to the texture of the particles we create.
We later ceate a geometry, const geometry = new THREE.BufferGeometry();
that will be used to hold our vertices in groups of x, y, z. That is, x,y,z represents one particle. Particles are just individual vertices in a geometry.
for (let i = 0; i < 10000; i++) {
const x = Math.random() * 2000 - 1000; // generate random number between -1000 to 1000
const y = Math.random() * 2000 - 1000;
const z = Math.random() * 2000 - 1000;
vertices.push(x, y, z);
}
As shown above, we are creating vertices in the group (x, y, z) and adding them to our vertices
array.
We then set a position attribute on our geometry
and add our vertices to it. Threejs will interpret this attribute as the placement(positions) of our vertices on our geometry spread across x and y axes.
for (let i = 0; i < parameters.length; i++) {
const color = parameters[i][0];
const sprite = parameters[i][1];
const size = parameters[i][2];
// Create the material that will be used to render each vertex of our geometry
materials[i] = new THREE.PointsMaterial({
size,
map: sprite,
blending: THREE.AdditiveBlending,
depthTest: false,
transparent: true,
});
materials[i].color.setHSL(color[0], color[1], color[2]);
// Create the particle system
const particleSystem = new THREE.Points(geometry, materials[i]);
/* Offset the particle system x, y, z to different random points to break
uniformity in the direction of movement during animation */
particleSystem.rotation.x = Math.random() * 6;
particleSystem.rotation.y = Math.random() * 6;
particleSystem.rotation.z = Math.random() * 6;
particleSystems.push(particleSystem);
}
return particleSystems;
}
With our parameters defined in parameters array:
// Create the material that will be used to render each vertex of our geometry
materials[i] = new THREE.PointsMaterial({
size,
map: sprite,
blending: THREE.AdditiveBlending,
depthTest: false,
transparent: true,
});
materials[i].color.setHSL(color[0], color[1], color[2]);
We are now defining how our particles would look like(The material that would be added to our vertices, to enable us to give the vertices texture of our image and also color them). Remember we said a single vertex x, y, z represents one particle. The option map
would map our particle to our png image texture. transparent: true
rids the background of our png. Without it, we would see the background of our image creating weird view. materials[i].color.setHSL(color[0], color[1], color[2]);
gives color to our material.
const particleSystem = new THREE.Points(geometry, materials[i]);
creates the particles managed as a system.
particleSystems.push(particleSystem);
, here we create several particleSystems and store to our array. We return particleSystems
array containing all the created particle systems.
Going back to our init()
function, below after calling createParticleSystems()
, you can see we are iterating over the returned value and add each particle system to our scene. That is:
const particleSystems = createParticleSytems();
// Add particleSystems to scene
for (let i = 0; i < particleSystems.length; i++) {
scene.add(particleSystems[i]);
}
And lastly, from our init
function, we render what we have created to the browser so that we can see:
// rendering output to the browser
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio = window.devicePixelRatio;
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
Animate the particles
To animate the particles, we are going to change the orientation of the whole particle system. Our scene has 5 particle Systems(length of our parameters
array) each with 10,000 particles.
We define our animate function:
function animate() {
// This will create a loop rendering at each frame
requestAnimationFrame(animate);
render();
stats.update();
}
function render() {
const time = Date.now() * 0.00005;
camera.position.x += (mouse.X - camera.position.x) * 0.05;
camera.position.y += (mouse.Y - camera.position.y) * 0.05;
camera.lookAt(scene.position);
for (let i = 0; i < scene.children.length; i++) {
const object = scene.children[i];
if (object instanceof THREE.Points) {
object.rotation.y = time * (i < 4 ? i + 1 : -(i + 1));
}
}
for (let i = 0; i < materials.length; i++) {
const color = parameters[i][0];
const h = (360 * ((color[0] + time) % 360)) / 360;
materials[i].color.setHSL(h, color[1], color[2]);
}
renderer.render(scene, camera);
}
in our render
function, we are animating the position of our camera to move to where our mouse is currently at. But by multiplying the difference between the position of the camera and the position of the mouse by 0.05
will make the camera move slowly slowly drawing towards the mouse at every frame.
for (let i = 0; i < scene.children.length; i++) {
const object = scene.children[i];
if (object instanceof THREE.Points) {
object.rotation.y = time * (i < 4 ? i + 1 : -(i + 1));
}
}
In this block of code, we are animating our particleSystems around the Y-axis. You can see that in every frame we are changing y value object.rotation.y = time * (i < 4 ? i + 1 : -(i + 1));
. We are using time, to ensure that our particleSystems rotate at the same speed regardless of the device which may have different frame rates. Though browsers in most devices have a Frame Rate of 60 frames per second(60FPS). Remember that time variable we defined as const time = Date.now() * 0.00005;
would increment at every frame and we multiply by a small factor 0.00005
to make small changes in the rotation around the y axis.
And that is how we created our particles and simulated it to give a snowfall effect.
The final code of our tutorial is in the repo.
I'll see you in my next article✌️.
Follow me on twitter to see what am upto these days. I share tips and tricks and hacky work arounds about javascript programming.
Posted on July 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.