00:00/00:00
3:22
00:03:22

Shortcuts ⌨️

  • SPACE to play / pause
  • ARROW RIGHT or L to go forward
  • ARROW LEFT or J to go backward
  • ARROW UP to increase volume
  • ARROW DOWN to decrease volume
  • F to toggle fullscreen
  • M to toggle mute
  • 0 to 9 to go to the corresponding part of the video
  • SHIFT + , to decrease playback speed
  • SHIFT + . or ; to increase playback speed

Unlock content 🔓

To get access to 93 hours of video, a members-only Discord server, subtitles, lesson resources, future updates and much more join us for only $95!

Want to learn more? 🤘

16%

That's the end of the free part 😔

To get access to 93 hours of video, a members-only Discord server and future updates, join us for only $95!

Next lesson
16.

Haunted House

Difficulty Hard

Introduction 00:00

In this lesson, we are going to put into practice what we’ve learned so far and start having fun building more of a real project by creating a haunted house:

We are not going to load pre-made models and are exclusively going to rely on primitive shapes. This has limitations, but knowing how to master these primitives to the best of your ability is important.

For the textures, we are going to use Poly Haven. You will actually choose them all by yourself while I explain how to download them in the right format, how to implement them properly and how to optimise them without hurting the quality.

Along the way, we’ll discover some tricks to make the scene look even better with fog, realistic sky, nice shadows, displacement maps, etc.

Setup 02:13

The starter already contains the following:

Timer

A quick note about the Timer class: it’s an alternative to the Clock that we have been using up until now. It’s quite similar but with the following key differences:

  • Fixes a well-known bug we had with the Clock where the values were messed up if calling getElapsedTime() multiple times on the same frame;
  • It needs to be updated manually with timer.update();
  • It tests if the tab is inactive and prevents large weird time values if that’s the case;
  • It needs to be imported manually import { Timer } from 'three/addons/misc/Timer.js'.

Tips for measurements 05:50

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

Imagine 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 imperial units, you'll have to do the conversion.

Floor 07:37

Before removing the placeholder sphere, let’s add the floor.

It’s going to be a square plane perfectly centered in the scene. This way, we can consider 0 on the y-axis to be the floor level when we position the various components of the house.

After the placeholder sphere part, create the floor variable using a Mesh and instantiate a PlaneGeometry and a MeshStandardMaterial in it, then add it to the scene:

// Floor
const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial()
)
scene.add(floor)

We need to rotate it, but before that, let’s talk about what we just did.

First, we instantiated the PlaneGeometry and the MeshStandardMaterial directly in the Mesh. It makes the code slightly shorter than having dedicated variables.

We can still access them if we need to by using floor.geometry and floor.material.

Secondly, we’ve used 20 and 20 for the size of the plane which should be enough to fit our house and the graves, but a square like this won’t look good if we can notice the edges. Fortunately, we are going to pull off a nice trick to fade out the edges later in the lesson.

Thirdly, we’ve used a MeshStandardMaterial because we wanted to go for a realistic look. The textures we are going to use are PBR textures which stands for “Physically Based Rendering”. These will make our material look realistic and convincing.

Let’s rotate the plane. Now would be a good time to try to guess the axis and the rotation angle by yourself.

Set the rotation.x to - Math.PI * 0.5 (the equivalent of a quarter of a circle anti-clockwise):

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial()
)
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)

Later, we are going to add in some textures, but for now, let’s create the various components with a neutral material like this.

Remove the placeholder sphere:

House 14:07

Group

Instead of putting every object that makes up the upcoming house in the scene, we are going to create a Group, just in case we want to move or scale the whole thing.

After the Floor section, create a House container section, instantiate a house Group and add it to the scene:

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

From now on, remember to add the objects that are part of the house (walls, roof, door, etc.) in the house instead of the scene.

Walls

Our house is going to be quite small. Since we consider 1 Three.js unit to be 1 meter, we are going to set its size to 4, 2.5, 4:

After the house Group, create the walls (with an s) Mesh and instantiate a BoxGeometry and a MeshStandardMaterial in it. Then add it to the house (not the scene):

// Walls
const walls = new THREE.Mesh(
    new THREE.BoxGeometry(4, 2.5, 4),
    new THREE.MeshStandardMaterial()
)
house.add(walls)

The walls are halfway buried in the floor because the origin of the geometry is at its center.

Move them up using the position.y property and a value of 1.25 (half of the height):

const walls = new THREE.Mesh(
    // ...
)
walls.position.y += 1.25

That’s all for the walls. Obviously, you can increase the architectural complexity, but let’s stick to something simple for now.

Note that we are going to put a bunch of values here and there to position and rotate the objects. A good practice would have been to put those values in separate variables and to use those variables as a reference for all measurements. Doing that makes updating the house much easier, but it’s a little bit far-fetched for the exercise, which is why we are not going to do it.

Roof

For the roof, we want a pyramid. Have a look at the Three.js documentation and try to find such primitive geometry.

As you can see, there is no “pyramid” geometry. But maybe you can find an alternative.

We are going to use a ConeGeometry and set its sides count to only 4. The first three parameters of the ConeGeometry are the radius, the height and the radialSegments (how many side faces).

Create the roof Mesh, instantiate a ConeGeometry as followed and a MeshStandardMaterial, then add it to the house:

// Roof
const roof = new THREE.Mesh(
    new THREE.ConeGeometry(3.5, 1.5, 4),
    new THREE.MeshStandardMaterial()
)
house.add(roof)

You can’t see it? It’s because it’s in the walls.

Move it up by setting the position.y property to 2.5 + 0.75:

const roof = new THREE.Mesh(
    // ...
)
roof.position.y = 2.5 + 0.75

Why 2.5 + 0.75 you might ask? 2.5 is the height of the walls and 0.75 is half of the roof’s height because the cone’s origin is at its center:

We now need to rotate it. Again, try to find the axis and the angle.

The axis is y (the vertical axis) and the amount is Math.PI * 0.25 which is 1/8 of a circle:

const roof = new THREE.Mesh(
    // ...
)
roof.position.y = 2.5 + 0.75
roof.rotation.y = Math.PI * 0.25

