[threejs-journey] Part 4

16 Haunted House

Difficulty Hard

Introduction

Let’s use what we’ve learned to create a haunted house. We will only use Three.js primitives as geometries, the textures in the /static/textures/ folder, and one or two new features.

We will create an elementary house composed of walls, a roof, a door, and some bushes. We will also produce graves in the garden. Instead of visible ghosts made of sheets, we will simply use multicolor lights floating around and passing through the walls and the floor.

Tips for measurements

One beginner mistake we always make when creating something using primitives is using random measures. One unit in Three.js can mean anything you want.

Suppose you are creating a considerable landscape to fly above. In that case, you might think of one unit as one kilometer. If you are building a house, you might think of one unit as one meter, and if you are making a marble game, you might think of one unit as one centimeter.

Having a specific unit ratio will help you create geometries. Let’s say you want to make the door. You know that a door is slightly taller than you, so it should reach around 2 meters.

For those using imperials units, you’ll have to do the conversion.

Setup

The starter is only composed of a floor, a sphere, some lights (way too intense for a haunted house), and shadows aren’t even working.

We will have to create the house all by ourselves, tweak the current lights for a better ambiance, add the shadows, and the rest.

The house

First, let’s remove the sphere and create a tiny house. We can leave the floor.

Instead of putting every object composing that house in the scene, we will first create a container just in case we want to move or scale the whole thing:

// House container
const house = new THREE.Group()
scene.add(house)

JavaScript

Copy

Then we can create the walls with a simple cube and add it to the house . Don’t forget to move the walls up on the y axis; otherwise it will be half inside the floor:

const walls = new THREE.Mesh(
    new THREE.BoxBufferGeometry(4, 2.5, 4),
    new THREE.MeshStandardMaterial({ color: '#ac8e82' })
)
walls.position.y = 1.25
house.add(walls)

JavaScript

Copy

We chose 2.5 for the height because it would seem like a normal height for the ceiling. We also chose '#ac8e82' for the color, but it’s temporary, and we will replace those colors with textures later.

For the roof, we want to make a pyramid shape. The problem is that Three.js doesn’t have this kind of geometry. But if you start from a cone and reduce the number of sides to 4 , you’ll get a pyramid. Sometimes you just have to deflect basic features usages:

// Roof
const roof = new THREE.Mesh(
    new THREE.ConeBufferGeometry(3.5, 1, 4),
    new THREE.MeshStandardMaterial({ color: '#b35f45' })
)
roof.rotation.y = Math.PI * 0.25
roof.position.y = 2.5 + 0.5
house.add(roof)

JavaScript

Copy

Finding the right position and the right rotation might be a little hard. Take your time, try to figure out the logic behind the values, and don’t forget that Math.PI is your friend.

As you can see, we left 2.5 + 0.5 . We could have written 3 but it’s sometime better to visualize the logic behind the value. 2.5 , because the roof walls are 2.5 units high and 0.5 because the cone is 1 unit high (and we need to move it up to half its height).

We will use a simple plane for the door as we are going to use the beautiful door texture we used in a previous lesson.

// Door
const door = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(2, 2),
    new THREE.MeshStandardMaterial({ color: '#aa7b7b' })
)
door.position.y = 1
door.position.z = 2 + 0.01
house.add(door)

JavaScript

Copy

We don’t know yet if the plane has the right size, but we can fix that later when we have the textures working.

As you can see, we move the door on the z axis to stick it to the wall but we also added 0.01 units. If you don’t add this small value, you’ll have a bug we already saw in a previous lesson called z-fighting. Z-fighting happens when you have two faces in the same position (or very close). The GPU doesn’t know which one is closer than the other, and you get some strange visual pixel fighting.

Let’s add some bushes. Instead of creating one geometry for each bush, we will create only one, and all meshes will share it. The result will be visually the same, but we’ll get a performance improvement. We can do the same with the material.

// Bushes
const bushGeometry = new THREE.SphereBufferGeometry(1, 16, 16)
const bushMaterial = new THREE.MeshStandardMaterial({ color: '#89c854' })

const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
bush1.scale.set(0.5, 0.5, 0.5)
bush1.position.set(0.8, 0.2, 2.2)

const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
bush2.scale.set(0.25, 0.25, 0.25)
bush2.position.set(1.4, 0.1, 2.1)

const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
bush3.scale.set(0.4, 0.4, 0.4)
bush3.position.set(- 0.8, 0.1, 2.2)

const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
bush4.scale.set(0.15, 0.15, 0.15)
bush4.position.set(- 1, 0.05, 2.6)

house.add(bush1, bush2, bush3, bush4)

JavaScript

Copy

It does take too long to place and scale all these objects directly in the code. In a later lesson, we will learn how to use a 3D software to create all of this.

We won’t add too many details to the house because we must move forward, but feel free to pause and add anything you want like low walls, an alley, windows, a chimney, rocks, etc.

The graves

Instead of placing each grave manually, we are going to create and place them procedurally.

The idea is to place the graves randomly on a circle around the house.

First, let’s create a container just in case:

// Graves
const graves = new THREE.Group()
scene.add(graves)

JavaScript

Copy

Like in the 3D Text lesson where we created multiple donuts with one geometry and one material, we are going to create one BoxBufferGeometryand one MeshStandardMaterial that will be shared amongst every graves:

const graveGeometry = new THREE.BoxBufferGeometry(0.6, 0.8, 0.2)
const graveMaterial = new THREE.MeshStandardMaterial({ color: '#b2b6b1' })

JavaScript

Copy

Finally, let’s loop and do some mathematics to position a bunch of graves around the house.

We are going to create a random angle on a circle. Remember that a full revolution is 2 times π. Then we are going to use that angle on both a sin(...) and a cos(...) . This is how you position things on a circle when you have the angle. And finally we also multiply those sin(...) and cos(...) results by a random value because we don’t want the graves to be positioned on a perfect circle.

