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

⚠️ Update

Although we are now using Vite.js instead of Webpack the configuration and behaviour are the same and you can follow the lesson.

⚠️ Update

Since the version 0.152 of Three.js encoding has been replaced by colorSpace:

this.environmentMap.texture.colorSpace = THREE.SRGBColorSpace

But the idea stays the same.

⚠️ Update

Since the version 0.152 of Three.js encoding has been replaced by colorSpace:

this.textures.color.colorSpace = THREE.SRGBColorSpace

But the idea stays the same.

Unlock content 🔓

To get access to 91 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? 👍

32%

That's the end of the free part 😔

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

Next lesson
26.

Code structuring for bigger projects

Difficulty Very hard

Introduction 00:00

To this point, all the exercises have been small enough to fit into a single JavaScript file without struggling too much. We've separated the different parts of our code by using block comments, and we've scrolled a lot.

But real-life projects tend to have much more code. Having everything as tangled as spaghetti in one file soon becomes an issue. For example:

  • It's hard to find what you want.
  • It's hard to re-use specific parts.
  • You need to make sure that variables are not in conflict with other variables.
  • If you work with other developers, you'll have a lot of conflicts on that file.
  • You'll start to have cramps in your fingers because you have to scroll so much.
  • Etc.

We need a way to structure our code in a more maintainable way.

Though this course is dedicated to Three.js, we are going to learn JavaScript concepts like classes and modules for this lesson. They will become very handy when it comes to organizing our code.

In the following parts of the lesson, I will show you how I (Bruno Simon) like to organize my code. This is based on very personal preferences that you might not agree with, and you might be right. Don't hesitate to create your own structure and only take what you think is good from the following advice.

In the rest of the course, for simplicity's sake and in case some of you are not comfortable with what you will learn here, we won't be using the structure presented in this lesson. We will keep coding all the JavaScript in one file (spaghetti style). But if you feel comfortable trying the new structure in this lesson, you can adapt the following lessons to the new way or to your own structure.

Modules 02:32

Modules are one of the most important features used to structure our code. The idea is to separate our code into multiple files and import those files when we need them.

We actually already use modules when we import dependencies like this:

import * as THREE from 'three'
import gsap from 'gsap'

But this time, we are going to import our own code.

Compatibility

When using Vite, all our imports are merged into one file that will work on most browsers. This way, we don't even have to worry about the compatibility of modules.

Furthermore, modules are now natively supported by most modern browsers without using a bundler. But, we aren't going to use that native support for a couple of reasons:

  • Not all browsers are compatible with it (Can I Use).
  • We already need Vite for other reasons like NPM dependencies, shader files integration (later in the course), local server, etc. And modules are natively supported by Vite.

Syntax

We are going to ignore the current state of our project for a moment to focus on syntax.

In /src/script.js, comment out everything (even the CSS import).

In the /src/ folder, create a test.js file. We are going to add content to that file and import it into script.js.

A file can export one or multiple things, but, to keep things simple, I like to export only one thing per file.

To do that, write the following code in test.js :

export default 'Hello modules'

And then, to import this code into /src/script.js, write the following code:

import test from './test.js'

console.log(test)

And that's it. Check your console and you should see Hello modules.

One very important detail is that the path starts with ./. When we refer to a file, we need to do it that way, otherwise the build tool will try to find it in the node_modules folder.

Here, we exported a string, which is not very useful. But we can export functions:

// test.js
export default () =>
{
    console.log('Hello modules')
}

// scripts.js
import test from './test.js'

test()

We can also export objects:

// test.js
export default {
    hello: 'modules'
}

// scripts.js
import test from './test.js'

console.log(test)

And we can also export classes, but we are going to see that later.

The export instruction can also be done after the object:

// test.js
const somethingToExport = {
    hello: 'modules'
}

export default somethingToExport

And as I said earlier, one file can export multiple things:

// test.js
const oneThing = {
    hello: 'modules'
}

const anotherThing = () =>
{
    console.log('Hi!')
}

export { oneThing, anotherThing }

// scripts.js
import { oneThing, anotherThing } from './test.js'

console.log(oneThing)
anotherThing()

By exporting multiple things, we don't need to import everything in the module. We can select what we want:

// script.js
import { oneThing } from './test.js'

console.log(oneThing)

And this is actually how Three.js classes can be imported without importing the whole library.

Currently, when we import Three.js, we do:

import * as THREE from 'three'

And everything that is being exported from three will be available in the THREE variable. But we could have imported specific classes like this:

import { SphereGeometry } from 'three'

But again, we are not going to use that feature and each one of our files is going to export only one thing.

Classes 14:15

Now that we know how to separate our code into multiple files, what should we export and import exactly?

We are going to use classes for exporting and importing. Classes allow us to use Object-Oriented Programming in JavaScript.

Compatibility

Classes are supported by most browsers (Can I Use), so we can use them without trouble.

Creating a class

Remove the code related to modules (even the test.js file) and leave the rest of the code commented. We are going to focus on the classes now.

To create a class, use the following syntax in /src/script.js:

class HelloClass {}

If you test this code now, nothing will happen because our class is currently empty and we haven't used it anywhere else in our code. But, in the classes in the example below, we are going to put some code between the {}.

Also, you should note that class names, by convention, usually use the PascalCase where the first letter of each word is in uppercase.

Instantiating the class

A class is like a blueprint. We can use that blueprint to create an object. We can also use that blueprint to create multiple objects.

Let's imagine that our class is a blueprint to create a robot:

class Robot {}

To create a robot from that blueprint, we would need to write this:

const robot = new Robot()
console.log(robot)

And that's it. Though this class does nothing and thus our robot variable does nothing, we created a robot out of our Robot class.

The robot variable is what we call an instance of the class.

As already noted, we can also create multiple instances of that class:

class Robot {}

const wallE = new Robot()
const ultron = new Robot()
const astroBoy = new Robot()

All of those robots will be based on the same class.

Methods

That's cute, but our robots can't do anything. We can add functions to our robots like this:

class Robot
{
    sayHi()
    {
        console.log('Hello!')
    }
}

Functions inside of a class are called methods and every instance of the class will have these methods.

Now, each one of our robots can say "hi".

ultron.sayHi()
astroBoy.sayHi()

But how impolite it is for those robots to not say "thank you" to their creator for being created!

If you add a method named constructor to the class, this method will be called automatically when instantiated:

class Robot
{
    constructor()
    {
        console.log('Thank you creator')
    }

    sayHi()
    {
        console.log('Hello!')
    }
}

Now, every robot being created with this class will say "Thank you creator" automatically when instantiated.

We can also provide parameters to that constructor.

To illustrate that, we are going to give a name to each one of the robots:

const wallE = new Robot('Wall-E')
const ultron = new Robot('Ultron')
const astroBoy = new Robot('Astro Boy')

And to retrieve those names in the class, we can add the parameter to the constructor function:

class Robot
{
    constructor(name)
    {
        console.log(`I am ${name}. Thank you creator`)
    }

    sayHi()
    {
        console.log('Hello!')
    }
}

Context

But what if we also want the robot to provide his name when saying "hi"?

We already sent the name to the constructor and we don't want to send it again to the sayHi function. What we want is for the robot to remember his name.

We can do that with this. Write the following code:

class Robot
{
    constructor(name)
    {
        this.name = name

        console.log(`I am ${this.name}. Thank you creator`)
    }

    sayHi()
    {
        console.log('Hello!')
    }
}

this is what we call the context. Though the class is the same for each instance, the context will be different for every instances of the class.

In our case, this will be the robot itself.

this is accessible in every method which is why we can now retrieve the name of the robot in the sayHi function:

class Robot
{
    // ...

    sayHi()
    {
        console.log(`Hello! My name is ${this.name}`)
    }
}

name is what we call a property of the class. We can add more properties:

class Robot
{
    constructor(name, legs)
    {
        this.name = name
        this.legs = legs

        console.log(`I am ${this.name}. Thank you creator`)
    }

    // ...
}

const wallE = new Robot('Wall-E', 0)
const ultron = new Robot('Ultron', 2)
const astroBoy = new Robot('Astro Boy', 2)

We can also access properties from the instance outside of the class:

// ...

const wallE = new Robot('Wall-E', 0)
const ultron = new Robot('Ultron', 2)
const astroBoy = new Robot('Astro Boy', 2)

console.log(wallE.legs)

Methods are also available from the context and we can ask the robot to say "hi" automatically in the constructor:

class Robot
{
    constructor(name, legs)
    {
        this.name = name
        this.legs = legs

        this.sayHi()

        console.log(`I am ${this.name}. Thank you creator`)
    }

    // ...
}

Inheritance

Inheritance is like creating a class based on another class. In a way, we create a blueprint based on another blueprint.

All the methods of the base class will be available in the new class.

To illustrate that, let's add a feature to our robots so that they can fly. But not every robot can fly like Wall-E. Still, every robot needs a name and legs.