Door

Although it might sound weird, we are going to use a simple squared plane for our door. Spoiler alert, it’s because we are going to use the beautiful door texture we used in previous lessons.

Create the door Mesh and instantiate a PlaneGeometry and a MeshStandardMaterial as follows, then add it to the house:

// Door
const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial()
)
house.add(door)

You can’t see it? Again, it’s because it’s inside the walls.

Move them on the y and z axes as follows:

const door = new THREE.Mesh(
    // ...
)
door.position.y = 1
door.position.z = 2
house.add(door)

Unfortunately, because the door color is the same as the wall, we can’t see it anymore.

You might think it’s not an issue and we will see the door once we add some textures. Unfortunately, we’ve made a mistake and not being able to see the door prevents us from noticing that mistake.

That’s why we are going to change the color of the door to 'red':

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial({ color: 'red' })
)

Now we can see something, but what the hell is going on?

That glitch is called “z-fighting”. It’s when two faces are mathematically in the exact same spot and the GPU doesn’t know which one is in front of the other.

The easiest solution for this problem is to slightly move the objects away:

door.position.z = 2 + 0.01

Silly, but efficient.

Let’s remove the color: 'red':

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial()
)

Bushes

We are going to put two bushes on each side of the door by using simple spheres.

Now is the perfect opportunity to apply an optimisation. We are going to create 4 bushes using 4 different Meshes, but all of them are going to share the same unique geometry and the same unique material.

Create the following bushGeometry and bushMaterial (singular) using a SphereGeometry and a MeshStandardMaterial:

// Bushes
const bushGeometry = new THREE.SphereGeometry(1, 16, 16)
const bushMaterial = new THREE.MeshStandardMaterial()

Now create the 4 following Meshes. In order to save you some time, I’ll give you their position and scale:

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)

Still nothing on screen? It’s because we haven’t added them to the house. Right now is a great opportunity to show you that you can send multiple objects to add():

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

Don’t they look cute? Well, not for long.

That’s all for the house itself.

Graves 37:25

We want to add a bunch of graves. They need to be all around the house but they should not be part of the walls.

Similar to the bushes, we are going to create only one geometry and one material that we are going to reuse for every grave.

Create a graveGeometry using a BoxGeometry and a graveMaterial using a MeshStandardMaterial:

// Graves
const graveGeometry = new THREE.BoxGeometry(0.6, 0.8, 0.2)
const graveMaterial = new THREE.MeshStandardMaterial()

Before creating the grave Meshes, we are going to create a Group. This is not mandatory, but it’s considered good practice in case you want to move or scale all the graves at once, just like we did with the house.

Create a graves Group and add it to the scene (not the house):

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

We want 30 graves. Let’s start by adding a classic for() loop going from 0 to 30 (not included):

for(let i = 0; i < 30; i++)
{
}

In that for() loop, let’s create the grave using a Mesh, the graveGeometry and the graveMaterial:

for(let i = 0; i < 30; i++)
{
    // Mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)

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

The 30 graves are currently at the exact same spot inside the house.

We now want to position them all around the house. More precisely, we want to place them on a circle with varying radius:

This is where trigonometry comes in handy.

First, we are going to define a random angle between 0 and a full circle.

When using trigonometry, we need to use radians where Math.PI is equal to approximately 3.1415 which is half a circle. Since we want a full circle, we need to multiply Math.PI by 2.

Let’s create an angle variable containing Math.random() multiplied by Math.PI and then multiplied by 2:

for(let i = 0; i < 30; i++)
{
    const angle = Math.random() * Math.PI * 2
    
    // ...
}

In trigonometry, when you send the same angle to sine and cosine, you end up with the x and y coordinates of a circle positioning.

The best way to visualise it is this animation you can find on the Wikipedia Sine and Cosine page:

In JavaScript, for sine and cosine, we can use Math.sin() and Math.cos(). Send them the angle and save the result as x and z:

for(let i = 0; i < 30; i++)
{
    // Coordinates
    const angle = Math.random() * Math.PI * 2
    const x = Math.sin(angle)
    const z = Math.cos(angle)

    // ...
}

Yes, we named the second one z and not y even though I mentioned x and y earlier. x and y are just the generic names for the axes in 2D space, but in our case, we want to move the graves along the x and z axes.

Time to move the grave according to those coordinates using the position.x and position.z:

for(let i = 0; i < 30; i++)
{
    // ...
    
    // Mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)
    grave.position.x = x
    grave.position.z = z

    // ...
}

OK, that’s a start, but the circle is too small.

To fix that, create a radius variable set to 4 and multiply the Math.sin(…) and Math.cos(…) by that radius:

for(let i = 0; i < 30; i++)
{
    // Coordinates
    const angle = Math.random() * Math.PI * 2
    const radius = 4
    const x = Math.sin(angle) * radius
    const z = Math.cos(angle) * radius

    // ...
}

Not bad, but the circle is too regular. Let’s add some randomness by setting the radius to 3 plus a random value using Math.random():

for(let i = 0; i < 30; i++)
{
    const angle = Math.random() * Math.PI * 2
    const radius = 3 + Math.random() * 4
    const x = Math.sin(angle) * radius
    const z = Math.cos(angle) * radius

    // Mesh
    const grave = new THREE.Mesh(graveGeometry, graveMaterial)
    grave.position.x = x
    grave.position.z = z

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

The positioning is great, but the graves are half inside the floor and oriented in the same perfect direction.

Set the position.y to Math.random() * 0.4:

for(let i = 0; i < 30; i++)
{
    // ...
    grave.position.x = x
    grave.position.y = Math.random() * 0.4
    grave.position.z = z

    // ...
}

Now, for the rotation, we want to add some randomness to all three axes, but we want that rotation to be as positive as negative.

Since Math.random() returns a value between 0 and 1, we are going to subtract 0.5 so that it ranges from -0.5 to +0.5 (as positive as negative).

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

    grave.rotation.x = Math.random() - 0.5
    grave.rotation.y = Math.random() - 0.5
    grave.rotation.z = Math.random() - 0.5

    // ...
}

The effect is a little too strong. Let’s reduce it by placing Math.random() - 0.5 in parentheses and multiplying it by 0.4:

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

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

    // ...
}