for(let i = 0; i < 50; i++)
{
    const angle = Math.random() * Math.PI * 2 // Random angle
    const radius = 3 + Math.random() * 6      // Random radius
    const x = Math.cos(angle) * radius        // Get the x position using cosinus
    const z = Math.sin(angle) * radius        // Get the z position using sinus

    // Create the mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)

    // Position
    grave.position.set(x, 0.3, z)                              

    // Rotation
    grave.rotation.z = (Math.random() - 0.5) * 0.4
    grave.rotation.y = (Math.random() - 0.5) * 0.4

    // Add to the graves container
    graves.add(grave)
}

JavaScript

Copy

Lights

We have a pretty cool scene, but it’s not that scary yet.

First, let’s dim the ambient and moon lights and give those a more blue-ish color:

const ambientLight = new THREE.AmbientLight('#b9d5ff', 0.12)

// ...

const moonLight = new THREE.DirectionalLight('#b9d5ff', 0.12)

JavaScript

Copy

We can’t see much right now. Let’s also add a warm PointLight above the door. Instead of adding this light to the scene, we can add it to the house:

// Door light
const doorLight = new THREE.PointLight('#ff7d46', 1, 7)
doorLight.position.set(0, 2.2, 2.7)
house.add(doorLight)

JavaScript

Copy

Fog

In horror movies, they always use fog. The good news is that Three.js supports it already with the Fog class.

The first parameter is the color , the second parameter is the near (how far from the camera does the fog start), and the third parameter is the far (how far from the camera will the fog be fully opaque).

To activate the fog, add the fog property to the scene :

/**
 * Fog
 */
const fog = new THREE.Fog('#262837', 1, 15)
scene.fog = fog

JavaScript

Copy

Not bad, but we can see a clean cut between the graves and the black background.

To fix that, we must change the clear color of the renderer and use the same color as the fog. Do that after instantiating the renderer :

renderer.setClearColor('#262837')

JavaScript

Copy

Here’s a slightly scarier scene.

Textures

For even more realism, we can add textures. The textureLoader is already in the code.

The door

Let’s start with something we already know and load all the door textures:

const doorColorTexture = textureLoader.load('/textures/door/color.jpg')
const doorAlphaTexture = textureLoader.load('/textures/door/alpha.jpg')
const doorAmbientOcclusionTexture = textureLoader.load('/textures/door/ambientOcclusion.jpg')
const doorHeightTexture = textureLoader.load('/textures/door/height.jpg')
const doorNormalTexture = textureLoader.load('/textures/door/normal.jpg')
const doorMetalnessTexture = textureLoader.load('/textures/door/metalness.jpg')
const doorRoughnessTexture = textureLoader.load('/textures/door/roughness.jpg')

JavaScript

Copy

Then we can apply all those textures to the door material. Don’t forget to add more subdivisions to the PlaneBufferGeometry, so the displacementMap has some vertices to move. Also, add the uv2 attribute to the geometry for the aoMap as we did in the Materials lesson.

You can access the door’s geometry by using mesh.geometry :

const door = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(2, 2, 100, 100),
    new THREE.MeshStandardMaterial({
        map: doorColorTexture,
        transparent: true,
        alphaMap: doorAlphaTexture,
        aoMap: doorAmbientOcclusionTexture,
        displacementMap: doorHeightTexture,
        displacementScale: 0.1,
        normalMap: doorNormalTexture,
        metalnessMap: doorMetalnessTexture,
        roughnessMap: doorRoughnessTexture
    })
)
door.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(door.geometry.attributes.uv.array, 2))

JavaScript

Copy

There you go! That’s a more realistic door.

Now that we have the textures, you realize that the door is a little too small. You can simply increase the PlaneBufferGeometry sizes:

// ...
    new THREE.PlaneBufferGeometry(2.2, 2.2, 100, 100),
// ...

JavaScript

Copy

The walls

Let’s do the same for the walls using the textures on the /static/textures/bricks/ folder. We don’t have as many textures as for the door, but it’s not a problem. We don’t need an alpha texture, and the wall has no metal in it, so we don’t need a metalness texture either.

Load the textures:

const bricksColorTexture = textureLoader.load('/textures/bricks/color.jpg')
const bricksAmbientOcclusionTexture = textureLoader.load('/textures/bricks/ambientOcclusion.jpg')
const bricksNormalTexture = textureLoader.load('/textures/bricks/normal.jpg')
const bricksRoughnessTexture = textureLoader.load('/textures/bricks/roughness.jpg')

JavaScript

Copy

Then we can update our MeshStandardMaterial for the wall. Don’t forget to remove the color and add the uv2 attribute for the ambient occlusion.

const walls = new THREE.Mesh(
    new THREE.BoxBufferGeometry(4, 2.5, 4),
    new THREE.MeshStandardMaterial({
        map: bricksColorTexture,
        aoMap: bricksAmbientOcclusionTexture,
        normalMap: bricksNormalTexture,
        roughnessMap: bricksRoughnessTexture
    })
)
walls.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(walls.geometry.attributes.uv.array, 2))

JavaScript

Copy

The floor

Same deal as for the walls. The grass textures are located in the /static/textures/grass/ folder.

Load the textures:

const grassColorTexture = textureLoader.load('/textures/grass/color.jpg')
const grassAmbientOcclusionTexture = textureLoader.load('/textures/grass/ambientOcclusion.jpg')
const grassNormalTexture = textureLoader.load('/textures/grass/normal.jpg')
const grassRoughnessTexture = textureLoader.load('/textures/grass/roughness.jpg')

JavaScript

Copy

Update the MeshStandardMaterial of the floor and don’t forget to remove the color and add the uv2 attribute for the ambient occlusion:

const floor = new THREE.Mesh(
    new THREE.PlaneBufferGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        map: grassColorTexture,
        aoMap: grassAmbientOcclusionTexture,
        normalMap: grassNormalTexture,
        roughnessMap: grassRoughnessTexture
    })
)
floor.geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(floor.geometry.attributes.uv.array, 2))

