DEV Community

Cover image for Create live snow effect for your website
Smitter
Smitter

Posted on • Edited on

Create live snow effect for your website

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 and sprites 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>

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
  }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
  }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)