Texturing 50:40

Right. Our meshes are ready. Time to add some textures.

Finding and adding textures

Setting up textures is a long process. You need to find a good place with nice textures. You need to make sure you are allowed to use them. You need to download and optimise them. You need to apply them to the object with a different approach depending on how textures are mapped. Etc.

Don’t worry, we are going to do that together.

Finding textures is always hard and doing a web search doesn’t always highlight the best places. That’s why I’ve created a Notion page gathering some of my favorite assets libraries: https://www.notion.so/brunosimon/Assets-953f65558015455eb65d38a7a5db7171?pvs=4

In this lesson, we are going to use the textures from Poly Haven. They are realistic, easy to download, you can change what’s being downloaded, they are free and they are under CC0 license (you can do whatever you want with them).

And if you appreciate their work, you can support them through their Patreon.

Poly Haven also provides models and environment maps (which we are going to use later in the course).

I’m going to show you how to set up and download textures from Poly Haven, but if you are in a hurry, you can download the final ones using the Resources button below the video player.

I have to warn you, we are going to make mistakes. Handling the file names, paths, various variables and their settings is quite daunting. Don’t worry, take your time, you’ll get there.

Finally, Poly Haven might have changed since the writing of this lesson. Be aware of potential variations.

Floor

Let’s start with the floor which is actually one of the trickiest. Once you’re done with it, you’ll be able to handle every other part of the house.

Alpha map

Before choosing a texture from Poly Haven, we are going to deal with the edges fade out we were talking about earlier.

If you look into the static/floor/ folder, you’ll find an alpha.jpg file:

This image is a simple radial gradient and we are going to use it to control the alpha of the floor. The white part will be opaque and the black part will be transparent.

In your JavaScript, right before the House section, create a Textures section:

/**
 * Textures
 */

We are going to load all the textures and update them here.

First, we need the TextureLoader as seen in the previous lessons.

Instantiate the TextureLoader and assign it to a textureLoader variable:

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()

Remember that one TextureLoader can handle all the textures of a project.

Next, load the alpha.jpg file using the load() method and save it as floorAlphaTexture:

const textureLoader = new THREE.TextureLoader()

// Floor
const floorAlphaTexture = textureLoader.load('./floor/alpha.jpg')

Remember not to write static/ when loading assets from the static/ folder.

From now on, we are going to end every texture variable with …Texture. This is not mandatory and it makes the variables quite long, but I think it makes more sense and prevents potential conflicts with the rest of the code. Feel free to avoid writing…Texture if you don’t like it.

Send an object to the MeshStandardMaterial of the floor and set the alphaMap property to the floorAlphaTexture:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        alphaMap: floorAlphaTexture
    })
)

Can you guess why it’s not working?

When playing with the alpha, we need to inform Three.js that this material now supports transparency by setting the transparent property to true:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        alphaMap: floorAlphaTexture,
        transparent: true
    })
)

The floor fades out properly.

As for creating such a gradient image, it’s up to you. You can use Photoshop, GIMP, Figma or whatever software that can generate a radial gradient. In this case, I used Figma.

Choosing and downloading textures

Now, onto the fun part. Let’s choose textures, download them and apply them to the floor.

In the following part, we are going to choose very specific textures, but have fun! Choose whatever texture suits you. Download different ones and test them until you are happy.

Go to the Textures section of Poly Haven and look for a nice floor texture.

Personally, I’m going to use Coast Sand Rock 02:

Before hitting the Download button, we need to change some settings, and this is why I love Poly Haven. You can choose exactly what to export.

First, for the resolution, change it from 4K to 1K:

Next to the resolution is a button controlling what is going to be exported. Choose ZIP:

Next, click on the menu icon (if not opened automatically) to control the ZIP contents:

Here, you can choose exactly what to export. Those names should remind you of what we’ve seen in previous lessons:

  • AO (ambient occlusion): Prevents the ambient light being applied to crevices
  • Diffuse: The actual color
  • Displacement: Will move the vertices up and down to create elevations
  • Normal: Will fake the orientation to create details. DX and GL are different ways of orienting the normals and we need to go for GL.
  • Rough: How smooth or rough the material is

Although it’s not the case for Coast Sand Rock 02, you might see more options such as:

  • Bump: Like the normal map, but it’s a grayscale value (we don’t need it)
  • Metal: Defines the metallic parts (we need this one if available)

And you might also have noticed that there is an AO/Rough/Metal option. This one will combine those three maps into one by saving them in the different channels (red, green and blue) which is perfect in our case since having fewer textures is good for performance (more about that later).

Now, for the format, we have three choices:

  • EXR: Large file size with maximum data
  • JPG: Small file size with potential compression artefacts
  • PNG: Medium file size with no compression artefacts

Normally, we would use JPG for most textures and PNG for the Normal because we avoid lossy compression on normal maps to prevent visual artefacts. But since we are going to use grungy textures, those artefacts won’t be visible, which is why we are going to choose JPG for all of them.

Yes, there are other formats. More about that later in the lesson.

Set JPG for the AO/Rough/Metal, Diffuse, Displacement, Normal and uncheck any other option:

Click Download, unzip the file and rename the folder using the initial zip file name, coast_sand_rocks_02_1k in my case.

Changing the name is optional, but I find it helpful when dealing with different versions.

Put the folder in the static/floor/ folder.

Back to the JavaScript, right after the floorAlphaTexture, load the 4 downloaded textures and name the variables as follows:

// Floor
const floorAlphaTexture = textureLoader.load('./floor/alpha.jpg')
const floorColorTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_diff_1k.jpg')
const floorARMTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_arm_1k.jpg')
const floorNormalTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_nor_gl_1k.jpg')
const floorDisplacementTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_disp_1k.jpg')

This is where you might end up making mistakes, especially since the Console won’t necessarily trigger an error if the path is wrong. Still, have a look at the Console and the Network tab from your DevTools to see if everything went well.

We can now apply those textures to the MeshStandardMaterial of the floor. Let’s start with the map using the floorColorTexture:

const floor = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        alphaMap: floorAlphaTexture,
        transparent: true,
        map: floorColorTexture
    })
)

There are multiple issues.

First, the texture is too big. To fix that, we are going to play with the repeat property of the texture, but we need to do that for all 4 textures. repeat is a Vector2 and will control how many times we want the texture to repeat for the same surface. A higher value will result in a smaller repeating pattern.

Since it’s a Vector2, we can use the set() method. Set it to 8 and 8:

floorColorTexture.repeat.set(8, 8)
floorARMTexture.repeat.set(8, 8)
floorNormalTexture.repeat.set(8, 8)
floorDisplacementTexture.repeat.set(8, 8)

Not the expected result and you might notice weird lines on the side of the floor. This is due to the texture not repeating itself. What you see are the last pixels being stretched to the end.

To fix that, we need to inform Three.js that those textures need to be repeated by setting the wrapS and wrapT to THREE.RepeatWrapping:

floorColorTexture.wrapS = THREE.RepeatWrapping
floorARMTexture.wrapS = THREE.RepeatWrapping
floorNormalTexture.wrapS = THREE.RepeatWrapping
floorDisplacementTexture.wrapS = THREE.RepeatWrapping

floorColorTexture.wrapT = THREE.RepeatWrapping
floorARMTexture.wrapT = THREE.RepeatWrapping
floorNormalTexture.wrapT = THREE.RepeatWrapping
floorDisplacementTexture.wrapT = THREE.RepeatWrapping

The next issue is that the color looks oddly gray. This is because of the color space.

Color textures (not data textures like the normal map, AO map, roughness map, etc.) are being encoded in sRGB to optimise how the color is stored according to how color is perceived by human eyes.

The coast_sand_rocks_02_diff_1k.jpg has been encoded properly using the sRGB color space and we need to inform Three.js of this by setting the colorSpace of the texture to THREE.SRGBColorSpace:

floorColorTexture.colorSpace = THREE.SRGBColorSpace

Now let’s add the floorARMTexture. Since this one contains three maps, we can use it on the aoMap, the roughnessMap and the metalnessMap properties:

const floor = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        // ...
        aoMap: floorARMTexture,
        roughnessMap: floorARMTexture,
        metalnessMap: floorARMTexture
    })
)

This one doesn’t make a huge difference in this case, but it helps improve the realism.

Let’s continue and add the floorNormalTexture to the normalMap:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        // ...
        normalMap: floorNormalTexture
    })
)

Now we are talking. But we are not done yet. When downloading the textures, we also chose the Displacement.

Add the floorDisplacementTexture to the displacementMap:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20),
    new THREE.MeshStandardMaterial({
        // ...
        displacementMap: floorDisplacementTexture
    })
)

It looks like the whole texture went up. If you remember from the previous lessons, the displacement map will move the actual vertices. It won’t fake it like the normal map does. We usually use the displacement map to create the rough shape and the normal map to create more granular details.

In other words, we need more vertices on that plane.

Set the widthSegments and heightSegments parameters of the PlaneGeometry to 100:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20, 100, 100),
    // ...
)

It’s better, but it looks quite edgy. You might be tempted to add more subdivisions, but don’t. Too many vertices are bad for performance, especially when using MeshStandardMaterial.

Instead, we are going to lower the effect using the displacementScale property and set it to 0.3:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20, 100, 100),
    new THREE.MeshStandardMaterial({
        // ...
        displacementMap: floorDisplacementTexture,
        displacementScale: 0.3,
    })
)

It’s looking better, but the plane still looks too high. This is due to the texture not being very dark, meaning that all the vertices will go up, at least a little.

To fix that, we can use the displacementBias property to offset the whole displacement.

I could have given you the right value, but now is a good opportunity to use a Debug UI in order to help you find the perfect value.

At the end of the Floor part, add two tweaks to gui as follows:

gui.add(floor.material, 'displacementScale').min(0).max(1).step(0.001).name('floorDisplacementScale')
gui.add(floor.material, 'displacementBias').min(-1).max(1).step(0.001).name('floorDisplacementBias')

We can now easily test different values.

Once you’re happy with the result, take the two values and add them to the displacementScale and displacementBias:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 20, 100, 100),
    new THREE.MeshStandardMaterial({
        // ...
        displacementMap: floorDisplacementTexture,
        displacementScale: 0.3,
        displacementBias: - 0.2
    })
)

We are not going to add tweaks to the Debug UI every time it makes sense, but for your personal projects, it’s considered good practice and it’ll save you time.

Walls

For the walls, good news: it’s a little easier.

First, go on Poly Haven and choose a nice brick texture. Personally, I’ll use the Castle Brick Broken 06.

Poly Haven should have saved your previous settings. Make sure it looks like this and uncheck the Displacement:

Hit Download, unzip the file and rename the unzipped folder as the name of the compressed version.

In static/, create a wall/ folder (singular) and put the textures folder inside (in my case static/wall/castle_brick_broken_06_1k/).

Back to the JavaScript, in the Textures section, after the Floor part, add a Wall part and load the three textures as follows:

// Wall
const wallColorTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_diff_1k.jpg')
const wallARMTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_arm_1k.jpg')
const wallNormalTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_nor_gl_1k.jpg')

Don’t forget to set the colorSpace of wallColorTexture to THREE.SRGBColorSpace:

wallColorTexture.colorSpace = THREE.SRGBColorSpace

Apply all 3 textures to the map, aoMap, roughnessMap, metalnessMap and normalMap properties of the MeshStandardMaterial:

// Walls
const walls = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        map: wallColorTexture,
        aoMap: wallARMTexture,
        roughnessMap: wallARMTexture,
        metalnessMap: wallARMTexture,
        normalMap: wallNormalTexture
    })
)

Look how gorgeous this texture is without having to tweak anything.

If you use another texture, you might need to play with the repeat, wrapS and wrapT as we did for the floor textures.

Roof

The roof texture implementation is similar to the wall.

First, go on Poly Haven and choose a texture. You can go for tiles, bricks, reed, wood planks or whatever. Personally, I’ll use the Roof Slates 02.

For the download settings, same as for the wall:

Hit Download, unzip the file and rename the unzipped folder as the name of the compressed version.