JavaScript

Copy

The texture is too large. To fix that, we can simply repeat each grass texture with the repeat property:

grassColorTexture.repeat.set(8, 8)
grassAmbientOcclusionTexture.repeat.set(8, 8)
grassNormalTexture.repeat.set(8, 8)
grassRoughnessTexture.repeat.set(8, 8)

JavaScript

Copy

And don’t forget to change the wrapS and wrapT properties to activate the repeat:

grassColorTexture.wrapS = THREE.RepeatWrapping
grassAmbientOcclusionTexture.wrapS = THREE.RepeatWrapping
grassNormalTexture.wrapS = THREE.RepeatWrapping
grassRoughnessTexture.wrapS = THREE.RepeatWrapping

grassColorTexture.wrapT = THREE.RepeatWrapping
grassAmbientOcclusionTexture.wrapT = THREE.RepeatWrapping
grassNormalTexture.wrapT = THREE.RepeatWrapping
grassRoughnessTexture.wrapT = THREE.RepeatWrapping

JavaScript

Copy

Ghosts

For the ghosts, let’s keep things simple and do with what we know.

We are going to use simple lights floating around the house and passing through the ground and graves.

/**
 * Ghosts
 */
const ghost1 = new THREE.PointLight('#ff00ff', 2, 3)
scene.add(ghost1)

const ghost2 = new THREE.PointLight('#00ffff', 2, 3)
scene.add(ghost2)

const ghost3 = new THREE.PointLight('#ffff00', 2, 3)
scene.add(ghost3)

JavaScript

Copy

Now we can animate them using some mathematics with a lot of trigonometry:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Ghosts
    const ghost1Angle = elapsedTime * 0.5
    ghost1.position.x = Math.cos(ghost1Angle) * 4
    ghost1.position.z = Math.sin(ghost1Angle) * 4
    ghost1.position.y = Math.sin(elapsedTime * 3)

    const ghost2Angle = - elapsedTime * 0.32
    ghost2.position.x = Math.cos(ghost2Angle) * 5
    ghost2.position.z = Math.sin(ghost2Angle) * 5
    ghost2.position.y = Math.sin(elapsedTime * 4) + Math.sin(elapsedTime * 2.5)

    const ghost3Angle = - elapsedTime * 0.18
    ghost3.position.x = Math.cos(ghost3Angle) * (7 + Math.sin(elapsedTime * 0.32))
    ghost3.position.z = Math.sin(ghost3Angle) * (7 + Math.sin(elapsedTime * 0.5))
    ghost3.position.y = Math.sin(elapsedTime * 4) + Math.sin(elapsedTime * 2.5)

    // ...
}

JavaScript

Copy

Shadows

Finally, to add more realism, let’s add shadows.

Activate the shadow map on the renderer:

renderer.shadowMap.enabled = true

JavaScript

Copy

Activate the shadows on the lights that you think should cast shadows:

moonLight.castShadow = true
doorLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true

JavaScript

Copy

Go through each object of your scene and decide if that object can cast and/or receive shadows:

walls.castShadow = true
bush1.castShadow = true
bush2.castShadow = true
bush3.castShadow = true
bush4.castShadow = true

for(let i = 0; i < 50; i++)
{
    // ...
    grave.castShadow = true
    // ...
}

floor.receiveShadow = true

JavaScript

Copy

The scene looks much better with these shadows, but we should optimize them anyway.

A good thing would be to go through each light, create camera helpers on the light.shadowMap.camera , and make sure the near , the far , the amplitude or the fov fit nicely. But instead, let’s use the following values that should be just right.

We can also reduce the shadow map render sizes to improve performances:

moonLight.shadow.mapSize.width = 256
moonLight.shadow.mapSize.height = 256
moonLight.shadow.camera.far = 15

// ...

doorLight.shadow.mapSize.width = 256
doorLight.shadow.mapSize.height = 256
doorLight.shadow.camera.far = 7

// ...

ghost1.shadow.mapSize.width = 256
ghost1.shadow.mapSize.height = 256
ghost1.shadow.camera.far = 7

// ...

ghost2.shadow.mapSize.width = 256
ghost2.shadow.mapSize.height = 256
ghost2.shadow.camera.far = 7

// ...

ghost3.shadow.mapSize.width = 256
ghost3.shadow.mapSize.height = 256
ghost3.shadow.camera.far = 7

// ...

renderer.shadowMap.type = THREE.PCFSoftShadowMap

JavaScript

Copy

This process is long, but it’s essential. We are already flirting with performance limitations, and the haunted house might not even work at 60fps on mobile. We will see more optimization tips in a future lesson.

Go further

That’s it for the lesson, but you can try to improve what we did. You can add new elements in the scene, replace the ghosts with real 3D ghosts by using Three.js primitives, add names on the grave, etc.

17 Particles

Difficulty Hard

Introduction

Particles are precisely what you expect from that name. They are very popular and can be used to achieve various effects such as stars, smoke, rain, dust, fire, and many other things.

The good thing with particles is that you can have hundreds of thousands of them on screen with a reasonable frame rate. The downside is that each particle is composed of a plane (two triangles) always facing the camera.

Creating particles is as simple as making a Mesh. We need a geometry (ideally a BufferGeometry), a material that can handle particles (PointsMaterial), and instead of producing a Mesh we need to create a Points.

Setup

The starter is only composed of a cube in the middle of the scene. That cube ensures that everything is working.

First particles

Let’s get rid of our cube and create a sphere composed of particles to start.

Geometry

You can use any of the basic Three.js geometries. For the same reasons as for the Mesh, it’s preferable to use BufferGeometries. Each vertex of the geometry will become a particle:

/**
 * Particles
 */
// Geometry
const particlesGeometry = new THREE.SphereBufferGeometry(1, 32, 32)

