DEV Community

Cover image for Code Structuring for Creative Coding
Dani Passos
Dani Passos

Posted on

Code Structuring for Creative Coding

When you're starting out with three.js, your projects might be simple enough to fit into a single JavaScript file. But as you take on more complex projects, you'll quickly realize that having everything tangled up like spaghetti in one file is a recipe for disaster.

Sure, you can use block comments to separate different parts of your code, but let's face it - you'll still need to scroll through a lot of code to find what you're looking for. And that's just the tip of the iceberg 🤯

Think about it: it's hard to find what you want when everything's jumbled together. It's even harder to reuse specific parts of your code, and you constantly have to worry about variable conflicts. And if you're working with other developers, forget about it - you'll be dealing with conflicts left and right.

Plus, let's not forget the physical toll it takes on you. Your fingers will start to cramp up from all that scrolling. And who wants that? 👀

We need to structure our code in a more maintainable way. Don't worry, it's not as daunting as it sounds. By breaking your code up into smaller, more manageable pieces, you'll make it easier to navigate and maintain in the long run.

Trust me, your fingers (and your fellow developer friends) will thank you for it.

Modules

When it comes to organizing our code, modules are a game-changer. The basic concept is to divide our code into multiple files, so that we can import and use only what we need, when we need it.

In fact, if you've ever imported dependencies into your code, you're already familiar with how modules work. It's a simple yet powerful way to keep your codebase neat and tidy, and avoid cluttering your files with code that you don't need right away.

By breaking down the code down into smaller, more focused chunks, we'll be able to make our code easier to read, maintain, and scale as the project grows.

import * as THREE from 'three'
import gsap from 'gsap'
Enter fullscreen mode Exit fullscreen mode

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 cool.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 cool.js :

export default 'Hello developer friend!'
Enter fullscreen mode Exit fullscreen mode

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

import test from './cool.js'

console.log(test)
Enter fullscreen mode Exit fullscreen mode

And that's it. Check your console and you should see Hello developer friend!.

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:

// cool.js
export default () =>
{
    console.log('Hello developer friends')
}

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

test()
Enter fullscreen mode Exit fullscreen mode

We can also export objects:

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

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

console.log(test)
Enter fullscreen mode Exit fullscreen mode

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

And as mentioned 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 './cool.js'

console.log(oneThing)
anotherThing()
Enter fullscreen mode Exit fullscreen mode

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 './cool.js'

console.log(oneThing)
Enter fullscreen mode Exit fullscreen mode

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

And everything that is being exported from three will be available in the THREE variable. But we can choose to import specific classes like this:

import { SphereGeometry } from 'three'
Enter fullscreen mode Exit fullscreen mode

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

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
{

}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