In static/, create a roof/ folder and put the textures folder inside (in my case static/roof/roof_slates_02_1k/).

Back to the JavaScript: in the Textures section, after the Wall part, add a Roof part and load the three textures as follows:

// Roof
const roofColorTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_diff_1k.jpg')
const roofARMTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_arm_1k.jpg')
const roofNormalTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_nor_gl_1k.jpg')

roofColorTexture.colorSpace = THREE.SRGBColorSpace

Apply all 3 textures to the map, aoMap, roughnessMap, metalnessMap and normalMap properties of the MeshStandardMaterial:

const roof = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        map: roofColorTexture,
        aoMap: roofARMTexture,
        roughnessMap: roofARMTexture,
        metalnessMap: roofARMTexture,
        normalMap: roofNormalTexture
    })
)

If you’ve used the same as me, you might want to increase the repetitions on the x axis.

To do that, set the repeat.x of all three textures to 3 and set their wrapS to THREE.RepeatWrapping:

roofColorTexture.repeat.set(3, 1)
roofARMTexture.repeat.set(3, 1)
roofNormalTexture.repeat.set(3, 1)

roofColorTexture.wrapS = THREE.RepeatWrapping
roofARMTexture.wrapS = THREE.RepeatWrapping
roofNormalTexture.wrapS = THREE.RepeatWrapping

Unless the repeat.y gets higher than 1, there’s no need to change the wrapT.

You might have noticed two issues. The first one is that the texture is all skewed. The second one is that the light acts a little bit weird and we see reflections where we are not supposed to.

If you remove the textures, you can notice that more easily:

This is caused by how the ConeGeometry normals and uv attributes are being made and, unfortunately, there is no easy fix, which is why, right now, we will leave it like this.

Still, let me give you some hints on how to fix it.

The first option is to create the model in 3D software. As an example, creating a pyramid in Blender is extremely easy, and setting the UV is quite simple too. Good news, you’ll learn how to use Blender later in the course.

The second option consists of creating the geometry attributes by yourself. This one is actually quite a fun challenge. You could create one triangle for each side and two triangles for the base of the pyramid. Then you would need to set the UV coordinates. And for the normals, good news, you can ask Three.js to calculate them for you with the computeVertexNormals method available on BufferGeometry.

But let’s move on.

Bushes

The bushes’ textures implementation is quite similar. I told you adding textures takes quite some time.

First, go on Poly Haven and choose a texture. Personally, I’ll be using the Leaves Forest Ground.

For the download settings, same as for the wall and roof:

Hit Download, unzip the file and rename the unzipped folder as the name of the compressed version.

In static/, create a bush/ folder (singular) and put the textures folder inside of it (in my case static/bush/leaves_forest_ground_1k/).

Back to the JavaScript: in the Textures section, after the Roof part, add a Bush part and load the three textures with the right colorSpace:

// Bush
const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.jpg')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.jpg')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.jpg')

bushColorTexture.colorSpace = THREE.SRGBColorSpace

Let’s save some time and immediately update the repeat and wrapS so that the texture repeats twice on the x:

bushColorTexture.repeat.set(2, 1)
bushARMTexture.repeat.set(2, 1)
bushNormalTexture.repeat.set(2, 1)

bushColorTexture.wrapS = THREE.RepeatWrapping
bushARMTexture.wrapS = THREE.RepeatWrapping
bushNormalTexture.wrapS = THREE.RepeatWrapping

Apply all 3 textures to the map, aoMap, roughnessMap, metalnessMap and normalMap properties of the bushMaterial:

const bushMaterial = new THREE.MeshStandardMaterial({
    map: bushColorTexture,
    aoMap: bushARMTexture,
    roughnessMap: bushARMTexture,
    metalnessMap: bushARMTexture,
    normalMap: bushNormalTexture
})

If you look closely at the top of the bushes, you might notice a weird stretch of the texture that looks like an a-hole:

Similar to the roof, creating our own geometry in code or using 3D software might help, but there is a quicker solution. Let’s rotate the bushes so that the a-hole is mostly hidden in the wall:

const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush1.rotation.x = - 0.75

const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush2.rotation.x = - 0.75

const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush3.rotation.x = - 0.75

const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
// ...
bush4.rotation.x = - 0.75

Another thing, even though the bushes look good like this, it could be nice to have a more greenish color. Obviously, it depends on the texture you choose, but it’s the perfect opportunity to show you a little trick.

On the MeshStandardMaterial of the bushes, add a color property to '#ccffcc':

const bushMaterial = new THREE.MeshStandardMaterial({
    color: '#ccffcc',
    // ...
})

#ccffcc corresponds to a bright green and that color will tint the texture. You can do that for any of the other materials in the scene.

Graves

You know the drill by now.

Go on Poly Haven and choose a texture. Personally, I’ll be using the Plastered Stone Wall.

For the download settings, repeat what we did earlier:

Hit Download, unzip the file and rename the unzipped folder as the name of the compressed version.

In static/, create a grave/ folder (singular) and put the textures folder inside of it (in my case static/grave/plastered_stone_wall_1k/).

Back to the JavaScript: in the Textures section, after the Bush part, add a Grave part, load the three textures and set the repeat as follows:

// Grave
const graveColorTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_diff_1k.jpg')
const graveARMTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_arm_1k.jpg')
const graveNormalTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_nor_gl_1k.jpg')

graveColorTexture.colorSpace = THREE.SRGBColorSpace

graveColorTexture.repeat.set(0.3, 0.4)
graveARMTexture.repeat.set(0.3, 0.4)
graveNormalTexture.repeat.set(0.3, 0.4)

No need to change the wrapS and wrapT when the repeat values are less than 1.

Apply all 3 textures to the map, aoMap, roughnessMap, metalnessMap and normalMap properties of the MeshStandardMaterial:

const graveMaterial = new THREE.MeshStandardMaterial({
    map: graveColorTexture,
    aoMap: graveARMTexture,
    roughnessMap: graveARMTexture,
    metalnessMap: graveARMTexture,
    normalMap: graveNormalTexture
})

The texture repeats a little bit too much on the sides, but there isn’t much we can do about it without creating our own geometry.