JavaScript

Copy

PointsMaterial

We need a special type of material called PointsMaterial. This material can already do a lot, but we will discover how to create our own particles material to go even further in a future lesson.

The PointsMaterial has multiple properties specific to particles like the size to control all particles size and the sizeAttenuation to specify if distant particles should be smaller than close particles:

// Material
const particlesMaterial = new THREE.PointsMaterial({
    size: 0.02,
    sizeAttenuation: true
})

JavaScript

Copy

As always, we can also change those properties after creating the material:

const particlesMaterial = new THREE.PointsMaterial()
particlesMaterial.size = 0.02
particlesMaterial.sizeAttenuation = true

JavaScript

Copy

Points

Finally, we can create the final particles the same way we create a Mesh, but this time by using the Points class. Don’t forget to add it to the scene:

// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)

JavaScript

Copy

That was easy. Let’s customize those particles.

Custom geometry

To create a custom geometry, we can start from a BufferGeometry, and add a position attribute as we did in the Geometries lesson. Replace the SphereBufferGeometry with custom geometry and add the 'position' attribute as we did before:

// Geometry
const particlesGeometry = new THREE.BufferGeometry()
const count = 500

const positions = new Float32Array(count * 3) // Multiply by 3 because each position is composed of 3 values (x, y, z)

for(let i = 0; i < count * 3; i++) // Multiply by 3 for same reason
{
    positions[i] = (Math.random() - 0.5) * 10 // Math.random() - 0.5 to have a random value between -0.5 and +0.5
}

particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) // Create the Three.js BufferAttribute and specify that each information is composed of 3 values

JavaScript

Copy

Don’t be frustrated if you can’t pull out this code by yourself. It’s a little complex, and variables are using strange formats.

You should get a bunch of particles all around the scene. Now is an excellent time to have fun and test the limits of your computer. Try 5000 , 50000 , 500000 maybe. You can have millions of particles and still have a reasonable frame rate.

You can imagine that there are limits. On an inferior computer or a smartphone, you won’t be able to have a 60fps experience with millions of particles. We are also going to add effects that will drastically reduce the frame rate. But still, that’s quite impressive.

For now, let’s keep the count to 5000 and change the size to 0.1 :

const count = 5000

// ...

particlesMaterial.size = 0.1

// ...

JavaScript

Copy

Color, map and alpha map

We can change the color of all particles with the color property on the PointsMaterial. Don’t forget that you need to use the Color class if you’re changing this property after instancing the material:

particlesMaterial.color = new THREE.Color('#ff88cc')

JavaScript

Copy

We can also use the map property to put a texture on those particles. Use the TextureLoader already in the code to load one of the textures located in /static/textures/particles/ :

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const particleTexture = textureLoader.load('/textures/particles/2.png')

// ...

particlesMaterial.map = particleTexture

JavaScript

Copy

These textures are resized versions of the pack provided by Kenney and you can find the full pack here: https://www.kenney.nl/assets/particle-pack. But you can also create your own.

As you can see, the color property is changing the map, just like with the other materials.

If you look closely, you’ll see that the front particles are hiding the back particles.

We need to activate transparency with transparent and use the texture on the alphaMap property instead of the map :

// particlesMaterial.map = particleTexture
particlesMaterial.transparent = true
particlesMaterial.alphaMap = particleTexture

JavaScript

Copy

Now that’s better, but we can still randomly see some edges of the particles.

That is because the particles are drawn in the same order as they are created, and WebGL doesn’t really know which one is in front of the other.

There are multiple ways of fixing this.

Using alphaTest

The alphaTest is a value between 0 and 1 that enables the WebGL to know when not to render the pixel according to that pixel’s transparency. By default, the value is 0 meaning that the pixel will be rendered anyway. If we use a small value such as 0.001 , the pixel won’t be rendered if the alpha is 0 :

particlesMaterial.alphaTest = 0.001

JavaScript

Copy

This solution isn’t perfect and if you watch closely, you can still see glitches, but it’s already more satisfying.

Using depthTest

When drawing, the WebGL tests if what’s being drawn is closer than what’s already drawn. That is called depth testing and can be deactivated (you can comment the alphaTest ):

// particlesMaterial.alphaTest = 0.001
particlesMaterial.depthTest = false

JavaScript

Copy

While this solution seems to completely fix our problem, deactivating the depth testing might create bugs if you have other objects in your scene or particles with different colors. The particles might be drawn as if they were above the rest of the scene.

Add a cube to the scene to see that:

const cube = new THREE.Mesh(
    new THREE.BoxBufferGeometry(),
    new THREE.MeshBasicMaterial()
)
scene.add(cube)

JavaScript

Copy

Using depthWrite

As we said, the WebGL is testing if what’s being drawn is closer than what’s already drawn. The depth of what’s being drawn is stored in what we call a depth buffer. Instead of not testing if the particle is closer than what’s in this depth buffer, we can tell the WebGL not to write particles in that depth buffer (you can comment the depthTest ):

// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false

JavaScript

Copy

In our case, this solution will fix the problem with almost no drawback. Sometimes, other objects might be drawn behind or in front of the particles depending on many factors like the transparency, in which order you added the objects to your scene, etc.

We saw multiple techniques, and there is no perfect solution. You’ll have to adapt and find the best combination according to the project.

Blending

Currently, the WebGL draws the pixels one on top of the other.

By changing the blending property, we can tell the WebGL not only to draw the pixel, but also to add the color of that pixel to the color of the pixel already drawn. That will have a saturation effect that can look amazing.

To test that, simply change the blending property to THREE.AdditiveBlending (keep the depthWrite property):

// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false
particlesMaterial.blending = THREE.AdditiveBlending

JavaScript

Copy

Add more particles (let’s say 20000 ) to better enjoy this effect.

But be careful, this effect will impact the performances, and you won’t be able to have as many particles as before at 60fps.

Now, we can remove the cube .

Different colors