To create a class based on another, use the extends keyword. Create the following class after the Robot class:

class FlyingRobot extends Robot
{
    
}

We have created a FlyingRobot class which we can now use for robots that can fly:

const wallE = new Robot('Wall-E', 0)
const ultron = new FlyingRobot('Ultron', 2)
const astroBoy = new FlyingRobot('Astro Boy', 2)

Currently, this FlyingRobot doesn't add anything to the Robot class, but we can add methods like this:

class FlyingRobot extends Robot
{
    takeOff()
    {
        console.log(`Have a good flight ${this.name}`)
    }

    land()
    {
        console.log(`Welcome back ${this.name}`)
    }
}

Robots instantiated with FlyingRobot will still be able to say "hi", but now they will also be able to take off and land:

astroBoy.sayHi()
astroBoy.takeOff()
astroBoy.land()

But if we try to do the same with Wall-E:

wallE.takeOff()

We get an error. Wall-E isn't an instance of FlyingRobot and thus can't take off.

Providing a method with the same name to the FlyingRobot class will override what the method does in the Robot class:

class FlyingRobot extends Robot
{
    sayHi()
    {
        console.log(`Hello! My name is ${this.name} and I am a flying robot`)
    }

    // ...
}

But if you want to provide a different constructor, you have to start the method with super() and send the needed parameters to it:

class FlyingRobot extends Robot
{
    constructor(name, legs)
    {
        super(name, legs)

        this.canFly = true
    }

    // ...
}

super corresponds to the base class (Robot) and using super() is like calling the base constructor so that everything we do in the base constructor will be done in the new class, too.

We can also use super to call methods from the base class. As an example, we can make the robot say "hi" like it use to and then, in another log, say that it is a flying robot:

class FlyingRobot extends Robot
{
    sayHi()
    {
        super.sayHi()

        console.log('I am a flying robot')
    }

    // ...
}

But this approach tends to complicate the code.

That's it for classes.

Combining the classes and the modules 37:45

The idea here is that we are going to separate our code into files and each one of these files will export a different class.

To illustrate that with the robots, create a /src/Robot.js file and put the Robot class in it, but with an export default at the beginning:

export default class Robot
{
    constructor(name, legs)
    {
        this.name = name
        this.legs = legs

        console.log(`I am ${this.name}. Thank you creator`)

        this.sayHi()
    }

    sayHi()
    {
        console.log(`Hello! My name is ${this.name}`)
    }
}

Now create a /src/FlyingRobot.js file and put the FlyingRobot class in it, but with an export default at the beginning:

export default class FlyingRobot extends Robot
{
    constructor(name, legs)
    {
        super(name, legs)

        this.canFly = true
    }

    sayHi()
    {
        console.log(`Hello! My name is ${this.name} and I'm a flying robot`)
    }

    takeOff()
    {
        console.log(`Have a good flight ${this.name}`)
    }

    land()
    {
        console.log(`Welcome back ${this.name}`)
    }
}

Before importing them, however, we need to fix an issue.

FlyingRobot inherits from Robot, but Robot isn't available in the file. We need to first import that class to refer to it.

Add the following import:

import Robot from './Robot.js'

export default class FlyingRobot extends Robot
{
    // ...

In /src/scripts.js, we can now import and use these classes:

import Robot from './Robot.js'
import FlyingRobot from './FlyingRobot.js'

const wallE = new Robot('Wall-E', 0)
const ultron = new FlyingRobot('Ultron', 2)
const astroBoy = new FlyingRobot('Astro Boy', 2)

And our code to create robots becomes suddenly very simple.

At first, all of this might seem a bit complicated, but your code will become much more maintainable and you'll be able to reuse it in different projects simply by copying the classes you need.

Structuring our project 42:14

It's time to use this new knowledge in our project.

Remove the code that we have added above and delete the files related to the robots.

If you uncomment the project code, you'll see a simple scene with a fox on a floor.

We are going to keep the existing code and move it into classes piece by piece.

Comment the whole code again.

Experience class 43:44

A good practice is to put the whole experience inside a main class that will then create everything else. This is particularly useful if your WebGL experience is part of a bigger website with HTML content, other pages, etc.

The code related to your experience will be separate from the rest of your code, but still accessible through the class and all the methods and properties you provide within that class.

As for the name of that class, I like to use Experience but it can be MySuperGame, WebGLAwesomeStuff, Application or whatever.

Want to learn more?

That's the end of the free part 😔

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

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