Door

We’re almost there. All we need to do now is handle the door.

The textures are already available in the static/door/ folder. This set of textures has been made by Katsukagi and can be downloaded on this website https://3dtextures.me/2019/04/16/door-wood-001/ under CC0 license.

In the Textures section, after the Graves part, add a Door part and load all textures as follows:

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

doorColorTexture.colorSpace = THREE.SRGBColorSpace

This time, the ambient occlusion, metalness and roughness textures are separated. There’s also an alpha texture.

Apply them to the MeshStandardMaterial, don’t forget the alphaMap and set the transparent to true:

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2),
    new THREE.MeshStandardMaterial({
        map: doorColorTexture,
        transparent: true,
        alphaMap: doorAlphaTexture,
        aoMap: doorAmbientOcclusionTexture,
        displacementMap: doorHeightTexture,
        normalMap: doorNormalTexture,
        metalnessMap: doorMetalnessTexture,
        roughnessMap: doorRoughnessTexture
    })
)

Unfortunately, the door is all flat, even though we added a displacementMap:

You’ve probably guessed why. We don’t have enough vertices on the PlaneGeometry.

Set the widthSegments and heightSegments parameters of the PlaneGeometry to 100:

const door = new THREE.Mesh(
    new THREE.PlaneGeometry(2.2, 2.2, 100, 100),
    // ...
)

It’s working, but the displacement is way too strong.

Now would be a good opportunity to add tweaks to the displacementScale and the displacementBias, but let’s save some time and use the following values:

const door = new THREE.Mesh(
    // ...
    new THREE.MeshStandardMaterial({
        // ...
        displacementScale: 0.15,
        displacementBias: -0.04,
        // ...
    })
)

Using displacement for a door is overkill, but it’s a nice-looking door and it’s an opportunity to have fun with the displacement feature.

Lights 01:55:30

Thanks to those textures, our scene looks more detailed and realistic. It’s time to tweak the staging to make it more immersive.

Ambient light and directional light

Currently, the AmbientLight and DirectionalLight are white, which isn’t very realistic unless we are in a laboratory.

Change both colors to #86cdff, set the AmbientLight intensity to 0.275 and the DirectionalLight intensity to 1:

const ambientLight = new THREE.AmbientLight('#86cdff', 0.275)
// ...

const directionalLight = new THREE.DirectionalLight('#86cdff', 1)
// ...

The scene looks quite dark, but don’t worry, we’re going to fix that.

Having a dimmed AmbientLight allows the user to enjoy surfaces in the shade and mimics the light’s bounce of DirectionalLight.

Although we won’t do it, feel free to add some tweaks to control the color and the intensity of the lights.

Door light

Having the DirectionalLight behind the house like this puts the front part (the most important side of the house) in the shade. Let’s fix that by adding a door light, slightly above the door.

In real life, a door light would very likely be a light bulb, which is why we are going to use a PointLight.

Create a doorLight variable using a PointLight, set the color to '#ff7d46', the intensity to 5 and position it slightly away from the wall, above the door. Finally, add it to the house (not the scene):

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

Now the front side of the house grabs everyone’s attention. The warm color of the light makes it look like a comfortable place, which nicely contrasts with the coldness of the surroundings.

Ghosts 01:59:25

Maybe you imagined ghosts in animated sheets waving their arms around like in cartoons. Unfortunately, at this stage of the course, that would be difficult and we don’t even know how to import a model.

Let’s stick to a more minimalist approach and represent the ghosts by using PointLights.

We are going to create three PointLights with different colors and they will circle the house while moving in and out the floor at various frequencies.

After the House section, add a Ghosts section and create three PointLights with the following colors and intensities:

/**
 * Ghosts
 */
const ghost1 = new THREE.PointLight('#8800ff', 6)
const ghost2 = new THREE.PointLight('#ff0088', 6)
const ghost3 = new THREE.PointLight('#ff0000', 6)
scene.add(ghost1, ghost2, ghost3)

Currently, all ghosts are inside the house.

For the animation, we are going to focus on the ghost1 and, later, handle the other two.

We want the ghost to rotate around the house in a circular pattern and we have done this quite a few times already. We need trigonometry.

And since we want the ghost position to change on each frame, we’re going to do it in the tick function.

As mentioned earlier in trigonometry, when you send the same angle to sine and cosine, you end up with the x and y coordinates of a circle positioning:

For the angle, we can use the elapsedTime which is retrieved from the Timer instance and corresponds to how many seconds have passed since the start of the experience.

In the tick function, create a Ghosts section, create a ghost1Angle variable and assign the elapsedTimeto it:

const tick = () =>
{
    // Timer
    timer.update()
    const elapsedTime = timer.getElapsed()

    // Ghosts
    const ghost1Angle = elapsedTime

    // ...
}

We can now use the ghost1Angle as the parameter of Math.cos() and Math.sin() on the position.x and position.z variables of the ghost1:

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

    // Ghosts
    const ghost1Angle = elapsedTime
    ghost1.position.x = Math.cos(ghost1Angle)
    ghost1.position.z = Math.sin(ghost1Angle)

    // ...
}

It’s working, but when using cosine and sine, you get a position on a circle of radius 1. To fix that, multiply both by 4:

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

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

    // ...
}

Multiply elapsedTime by 0.5 to slow it down:

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

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

    // ...
}

We now want the ghost to go up and down through the floor. Using a sine for this is perfect:

But the pattern is too regular.

One easy solution is to combine multiple sines with different frequencies.

You can test that easily and find frequencies that look unpredictable using mathematic formulas and visualisation tools such as the Desmos Calculator.

One sine:

Two sines:

Three sines:

Right, now we can write this formula in JavaScript and send it to the position.y of the ghost1:

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

    // 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(ghost1Angle) * Math.sin(ghost1Angle * 2.34) * Math.sin(ghost1Angle * 3.45)

    // ...
}

Now let’s try to do the same with ghost2.