We can have a different color for each particle. We first need to add a new attribute named color as we did for the position. A color is composed of red, green, and blue (3 values), so the code will be very similar to the position attribute. We can actually use the same loop for these two attributes:

const positions = new Float32Array(count * 3)
const colors = new Float32Array(count * 3)

for(let i = 0; i < count * 3; i++)
{
    positions[i] = (Math.random() - 0.5) * 10
    colors[i] = Math.random()
}

particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

JavaScript

Copy

Be careful with singulars and plurals.

To activate those vertex colors, simply change the vertexColors property to true :

particlesMaterial.vertexColors = true

JavaScript

Copy

The main color of the material still affects these vertex colors. Feel free to change that color or even comment it.

// particlesMaterial.color = new THREE.Color('#ff88cc')

JavaScript

Copy

Animate

There are multiple ways of animating particles.

By using the points as an object

Because the Points class inherits from the Object3D class, you can move, rotate and scale the points as you wish.

Rotate the particles in the tick function:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update particles
    particles.rotation.y = elapsedTime * 0.2

    // ...
}

JavaScript

Copy

While this is already cool, we want more control over each particle.

By changing the attributes

Another solution would be to update each vertex position separately. This way, vertices can have different trajectories. We are going to animate the particles as if they were floating on waves but first, let’s see how we can update the vertices.

Start by commenting the previous rotation we did on the whole particles :

const tick = () =>
{
    // ...

    // particles.rotation.y = elapsedTime * 0.2

    // ...
}

JavaScript

Copy

To update each vertex, we have to update the right part in the position attribute because all the vertices are stored in this one dimension array where the first 3 values correspond to the x , y and z coordinates of the first vertex, then the next 3 values correspond to the x , y and z of the second vertex, etc.

We only want the vertices to move up and down, meaning that we are going to update the y axis only. Because the position attribute is a one dimension array, we have to go through it 3 by 3 and only update the second value which is the y coordinate.

Let’s start by going through each vertices:

const tick = () =>
{
        // ...

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3
    }

    // ...
}

JavaScript

Copy

Here, we chose to have a simple for loop that goes from 0 to count and we created a i3 variable inside that goes 3 by 3 simply by multiplying i by 3.

The easiest way to simulate waves movement is to use a simple sinus . First, we are going to update all vertices to go up and down on the same frequency.

The y coordinate can be access in the array at the index i3 + 1 :

const tick = () =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
    }

    // ...
}

JavaScript

Copy

Unfortunately, nothing is moving. The problem is that Three.js has to be notified that the geometry changed. To do that, we have to set the needsUpdate to true on the position attribute once we are done updating the vertices:

const tick = () =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        const i3 = i * 3

        particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
    }
    particlesGeometry.attributes.position.needsUpdate = true 

    // ...
}

JavaScript

Copy

All the particles should be moving up and down like a plane.

That’s a good start and we are almost there. All we need to do now is apply an offset to the sinus between the particles so that we get that wave shape.

To do that, we can use the x coordinate. And to get this value we can use the same technique that we used for the y coordinate but instead of i3 + 1 , it’s just i3 :

const tick = () =>
{
    // ...

    for(let i = 0; i < count; i++)
    {
        let i3 = i * 3

        const x = particlesGeometry.attributes.position.array[i3]
        particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime + x)
    }
    particlesGeometry.attributes.position.needsUpdate = true

    // ...
}

JavaScript

Copy

You should get beautiful waves of particles. Unfortunately, you should avoid this technique. If we have 20000 particles, we are going through each one, calculating a new position, and updating the whole attribute on each frame. That can work with a small number of particles, but we want millions of particles.

By using a custom shader

To update these millions of particles on each frame with a good framerate, we need to create our own material with our own shaders. But shaders are for a later lesson.

18 Galaxy Generator

Difficulty Hard

Introduction

Now that we know how to use particles, we could create something cool like a Galaxy. But instead of producing just one galaxy, let’s do a galaxy generator.

For that, we’re going to use Dat.GUI to let the user tweak the parameters and generate a new galaxy on each change.

Setup

The starter is only composed of a cube in the middle of the scene. It ensures that everything is working.

Base particles

First, remove the cube and create a generateGalaxy function. Each time we call that function, we will remove the previous galaxy (if there is one) and create a new one.

We can call that function immediately:

/**
 * Galaxy
 */
const generateGalaxy = () =>
{

}

generateGalaxy()

JavaScript

Copy

We can create an object that will contain all the parameters of our galaxy. Create this object before the generateGalaxy function. We will populate it progressively and also add each parameter to Dat.GUI :

const parameters = {}

JavaScript

Copy

In our generateGalaxy function, we’re going to create some particles just to make sure that everything is working. We can start with the geometry and add the particles count to the parameters:

const parameters = {}
parameters.count = 1000

const generateGalaxy = () =>
{
    /**
     * Geometry
     */
    const geometry = new THREE.BufferGeometry()

    const positions = new Float32Array(parameters.count * 3)

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        positions[i3    ] = (Math.random() - 0.5) * 3
        positions[i3 + 1] = (Math.random() - 0.5) * 3
        positions[i3 + 2] = (Math.random() - 0.5) * 3
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
}
generateGalaxy()

JavaScript

Copy

That’s the same code as before, but we handled the loop a little differently.

We can now create the material by using the PointsMaterial class. This time again, we can add tweaks to the parameters object:

parameters.size = 0.02

const generateGalaxy = () =>
{
    // ...

    /**
     * Material
     */
    const material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    })
}

JavaScript

Copy

Finally, we can create the points by using the Points class and add it to the scene:

const generateGalaxy = () =>
{
    // ...

    /**
     * Points
     */
    const points = new THREE.Points(geometry, material)
    scene.add(points)
}

JavaScript

Copy

You should see few points floating around.

Tweaks