wallE.takeOff()
Enter fullscreen mode Exit fullscreen mode

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`)
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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')
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Note: this approach tends to complicate the code, don't overuse it.

Combining the classes and the modules

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

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

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
{
    // ...
Enter fullscreen mode Exit fullscreen mode

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

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 a Creative Project

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.

Create and instantiate the class

In the /src/ folder, create an Experience/ folder and, in that /src/Experience folder, create an Experience.js file.

In that file, export a class as follows:

export default class Experience
{
    constructor()
    {
        console.log('Here starts a great creative experience')
    }
}
Enter fullscreen mode Exit fullscreen mode

All classes related to the experience will be in that folder.

In /src/script.js, we can import and instantiate that class:

import Experience from './Experience/Experience.js'

const experience = new Experience()
Enter fullscreen mode Exit fullscreen mode

Canvas

Covering the canvas as our example for this article, is good to know that when instantiating the Experience, we are going to send the canvas as a parameter so that other developers using our class in different situations will be able to choose what <canvas> they want to use.

When instantiating in /src/script.js, use querySelector() to send the canvas parameter:

const experience = new Experience(document.querySelector('canvas.webgl'))
Enter fullscreen mode Exit fullscreen mode

And, in the class, save it as a property:

export default class Experience
{
    constructor(canvas)
    {
        // ...

        // Options
        this.canvas = canvas
    }
}
Enter fullscreen mode Exit fullscreen mode

Sizes

The first and super useful class we will cover in this article is the one that will handle the sizes of the experience. It'll include the width and the height of the viewport as well as the pixel ratio of the screen.

We are going to update these values when a resize occurs, but we are also going to warn the experience of that resize.

In the /src/Experience/Utils/ folder, create the Sizes.js class:

export default class Sizes
{
    constructor()
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

And instantiate it in the Experience class:

import Sizes from './Utils/Sizes.js'

export default class Experience
{
    constructor(canvas)
    {
        // ...

        // Setup
        this.sizes = new Sizes()
    }
}
Enter fullscreen mode Exit fullscreen mode

In that Sizes class, add the usual width, height and pixelRatio as we did before, but save them as properties:

export default class Sizes
{
    constructor()
    {
        // Setup
        this.width = window.innerWidth
        this.height = window.innerHeight
        this.pixelRatio = Math.min(window.devicePixelRatio, 2)
    }
}
Enter fullscreen mode Exit fullscreen mode

Then listen to the resize event and update those properties:

export default class Sizes
{
    constructor()
    {
        // ...

        // Resize event
        window.addEventListener('resize', () =>
        {
            this.width = window.innerWidth
            this.height = window.innerHeight
            this.pixelRatio = Math.min(window.devicePixelRatio, 2)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we assume that the experience always fills the viewport. If that's not the case, you'll have to do things differently.

We can now access the width, height and pixelRatio from the Experience class:

import Sizes from './Utils/Sizes.js'

export default class Experience
{
    constructor(canvas)
    {
        // ...

        this.sizes = new Sizes()

        console.log(this.sizes.width)
        console.log(this.sizes.height)
        console.log(this.sizes.pixelRatio)
    }
}
Enter fullscreen mode Exit fullscreen mode

At some point, we will also have to update other values like the camera or the renderer when a resize occurs. We could listen to the resize event on window like we just did, but instead, we are going to use the Sizes class to warn the other classes about that change.

Closing thoughts

If you're working on a Three.js or React Three.js project, you already know that these libraries offer a lot of flexibility and power when it comes to creating 3D visualizations, animations, and interactive experiences. But with great power comes great responsibility, especially when it comes to organizing your code. Here are some tips to help you structure your code in a way that's clear, efficient, and maintainable.

  1. Start with a plan: Before you dive into coding, take some time to sketch out your project's architecture and flow. Think about the different components or modules you'll need, and how they'll interact with each other. Consider using a diagramming tool like Lucidchart or Draw.io to visualize your plan.

  2. Break it down into components: In React Three.js, everything is a component. Take advantage of this by breaking your code down into small, reusable components that do one thing and do it well. This makes it easier to manage your code, test your components in isolation, and swap out components as needed.

  3. Use a folder structure: Create a folder structure that makes sense for your project, and stick to it. For example, you might have separate folders for components, utilities, assets, and tests. This helps you find what you need quickly, and keeps your project organized as it grows.

  4. Name your files and components clearly: Choose descriptive names for your files and components, and avoid using generic names like "utils" or "helpers". This makes it easier to understand what each file or component does, and helps you avoid naming collisions with other libraries or modules.

  5. Use a linter: A linter is a tool that checks your code for style and syntax errors, and can also enforce coding standards and best practices. Use a linter like ESLint or Prettier to keep your code consistent and readable, and catch errors before they cause problems.

  6. Use version control: Whether you're working solo or with a team, version control is essential for managing changes to your code over time. Use a version control system like Git to track changes, collaborate with others, and revert to earlier versions if needed.

  7. Keep your code DRY: DRY stands for "Don't Repeat Yourself", and it's a fundamental principle of good code design. Avoid duplicating code across your project, and instead look for ways to abstract common functionality into reusable functions or modules.

  8. Use comments judiciously: Comments can be helpful for explaining complex code or providing context for others who might work on your project. But too many comments can clutter your code and make it harder to read. Use comments sparingly, and focus on writing code that's self-explanatory.

  9. Test your code: Finally, make sure to test your code thoroughly to catch errors and ensure that your project works as expected. Use a testing framework like Jest or Enzyme to write unit tests for your components and utilities, and automate your tests using tools like Travis CI or CircleCI.

By following these tips, you'll be well on your way to writing clean, organized code that's easy to maintain and build upon over time.

Remember, good code structure isn't just a nicety - it's essential for creating successful Three.js or React Three.js projects that meet your goals and exceed your users' expectations.


That is it for this article about structuring big creative projects!

Let's connect on Twitter and LinkedIn 👋

Top comments (1)

Collapse
 
sabrinaesaquino profile image
Sabrina

Super valuable information! Thanks for putting this together