Duplicate the 4 lines of code and change ghost1 to ghost2:

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

    // Ghosts
    // ...
    
    const ghost2Angle = elapsedTime * 0.5
    ghost2.position.x = Math.cos(ghost2Angle) * 4
    ghost2.position.z = Math.sin(ghost2Angle) * 4
    ghost2.position.y = Math.sin(ghost2Angle) * Math.sin(ghost2Angle * 2.34) * Math.sin(ghost2Angle * 3.45)

    // ...
}

They are at the exact same place all the time.

Change the radius to 5:

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

    const ghost2Angle = elapsedTime * 0.5
    ghost2.position.x = Math.cos(ghost2Angle) * 5
    ghost2.position.z = Math.sin(ghost2Angle) * 5
    ghost2.position.y = Math.sin(ghost2Angle) * Math.sin(ghost2Angle * 2.34) * Math.sin(ghost2Angle * 3.45)

    // ...
}

Invert the direction by adding a - in front of the elapsedTime:

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

    const ghost2Angle = - elapsedTime * 0.5

    // ...
}

Change the frequency of the whole animation by multiplying elapsedTime by 0.38 instead of 0.5:

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

    const ghost2Angle = - elapsedTime * 0.38

    // ...
}

Finally, our final ghost3. Now would be a good opportunity to try and complete the process on your own.

Set the frequency multiplier to 0.23, don’t invert it with a - and set the radius to 6:

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

    const ghost3Angle = elapsedTime * 0.23
    ghost3.position.x = Math.cos(ghost3Angle) * 6
    ghost3.position.z = Math.sin(ghost3Angle) * 6
    ghost3.position.y = Math.sin(ghost3Angle) * Math.sin(ghost3Angle * 2.34) * Math.sin(ghost3Angle * 3.45)

    // ...
}

Shadows 02:09:07

To add even more realism, we are going to activate shadows.

Having multiple lights casting shadows doesn’t always look good as we’ve seen in the Shadows lesson, but since this scene is quite complex, we can cast shadows from the ghosts and the directional light while still achieving a good result.

In our code, we are going to set up the shadows in the same place instead of spreading them all around.

After the Renderer section, create a Shadows section:

/**
 * Shadows
 */

Renderer

In there, start by activating the shadow map on the renderer:

// Renderer
renderer.shadowMap.enabled = true

Let’s also change the type to THREE.PCFSoftShadowMap for a more realistic result:

renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

Cast and receive shadows

Next, activate the shadows on the directionalLight and the three ghosts by setting their castShadow to true:

// Cast and receive
directionalLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true

The door light doesn’t make much of a difference, so let’s not cast a shadow from it to improve performance.

We now need to go through each object and decide if it needs to cast shadows and/or receive shadows (ignore the graves for now):

walls.castShadow = true
walls.receiveShadow = true
roof.castShadow = true
floor.receiveShadow = true

We can already enjoy the shadow of the house.

The graves are a little bit trickier because they have been created inside a for() loop, meaning that we can’t access their variables from outside of that loop.

One solution would be to add the castShadow and receiveShadow directly in the for() loop and that would have worked, but we want all shadow-related code to be in the same place.

Thankfully, we put all the graves in a graves Group and that group is available from outside the for() loop.

We can loop through the graves.children property using a for(… of …) and update each child:

for(const grave of graves.children)
{
    grave.castShadow = true
    grave.receiveShadow = true
}

There are other ways to loop on such an array, but this method works just fine.

Mapping

The scene looks much better with these shadows, but let’s not forget to optimize them and make sure the shadow maps fit the scene nicely.

A good thing would be to go through each light, create a camera helper on the light.shadowMap.camera, and then tweak the camera until it’s perfect. But that would be boring, which is why I already did that so you can use the following values.

First, for the directionalLight:

  • Reduce the mapSize to 256 to get blurred shadows and better performance;
  • Set the top, right, bottom and left to 8 and -8 so that the shadows aren’t cut off on the sides;
  • Set the near to 1 and the far to 20 to fit the scene in terms of depth.
// Mappings
directionalLight.shadow.mapSize.width = 256
directionalLight.shadow.mapSize.height = 256
directionalLight.shadow.camera.top = 8
directionalLight.shadow.camera.right = 8
directionalLight.shadow.camera.bottom = - 8
directionalLight.shadow.camera.left = - 8
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 20

When the scene is well-textured with a lot of details, a low resolution on the shadow map will make it look more spread out without visible artefacts. In turn, this is good for performance and adds to the visual appeal of our scene.

For the ghost lights, we can lower the mapSize to 256 and set the far to 10:

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

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

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

Sky 02:22:18

The scene itself looks awesome, but the black background is lame. Let’s go crazy and implement the Sky class that you can see working its magic here: https://threejs.org/examples/?q=sky#webgl_shaders_sky

It’s a realistic sky with a lot of parameters. Since we will be exploring a more in-depth implementation of the Sky in a later lesson, and also because this lesson is getting quite long, we are going to implement it right away without any tweaks. Yet, if you feel like digging into this class, you can have a look at the code of this example: https://github.com/mrdoob/three.js/blob/master/examples/webgl_shaders_sky.html

First, import Sky from three/addons/objects/Sky.js:

import { Sky } from 'three/addons/objects/Sky.js'

After the Shadow section, instantiate Sky, assign it to a sky variable and add it to the scene:

/**
 * Sky
 */
const sky = new Sky()
scene.add(sky)

If you test the result right now, you won’t see anything.

First, we need to add some parameters to this Sky instance, and the way to do it is quite weird. We need to update some uniforms on the material of sky as follows (don’t forget the .value):

sky.material.uniforms['turbidity'].value = 10
sky.material.uniforms['rayleigh'].value = 3
sky.material.uniforms['mieCoefficient'].value = 0.1
sky.material.uniforms['mieDirectionalG'].value = 0.95
sky.material.uniforms['sunPosition'].value.set(0.3, -0.038, -0.95)

What a weird way to update a material, right?

This is how we update the parameters of a custom shader in Three.js. Don’t worry, you’ll learn everything you need to in the Shaders chapter.

All those values have been chosen to look nice, but normally, you would add some tweaks in order to find the perfect settings.

But still, where is the sky?

It’s there, but it’s too small:

It looks like a small cube.

Since Sky inherits from Mesh, we can update its scale property:

const sky = new Sky()
sky.scale.set(100, 100, 100)
scene.add(sky)

Fog 02:28:01

In horror movies, they always use fog to make the scene more oppressive. You don’t want the survivors to see the exit too soon. The good news is that Three.js supports that feature with Fog and FogExp2.

The difference between the two is how the density is calculated. Let’s start with the Fog.

After the Sky section, add a Fog section, instantiate the Fog as follows and assign it to fog.scene:

/**
 * Fog
 */
scene.fog = new THREE.Fog('#ff0000', 1, 13)

The first parameter is the color and we insert a full red which enables us to notice where the fog is. We will replace it in a minute.

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

Fog is useful if you want perfect control over where the fog starts and ends.

But there is also the FogExp2.

Comment the previous Fog and try the FogExp2 with the following parameters:

// scene.fog = new THREE.Fog('#ff0000', 1, 13)
scene.fog = new THREE.FogExp2('#ff0000', 0.1)

This one is more straightforward: the further away from the camera, the higher the density.

The first parameter is the color, the second parameter is the density and this is actually a more realistic approach to the fog.

We are going to stick with this one, but what about the color? The best thing you could do is to choose a color that merges well with the background (the sky in that case).

Use a color picker to get the color of the bottom part of the sky and apply it to the fog:

// scene.fog = new THREE.Fog('#04343f', 1, 13)
scene.fog = new THREE.FogExp2('#04343f', 0.1)

Textures optimisation 02:32:04

And we are done… but wait! We forgot something quite important. We downloaded and used realistic textures, but have you checked how much they weigh?

The images weigh in at about 13MB and that’s way too high for such a simple scene.

The textures are too big and too heavy, which is not just an issue for the loading. It’s also an issue for the GPU. The bigger the textures, the more memory they’ll occupy. It’s bad for performance, but it also generates a small freeze when the textures are being uploaded to the GPU.

We are going to make the textures as small as possible, but also compress them. Currently, we have a bunch of JPG files, which is already a good start. But there’s an even better format now and it’s called WEBP.

WEBP is now supported by all major browsers and only old versions of Safari and Safari iOS don’t support it: https://caniuse.com/webp

WEBP supports transparency.

WEBP applies a lossy compression, meaning that the image will degrade. The goal is to find the right spot between compression and file size, which usually gravitates around 80%.

Even though we won’t use it, there is also a lossless version.

Remember that, you might not want to apply a lossy compression for textures that are being used as data, like the normal map. But in the case of such a grungy scene, you can do it safely, even for the normal map. Otherwise, you might want to stick to a lossless format such as PNG or even the lossless version of WEBP (because yes there’s one too).

As for the tools to convert our images, there are plenty around and you probably already have your favorite.

Here’s a little list of popular online solutions:

  • Squoosh: One file at a time, a lot of options, live preview
  • CloudConvert: Multiple files at a time, 25 images daily with a user account, some options
  • TinyPNG: Multiple files at a time, limited but you can just wait for a while, no option

And there are many local solutions, NPM libraries, extensions, etc.:

In this lesson, we’re going to use CloudConvert, but feel free to test the other solutions.

Again, if you are in a hurry, you can find the WEBP versions in the resources file using the Resources button below the video player.

CloudConvert is limited to a few conversions a day, but if you create an account, you can go up to 25 compressions a day. Don’t forget that you are sending images to a server. Try not to send sensitive data there.

Open the compression page: https://cloudconvert.com/jpg-to-webp

Send bushes texture files to it:

Click on the wrench icon, set the Width and Height to 256, and the Quality to 80:

Before hitting Okay, press the little arrow on the right of the button and choose Apply options to all open files:

Hit the Convert button and wait for the compression to finish:

You can now download the files and put them in the same folder as the JPGs. I recommend you keep the original files until you are sure you are done. You might even want to save the originals somewhere else, just in case.

In the JavaScript, where you load the bush textures, replace .jpg with .webp:

const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.webp')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.webp')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.webp')

Note that you might have a different path if you used a different texture.

The texture isn’t as sharp as before, but it’s a lot smaller.

Let’s repeat the process for every texture:

  • door/: size 1024, quality to 80
  • floor/: size 512, quality 80 (and compress the alpha.jpg the same way)
  • grave/: set 512, quality 80
  • roof/: set 512, quality 80
  • wall/: set 1024, quality 80

Once you’re done, have a look at the texture size:

We are down to a mere 1.6MB, which is about 8 times smaller. Your users and their GPU will thank you.

In an ideal scenario, we would have downloaded the textures from Poly Haven as PNG so that we have the originals without any lossy compression applied. Then we would have converted those to WEBP. Don’t worry, the JPGs were quite high quality anyway.

WEBP isn’t the only solution and there are many other formats. Basis is one of them. It’s a GPU-friendly texture format, but it’s quite complex to implement and it comes with downsides, which is why we are not going to talk about it here.

Going further 02:48:35

And we are done with this lesson. As you can see, we can do a lot with what Three.js provides out of the box. Yes, good textures help a lot. Obviously, it would have been even better if we had implemented custom models, but that’s for a later lesson.

Still, there is room for improvement if you feel like continuing the project:

  • Add more tweaks for things like the lights, the colors, the textures repetitions, the ghost speed, etc.
  • Add tweaks to the Sky as seen in the example (the code)
  • Stray away from the cube house and add more shapes to it like a garage, a tool shed, toilets in the back or whatever
  • Add more objects to the scene (fences, pathways, rocks, wood signs, windows, etc.)
  • Once you’re done learning how to import models, you can get back to this lesson
  • Explore the Basis format
  • Add sounds
  • Make the door light flicker
  • Etc.

How to use it 🤔

  • Download the Starter pack or Final project
  • Unzip it
  • Open your terminal and go to the unzip folder
  • Run npm install to install dependencies
    (if your terminal warns you about vulnerabilities, ignore it)
  • Run npm run dev to launch the local server
    (project should open on your default browser automatically)
  • Start coding
  • The JS is located in src/script.js
  • The HTML is located in src/index.html
  • The CSS is located in src/style.css

If you get stuck and need help, join the members-only Discord server:

Discord