We have already two parameters, count and size . Let’s add them to the Dat.GUI instance that we already created at the start of the code. As you can imagine, we must add those tweaks after creating the parameters:

parameters.count = 1000
parameters.size = 0.02

gui.add(parameters, 'count').min(100).max(1000000).step(100)
gui.add(parameters, 'size').min(0.001).max(0.1).step(0.001)

JavaScript

Copy

You should have two new ranges in the tweaks but changing them doesn’t generate a new galaxy. To generate a new galaxy, you must listen to the change event. More precisely to the finishChange event to prevent generating galaxies while you are drag and dropping the range value:

gui.add(parameters, 'count').min(100).max(1000000).step(100).onFinishChange(generateGalaxy)
gui.add(parameters, 'size').min(0.001).max(0.1).step(0.001).onFinishChange(generateGalaxy)

JavaScript

Copy

This code won’t work because the generateGalaxy doesn’t exist yet. You must move those tweaks after the generateGalaxy function.

Be careful, we still have a problem, and if you play too much with the tweaks, your computer will start to heat. It’s because we didn’t destroy the previously created galaxy. We are creating galaxies one above the other.

To make things right, we must first move the geometry , material and points variables outside the generateGalaxy .

let geometry = null
let material = null
let points = null

const generateGalaxy = () =>
{
    // ...

    geometry = new THREE.BufferGeometry()

    // ...

    material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    })

    // ...

    points = new THREE.Points(geometry, material)

    // ...
}

JavaScript

Copy

Then, before assigning those variables, we can test if they already exist. If so, we can call the dispose() method on the geometry and the material. Then remove the points from the scene with the remove() method:

const generateGalaxy = () =>
{
    // Destroy old galaxy
    if(points !== null)
    {
        geometry.dispose()
        material.dispose()
        scene.remove(points)
    }

    // ...
}

JavaScript

Copy

Instead of using a texture that can create depth and alpha issues as we saw in the previous lesson, we will just let the default square shape. Don’t worry; there will be so many small particles that we won’t notice that they are squares.

Now that we can estimate how much particles we can have and their size, let’s update the parameters:

parameters.count = 100000
parameters.size = 0.01

JavaScript

Copy

Shape

Galaxies can have several different shapes. We will focus on the spirals one. There are many ways to position the particles to create a galaxy. Feel free to try your way before testing the lesson way.

Radius

First, let’s create a radius parameter:

parameters.radius = 5

// ...

gui.add(parameters, 'radius').min(0.01).max(20).step(0.01).onFinishChange(generateGalaxy)

JavaScript

Copy

Each star will be positioned accordingly to that radius. If the radius is 5 , the stars will be positioned at a distance from 0 to 5 . For now, let’s position all the particles on a straight line:

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius

        positions[i3    ] = radius
        positions[i3 + 1] = 0
        positions[i3 + 2] = 0
    }

JavaScript

Copy

Branches

Spin galaxies always seem to have at least two branches, but it can have much more.

Create a branches parameter:

parameters.branches = 3

// ...

gui.add(parameters, 'branches').min(2).max(20).step(1).onFinishChange(generateGalaxy)

JavaScript

Copy

We can use Math.cos(...) and Math.sin(...) to position the particles on those branches. We first calculate an angle with the modulo ( % ), divide the result by the branches count parameter to get an angle between 0 and 1 , and multiply this value by Math.PI * 2 to get an angle between 0 and a full circle. We then use that angle with Math.cos(...) and Math.sin(...) for the x and the z axis and we finally multiply by the radius:

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2

        positions[i3    ] = Math.cos(branchAngle) * radius
        positions[i3 + 1] = 0
        positions[i3 + 2] = Math.sin(branchAngle) * radius
    }

JavaScript

Copy

Spin

Let’s add the spin effect.

Create a spin parameter:

parameters.spin = 1

// ...

gui.add(parameters, 'spin').min(- 5).max(5).step(0.001).onFinishChange(generateGalaxy)

JavaScript

Copy

Then we can multiply the spinAngle by that spin parameter. To put it differently, the further the particle is from the center, the more spin it’ll endure:

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius
        const spinAngle = radius * parameters.spin
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2

        positions[i3    ] = Math.cos(branchAngle + spinAngle) * radius
        positions[i3 + 1] = 0
        positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius
    }

JavaScript

Copy

Randomness

Those particles are perfectly aligned. We need randomness. But what we truly need is spread stars on the outside and more condensed star on the inside.

Create a randomness parameter:

parameters.randomness = 0.2

// ...

gui.add(parameters, 'randomness').min(0).max(2).step(0.001).onFinishChange(generateGalaxy)

JavaScript

Copy

Now create a random value for each axis with Math.random() , multiply it by the radius and then add those values to the postions :

    for(let i = 0; i < parameters.count; i++)
    {
        const i3 = i * 3

        const radius = Math.random() * parameters.radius

        const spinAngle = radius * parameters.spin
        const branchAngle = (i % parameters.branches) / parameters.branches * Math.PI * 2

        const randomX = (Math.random() - 0.5) * parameters.randomness * radius
        const randomY = (Math.random() - 0.5) * parameters.randomness * radius
        const randomZ = (Math.random() - 0.5) * parameters.randomness * radius

        positions[i3    ] = Math.cos(branchAngle + spinAngle) * radius + randomX
        positions[i3 + 1] = randomY
        positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ
    }

JavaScript

Copy

It’s working but it’s not very convincing, right? And we can still see the pattern. To fix that, we can use Math.pow() to crush the value. The more power you apply, the closest to 0 it will get. The problem is that you can’t use a negative value with Math.pow() . What we will do is calculate the power then multiply it by -1 randomly.

First create the power parameter:

parameters.randomnessPower = 3

// ...

gui.add(parameters, 'randomnessPower').min(1).max(10).step(0.001).onFinishChange(generateGalaxy)

JavaScript

Copy

Then apply the power with Math.pow() and multiply it by -1 randomly:

        const randomX = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
        const randomY = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius
        const randomZ = Math.pow(Math.random(), parameters.randomnessPower) * (Math.random() < 0.5 ? 1 : - 1) * parameters.randomness * radius

JavaScript

Copy

Colors

For a better effect, we need to add some colors to our creation. A cool thing would be to have different colors inside the galaxy and on its edges.

First, add the colors parameters:

parameters.insideColor = '#ff6030'
parameters.outsideColor = '#1b3984'

// ...

gui.addColor(parameters, 'insideColor').onFinishChange(generateGalaxy)
gui.addColor(parameters, 'outsideColor').onFinishChange(generateGalaxy)

JavaScript

Copy

We’re going to provide a color for each vertex. We must active the vertexColors on the material:

    material = new THREE.PointsMaterial({
        size: parameters.size,
        sizeAttenuation: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending,
        vertexColors: true
    })

JavaScript

Copy

Then add a color attribute on our geometry just like we added the position attribute. For now, we’re not using the insideColor and outsideColor parameters:

    geometry = new THREE.BufferGeometry()

    const positions = new Float32Array(parameters.count * 3)
    const colors = new Float32Array(parameters.count * 3)

    for(let i = 0; i < parameters.count; i++)
    {
        // ...

        colors[i3    ] = 1
        colors[i3 + 1] = 0
        colors[i3 + 2] = 0
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

JavaScript

Copy

You should get a red galaxy.

To use the colors from the parameters, we first need to create a Color instance for each one. We have to do it inside the generateGalaxy function for reasons you’ll understand in a bit:

const generateGalaxy = () =>
{
    // ...

    const colorInside = new THREE.Color(parameters.insideColor)
    const colorOutside = new THREE.Color(parameters.outsideColor)

    // ...
}

JavaScript

Copy

Inside the loop function, we want to mix these colors into a third color. That mix depends on the distance from the center of the galaxy. If the particle is at the center of the galaxy, it’ll have the insideColor and the further it gets from the center, the more it will get mixed with the outsideColor .

Instead of creating a third Color, we are going to clone the colorInside and then use the lerp(...) method to interpolate the color from that base color to another one. The first parameter of lerp(...) is the other color, and the second parameter is a value between 0 and 1 . If it’s 0 , the color will keep its base value, and if it’s 1 the result color will be the one provided. We can use the radius divided by the radius parameter:

        const mixedColor = colorInside.clone()
        mixedColor.lerp(colorOutside, radius / parameters.radius)

JavaScript

Copy

We can then use the r , g and b properties in our colors array:

        colors[i3    ] = mixedColor.r
        colors[i3 + 1] = mixedColor.g
        colors[i3 + 2] = mixedColor.b

JavaScript

Copy

And here you have a beautiful galaxy generator. You can play with the tweaks and continue to add parameters and improve the style of your galaxies.

Try not to burn your computer.

Go further

To go further, you can try to add more tweaks or test other galaxy shapes. We will learn how to animate all the particles in a cool spin animation in a future lesson.

19 Raycaster

Difficulty Hard

Introduction

As the name suggests, a Raycaster can cast (or shoot) a ray in a specific direction and test what objects intersect with it.

You can use that technique to detect if there is a wall in front of the player, test if the laser gun hit something, test if something is currently under the mouse to simulate mouse events, and many other things.

Setup

In our starter, we have 3 red spheres, and we are going to shoot a ray through and see if those spheres intersect.

Create the Raycaster

Instantiate a Raycaster:

/**
 * Raycaster
 */
const raycaster = new THREE.Raycaster()

JavaScript

Copy

To change the position and direction where ray will be cast, we can use the set(...) method. The first parameter is the position and the second parameter is the direction .

Both are Vector3, but the direction has to be normalized. A normalized vector has a length of 1 . Don’t worry, you don’t have to do the mathematics by yourself, and you can call the normalize() method on the vector:

const rayOrigin = new THREE.Vector3(- 3, 0, 0)
const rayDirection = new THREE.Vector3(10, 0, 0)
rayDirection.normalize()

raycaster.set(rayOrigin, rayDirection)

JavaScript

Copy

This example of a normalized vector isn’t very relevant because we could have set 1 instead of 10 , but if we change the values, we will still have the normalize() method making sure that the vector is 1 unit long.

Here, the ray position supposedly start a little on the left in our scene, and the direction seems to go to the right. Our ray should go through all the spheres.

Cast a ray

To cast a ray and get the objects that intersect we can use two methods, intersectObject(...) (singular) and intersectObjects(...) (plural).

intersectObject(...) will test one object and intersectObjects(...) will test an array of objects:

const intersect = raycaster.intersectObject(object2)
console.log(intersect)

const intersects = raycaster.intersectObjects([object1, object2, object3])
console.log(intersects)

JavaScript

Copy

If you look at the logs, you’ll see that intersectObject(...) returned an array of one item (probably the second sphere) and intersectObjects(...) , returned an array of three items (probably the 3 spheres).

Result of an intersection

The result of an intersection is always an array, even if you are testing only one object. That is because a ray can go through the same object multiple times. Imagine a donut. The ray will go through the first part of the ring, then the middle’s hole, then again the second part of the ring.

Each item of that returned array contains much useful information:

  • distance : the distance between the origin of the ray and the collision point.
  • face : what face of the geometry was hit by the ray.
  • faceIndex : the index of that face.
  • object : what object is concerned by the collision.
  • point : a Vector3 of the exact position in 3D space of the collision.
  • uv : the UV coordinates in that geometry.

It’s up to you to use that data. If you want to test if there is a wall in front of the player, you can test the distance . If you’re going to change the object’s color, you can update the object 's material. If you want to show an explosion on the impact point, you can create this explosion at the point position.

Test on each frame

Currently, we only cast one ray at the start. If we want to test things while they are moving, we have to do the test on each frame. Let’s animate the spheres and turn them blue when the ray intersects with them.

Remove the code we did previously and only keep the raycaster instantiation:

const raycaster = new THREE.Raycaster()

JavaScript

Copy

Animate the spheres by using the elapsed time and classic Math.sin(...) in the tick function:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Animate objects
    object1.position.y = Math.sin(elapsedTime * 0.3) * 1.5
    object2.position.y = Math.sin(elapsedTime * 0.8) * 1.5
    object3.position.y = Math.sin(elapsedTime * 1.4) * 1.5

    // ...
}

JavaScript

Copy

You should see the spheres waving up and down at different frequencies.

Now let’s update our raycaster like we did before but in the tick function:

const clock = new THREE.Clock()

const tick = () =>
{
    // ...

    // Cast a ray
    const rayOrigin = new THREE.Vector3(- 3, 0, 0)
    const rayDirection = new THREE.Vector3(1, 0, 0)
    rayDirection.normalize()

    raycaster.set(rayOrigin, rayDirection)

    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)
    console.log(intersects)

    // ...
}

JavaScript

Copy

Yet again, we don’t really need to normalize the rayDirection because its length is already 1 . But it’s good practice to keep the normalize() in case we change the direction.

We also put the array of objects to test in a variable objectsToTest . That will get handy later.

If you look at the console, you should get an array with intersections in it, and those intersections keep changing depending on the spheres’ positions.

We can now update the material of the object property for each item of the intersects array:

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

JavaScript

Copy

Unluckily, they all go blue but never go back red. There are many ways to turn the objects that didn’t intersect back to red. What we can do is turn all the spheres red and then turn the ones that intersect blue:

    for(const object of objectsToTest)
    {
        object.material.color.set('#ff0000')
    }

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

JavaScript

Copy

Use the raycaster with the mouse

As we said earlier, we can also use the raycaster to test if an object is behind the mouse. In other words, if you are hovering an object.

Mathematically speaking, it’s a little complex because we need to cast a ray from the camera in the mouse’s direction, but, fortunately, Three.js is doing all the heavy lifting.

For now, let’s comment the code related to the raycaster in the tick function.

Hovering

First, let’s handle hovering.

To begin with, we need the coordinates of the mouse. We cannot use the basic native JavaScript coordinates, which are in pixels. We need a value that goes from -1 to +1 in both the horizontal and the vertical axis, with the vertical coordinate being positive when moving the mouse upward.

This is how WebGL works and it’s related to things like clip space but we don’t need to understand those complex concepts.

Examples:

  • The mouse is on the top left of the page: -1 / 1
  • The mouse is on the bottom left of the page: -1 / - 1
  • The mouse is on the middle vertically and at right horizontally: 1 / 0
  • The mouse is in the center of the page: 0 / 0

First, let’s create a mouse variable with a Vector2, and update that variable when the mouse is moving:

/**
 * Mouse
 */
const mouse = new THREE.Vector2()

window.addEventListener('mousemove', (event) =>
{
    mouse.x = event.clientX / sizes.width * 2 - 1
    mouse.y = - (event.clientY / sizes.height) * 2 + 1

    console.log(mouse)
})

JavaScript

Copy

Look at the logs and make sure that the values match the previous examples.

We could cast the ray in the mousemove event callback, but it’s not recommend because the mousemove event might be triggered more than the frame rate for some browsers. Instead, we will cast the ray in the tick function as we did before.

To orient the ray in the right direction, we can use the setFromCamera() method on the Raycaster. The rest of the code is the same as before. We just update the objects materials to red or blue if they intersect or not:

const tick = () =>
{
    // ...

    raycaster.setFromCamera(mouse, camera)

    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)

    for(const intersect of intersects)
    {
        intersect.object.material.color.set('#0000ff')
    }

    for(const object of objectsToTest)
    {
        if(!intersects.find(intersect => intersect.object === object))
        {
            object.material.color.set('#ff0000')
        }
    }

    // ...
}

JavaScript

Copy

The spheres should become red if the cursor is above them.

Mouse enter and mouse leave events

Mouse events like 'mouseenter' , 'mouseleave' , etc. aren’t supported either. If you want to be inform when the mouse “enters” an object or “leaves” that object, you’ll have to do it by yourself.

What we can do to reproduce the mouseenter and mouseleave events, is to have a variable containing the currently hovered object.

If there is one object intersecting, but there wasn’t one before, it means a mouseenter has happened on that object.

If no object intersects, but there was one before, it means a mouseleave has happened.

We just need to save the currently intersecting object:

let currentIntersect = null

JavaScript

Copy

And then, test and update the currentIntersect variable:

const tick = () =>
{
    // ...
    raycaster.setFromCamera(mouse, camera)
    const objectsToTest = [object1, object2, object3]
    const intersects = raycaster.intersectObjects(objectsToTest)

    if(intersects.length)
    {
        if(!currentIntersect)
        {
            console.log('mouse enter')
        }

        currentIntersect = intersects[0]
    }
    else
    {
        if(currentIntersect)
        {
            console.log('mouse leave')
        }

        currentIntersect = null
    }

    // ...
}

JavaScript

Copy

Mouse click event

Now that we have a variable containing the currently hovered object, we can easily implement a click event.

First, we need to listen to the click event regardless of where it happens:

window.addEventListener('click', () =>
{

})

JavaScript

Copy

Then, we can test if the there is something in the currentIntersect variable:

window.addEventListener('click', () =>
{
    if(currentIntersect)
    {
        console.log('click')
    }
})

JavaScript

Copy

We can also test what object was concerned by the click:

window.addEventListener('click', () =>
{
    if(currentIntersect)
    {
        switch(currentIntersect.object)
        {
            case object1:
                console.log('click on object 1')
                break

            case object2:
                console.log('click on object 2')
                break

            case object3:
                console.log('click on object 3')
                break
        }
    }
})

JavaScript

Copy

Reproducing native events takes time, but once you understand it, it’s pretty straightforward.