Creative Coding I: Design & Communication

Prof. Dr. Lena Gieseke | l.gieseke@filmuniversitaet.de | Film University Babelsberg KONRAD WOLF


Script 06 - Three.js

The first version of this script was written by:

Anna Eschenbacher | anna.eschenbacher@filmuniversitaet.de | Film University Babelsberg KONRAD WOLF

By now, the script has been heavily updated by Lena.



Let’s start easy: What is space?

dalle_space_01 Dall-E: Space Virtual

Check the Merriam-Webster dictionary for space.

What Are The Spatial Properties Of The Virtual Space We Use?

dalle_space_virtual_01 Dall-E: Space Virtual

How Do Analog And Virtual Spatial Spaces Relate To Each Other?

dalle_connection_analog_virtual_space_01 Dall-E: Space Virtual

What Makes Analog Space Unique?


Threejs

Three.js is an open-source library and API using WebGL. It is used to create and display animated 3D graphics in a web browser. It simplifies the creation of WebGL tools and environments. [2] This means, there is a whole library of simplified functions for geometries, materials, lighting, audio, etc. Alternatively, you could write everything in WebGL, but that is a bit more complicated as you can see in the example below.

WebGL

WebGL is a JavaScript API for rendering interactive 2D and 3D graphics. It is executed on a computer’s GPU. It consists of control code written in JavaScript and shader code which is written in OpenGL ES Shading Language (GLSL ES), a language similar to C or C++. [1]

Here is an example for WebGL code:

<!doctype html>

<html>

  <body>

 	<canvas width="500" height="500"></canvas>

  <script>

    var canvas,
        gl,
        vertices,
        vertexBuffer,
        vertexShader,
        fragmentShader,
        shaderProgram;

    init();
    setupBuffers();
    createVertexShader();
    createFragmentShader();
    createShaderProgram();
    assignShadersToBuffers();
    draw();

    function init()
    {
      canvas = document.getElementsByTagName('canvas')[0];
      gl = canvas.getContext('webgl');
      vertices = [0, 1, 1, -1, -1, -1];
    }

    function setupBuffers()
    {
      vertexBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
      gl.bindBuffer(gl.ARRAY_BUFFER, null);
    }

    function createVertexShader()
    {
      var vertexShaderCode =
      'attribute vec2 coordinates;' +
      'void main(void) {' +
      ' gl_Position = vec4(coordinates, 0, 1.2);' +
      '}';

      vertexShader = gl.createShader(gl.VERTEX_SHADER);
      gl.shaderSource(vertexShader, vertexShaderCode);
      gl.compileShader(vertexShader);
    }

    function createFragmentShader()
    {
      var fragementShaderCode = 'void main(void) {' +
      'gl_FragColor = vec4(1, 0, 0, 1);' +
      '}';

      fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
      gl.shaderSource(fragmentShader, fragementShaderCode);
      gl.compileShader(fragmentShader);
    }

    function createShaderProgram()
    {
      shaderProgram = gl.createProgram();

      gl.attachShader(shaderProgram, vertexShader);
      gl.attachShader(shaderProgram, fragmentShader);
      gl.linkProgram(shaderProgram);
      gl.useProgram(shaderProgram);
    }

    function assignShadersToBuffers()
    {
      var numberOfComponentsPerVertexAttribute = 2,
          normalizeValues = false,
          numberOfBytesBetweenElements=0,
          offset=0,
          coordinates;

      gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

      coordinates = gl.getAttribLocation(shaderProgram, "coordinates");

      gl.vertexAttribPointer(coordinates, numberOfComponentsPerVertexAttribute, gl.FLOAT, normalizeValues, numberOfBytesBetweenElements, offset);
      gl.enableVertexAttribArray(coordinates);
    }

    function draw()
    {
      var drawMode = gl.TRIANGLES,
          first=0,
          count=3;

      gl.clearColor(0.7, 0.7, 0.7, 1);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawArrays(drawMode, first, count);
    }


  </script>

  </body>
</html>

This code will produce the following image:

As you can see it it’s quite a lot of code for a simple red triangle. In WebGL you do all setup steps manually.

This is where Three.js comes in to simplify the creation of 3D graphics with WebGL.

Alternatives

There are a few alternatives to Three.js. The most popular one for 3D content might be babylon.js. Of course p5.js is also an alternative, but mostly used for 2D graphics.

A bablyon.js video promo:

The master thesis project Somewhere.gl is also created with bablyon.js:

somewhere_01

Example Projects

To get a first idea of what Three.js can do, let’s have a look at some example projects:

Collections

Installation

Note for Class 2425: We install three.js locally with npm. See the slides for that.

As with all libraries, we can access local or online versions of the library. For a proper local installation there are steps involved, we don’t know yet. We will have a look at that next week.

For getting the coding started, we will simply work with links to online library files.

Online Library Files

To make an import from three.js online library files, we will use a slightly more sophisticated script call:

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.158.0/build/three.module.js"
            }
        }
    </script>

We need the above code due to the way the three.js library is constructed. Roughly speaking, the library is broken into different modules, which make it easier to maintain the code-base. The three.js library exports (meaning, gives to us) the module three. The script type importmap allows to specify, which module(s) to import. The imported library module will replace the text when the module URL is resolved.

Keep in mind, that modules only work with the HTTP(s) protocol. A web-page opened via the file:// protocol cannot use import / export.

Out of the box, three.js includes the fundamentals of a 3D engine. Other three.js components — such as controls, loaders, and post-processing effects — are part of the addons/ directory. [7]

<script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.158.0/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.158.0/examples/jsm/"
    }
  }
</script>

In total, we have in index.html for now:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello three.js!</title>

    <link rel="stylesheet" type="text/css" href="../css/style.css" />

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.158.0/build/three.module.js"
            }
        }
    </script>
  </head>

  <body>
    <canvas id="canvasThree"></canvas>
    <script type="module" src="./scene.js"></script>
  </body>
</html>

The three.js scene will be implemented in scene.js (often also called main.js). Please note, that the script type for a three.js scene must be set to module. If you do not specify the type as module, you will get an error, most likely. The type module causes the loaded source code to be treated as a JavaScript module, which is needed for three.js to work.

To define the canvas element (here with the id canvasThree), in which three.js is going to be rendered is not strictly necessary (similar to p5, where we didn’t specify it), but I find it a well-structured setup.

For our first scene, we are going to make our scene fill the whole browser window. Hence, we only need basic css, such as

body {
  margin: 0;
}

For such minimal code, we could also just add the code within a script tag within our .html file directly:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello three.js!</title>

    <style>
        body { margin: 0; }
    </style>

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.158.0/build/three.module.js"
            }
        }
    </script>
  </head>

  <body>
    <canvas id="canvasThree"></canvas>
    <script type="module" src="./scene.js"></script>
  </body>
</html>

To run this project we will use the same extensions as you did before - live server for VSCode.

Local Installation

We will come back to this.

Basic Scene

Now the fun part begins - let’s get started with our first three.js scene, which we will implement in th scene.js file.

To display something with three.js, such as the beautiful cube below, we need at the very least three components:

  1. a scene,
  2. a camera and
  3. a renderer.

The code below creates a basic scene, displaying a cube. Worry not, in the following script, I will explain each component.

import * as THREE from 'three';

// SCENE
const scene = new THREE.Scene();

// CAMERA
const fov = 70;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 10;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 1;

// RENDERER
const canvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
renderer.setSize( window.innerWidth, window.innerHeight);

// GEOMETRY
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh( geometry, material );

cube.rotation.x = 100;
cube.rotation.y = 180;
scene.add(cube);

renderer.render(scene, camera);

cube_01

The THREE Object

As above for the library loading already mentioned, three.js is a well-structured library, separated into different modules (and classes, see the section below). Such a setup requires us to specify for our JavaScript file, what to import from which module.

The following line indicates to import everything (*) from the library’s module three (the same value as in the "imports" call in index.html) and save it in an object, which is called THREE (in theory, you could change this name, but THREE is a convention):

import * as THREE from 'three';

In the following code we can now work with the object THREE and access its properties, functions, and classes with the .-notation, e.g., THREE.Scene();.

THREE Classes

In the three.js library many components are encapsulated into classes, e.g., there is a Scene class, a PerspectiveCamera class, a BoxGeometry and so on.

Don’t forget: To access these classes, we always have to use the THREE object, which we have imported in the previous step.

The short story for working with three.js is that you have to create instances from the given classes in order to work with that component and to, e.g., use that component’s functionalities. To create an instance of a class, we have to use the keyword new. The following code creates an instance of the PerspectiveCamera class (let’s ignore its arguments for now) and we have to access that class through the THREE object:

const camera = new THREE.PerspectiveCamera();

Once we have created an instance of the PerspectiveCamera class, we can work with that object:

camera.position.z = 1;

The slightly longer explanation is that a class is a common data structure in many programming languages (also see Script 04 - Javascript - Classes). It let’s programmers define their own templates for “a type of object” (conceptually this can be anything) and define which properties and function that object should have. For example, if I am working on a game about cats, I might want to have a data structure for cats, meaning a template for the properties and functions of a cat. Once I have that template -which in computer science lingo is the class- I can derive instances from that template/class, and all instances have certain cat properties and functions.

// The template
class Cat
{
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    makeSound() { console.log('meow') }
    getName() { console.log('My name is', this.name) }
}

// The instance
let ernie = new Cat('Ernie', 3);
ernie.makeSound();  // -> meow

JavaScript Objects

When working with three.js, we often have to use objects as data structure (also see Script 04 - Javascript - Classes). An object let’s you group values into a single value, similar to an array but objects use key-value pairs.

You define an object as:

let object_name = {
   key1: value1,
   key2: value2
}
let user = {        // an object
  name: "Sully",    // the key "name" stores the value "Sully"
  age: 30           // the key "age" stores the value 30
};

The above is usually written more compactly as:

let user = { name: 'Sully', age: 30 };

To access a property, we can use:

obj.property

e.g.,

user.name;

Which might be confusing, an which might happen, when working with three.js: When defining an object, there is a short-cut for the case that key and value are the same.

Let’s say we have a variable name that we want to save in a key called name:

let name = 'Sully';
let age_sully = 30;

let user = { name: name, age: age_sully };

The expresseion name: name, within an object can be shortend to just name:

let name = 'Sully';
let age_sully = 30;

let user = { name, age: age_sully };

For example, when creating an instance of three.js’s WebGLRenderer class, it takes as an argument an object. That object has, among other keys, the key canvas. The long version of the object as argument would be

const canvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({canvas: canvas, antialias: true});

But as we have the same name for object key and variable to pass, the code is usually written as:

const canvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

In the following case, we can not shorten the code, as our variable is called myCanvas and not canvas:

const myCanvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({canvas: myCanvas, antialias: true});

Scene

const scene = new THREE.Scene();

Three.js uses the concept of a scene. You can think of a scene as the object, which holds everything together, which belongs to a scene, such as geometry and lights, and which is then given to the renderer (together with a camera).

Under the hood, a Scene object defines the root of a scenegrap. A scene graph in a 3D engine is a hierarchy of nodes in a tree-like structure, where each node represents a local space:

scenegraph_01

Camera

There are a few different camera types in three.js. We will use a PerspectiveCamera since its projection mode is designed to mimic the way the human eye sees.

const fov = 70;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 10;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

As shown above, we can create an instance of the PerspectiveCamera class with the following arguments:

PerspectiveCamera( fov : Number, aspect : Number, near : Number, far : Number )

  1. fov — Camera frustum vertical field of view.
  2. aspect — Camera frustum aspect ratio.
  3. near — Camera frustum near plane.
  4. far — Camera frustum far plane.

The first attribute is the field of view. FOV is the extent of the scene that is seen on the display at any given moment. The value is in degrees.

For example, this image shows different field of views taken from the same camera position (with real cameras, this is done with changing the focal length of the lens used):

fov_01 [shotkit]

The second one is the aspect ratio. You almost always want to use the width of the element divided by the height, otherwise you’ll get a distorted image.

The next two attributes are the near and far clipping plane. What that means, is that objects further away from the camera than the value of far won’t be rendered. Same applies to objects closer than the near value. This is mainly used for better performance.

By defining these attributes, we constrain the rendered area to the view frustum, which is the space between the green and red planes in this image:

frustum_01

[5]

Renderer

Next up is the renderer. We will use the WebGLRenderer. Three.js comes with a few other renderers, often used as fallbacks for users with older browsers or for those who don’t have WebGL support for some reason.

const canvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

In the first line we are accessing with plain, old JavaScript the element in the DOM with the id canvasThree. This is the canvas element to which we want to render to. This canvas we pass as argument when creating an instance from the WebGLRenderer class.

We also need to set a size for the renderer. You can understand this as the “image size”, we are rendering to. It is best to use the width and height of the area we want to fill - in this case, the width and height of the browser window.

renderer.setSize( window.innerWidth, window.innerHeight);

To actually see a rendering, we have to activate the rendering (probably somewhere at the end of our file, once the scene is put together), with a scene and a camera:

renderer.render(scene, camera);

Now we have a scene, a camera, and renderer and with that a fully functioning rendering pipeline. Let’s put something into our scene.

Coordinate System

Before we add any geometry, we have to take a quick look at the coordinate system. We need to clarify the coordinate system of every new environment that we use, as these differ from environment to environment. As we are now in 3D, next to x, and y we have to consider a third dimension, namely z. As default, the camera is looking down the -z axis:

coordinates_01 [6]

Let’s start boring and add a cube to the scene.

Creating 3D Elements

To create a cube (fear not, there are many other shapes available in three.js), we create an instance of the BoxGeometry class with its sizes as arguments:

BoxGeometry(width : Float, height : Float, depth : Float)

  • width — Width; that is, the length of the edges parallel to the X axis. Optional; defaults to 1.
  • height — Height; that is, the length of the edges parallel to the Y axis. Optional; defaults to 1.
  • depth — Depth; that is, the length of the edges parallel to the Z axis. Optional; defaults to 1.
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);

The above code only defines cube geometry. However, to render that cube, we also must create a material to make it visible (the MeshNormalMaterial material is just an example here - it takes the colors from the orientation of the surfaces)

const material = new THREE.MeshNormalMaterial();

and add both, geometry and material, to a mesh:

const cube = new THREE.Mesh( geometry, material );

That is just how three.js wants it. Live with it. Hence, in total we have for creating a geometry, or in three.js lingo a mesh:

const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh( geometry, material );

Material

Three.js comes with several materials. All materials can be filled with certain attributes to change the look. This can be color, roughness, as well as a texture. Here, e.g., are all attributes listed for the oldy but goody MeshPhongMaterial. A Phong material mimics how real-world surfaces reflect light in a very limited but still useful fashion.

Mesh

A mesh is an object that takes a geometry, and applies a material to it.

meshgeomat

Then, we add the mesh to our scene with scene.add()

scene.add(cube);

By default, when we call scene.add(), the object will be added to the coordinates (0,0,0). This is why we moved the camera back a bit earlier.


Our basic scene is now completed.

Be aware that certain materials only appear, if you have a light in the scene. You can imagine the MeshNormalMaterial material is “self-illuminating”, meaning it doesn’t need a light to be visible. For the Phong material this is different. Let’s change the material to the standard material:

import * as THREE from 'three';


// SCENE
const scene = new THREE.Scene();

// CAMERA
const fov = 70;
const aspect = window.innerWidth / window.innerHeight;
const near = 0.1;
const far = 10;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 1;

// RENDERER
const canvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize( window.innerWidth, window.innerHeight);

// GEOMETRY
const geometry = new THREE.BoxGeometry( 0.5, 0.5, 0.5 );
const material = new THREE.MeshStandardMaterial({ color: 0x50ffc0 });

const cube = new THREE.Mesh( geometry, material );
cube.rotation.x = 100;
cube.rotation.y = 180;
scene.add(cube);

renderer.render(scene, camera);

Notice, how MeshStandardMaterial takes as argument an object with the key-value pair color - value.

Colors

The standard method of specifying color in three.js is with a hexadecimal triplet in the format of 0x.... There is also the color class in three.js, which gives you many different options to work with color.

The above scene renders as follows:

That is because the standard material reacts to light and to make it visible we need to add lights to the scene to be reflected.

Lighting

There are a few different lights like ambient light, directional light, point light, spot light. First, will use an white ambient light to illuminate the whole scene.

// LIGHTING
// AMBIENT
const lightColor = 0xffffff;
const lightIntensity = 0.8;
const ambientLight = new THREE.AmbientLight(lightColor, lightIntensity);
scene.add(ambientLight);

Btw. in real life you would code it like this, no need to extra variables if you do not want to re-use those values:

// LIGHTING
// AMBIENT
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);

Now our scene looks as follows:

ambient_01

Also not too exciting. An ambient light simply makes the whole scene evenly brighter.

Let’s also add a point light:

// POINTLIGHT
const pointLight = new THREE.PointLight(0xffffff, 0.5);
pointLight.position.set(1, 2, 1);
scene.add(pointLight);

Decrease the intensity of the ambient light to have a reasonable sum of the two light intensities (e.g., 0.1 for ambient and 0.8 for the point light).

For a point light the pointLightHelper is very useful. It is a frame around the light so we can see where it is positioned in our space. Sometimes this is extremely helpful when designing a scene. You might need to adjust your camera or zoom out to see the helper.

const sphereSize = 1;
const pointLightHelper = new THREE.PointLightHelper(pointLight, sphereSize);
// add the point light helper to the scene
scene.add(pointLightHelper);

Now we actually can see the shape in 3D:

cube_02

Stunning 🤩

Wouldn’t it be nice though, if we could navigate in the scene? For enabling any type of interaction or also, e.g., animation we are still a fundamental property missing in our scene: a render loop. As of now, we are rendering the scene exactly once. But we need a functionality such as the draw function in p5, which is called multiple times per second.

The Render Loop

We can easily create a render loop similar to the draw()-loop in p5.js. For that we are using JavaScript’s requestAnimationFrame function, which requests the browser to refresh its window.

This is the typical function to for creating an animation in a browser. requestAnimationFrame refreshes the current window and takes as argument another function that should be called from requestAnimationFrame. Such a function as an argument is a callback function.

requestAnimationFrame(callback)

In this case the callback function is the function to call when it’s time to update our animation or better our scene for the next repaint.

function animate() {
    
    ...
}

requestAnimationFrame(animate);

Wait, what? A function takes another function as argument?? Welcome to JavaScript, my friends!

These type of functions are called higher order functions and they are part of the functional programming paradigm. With that they are not JavaScript specific but to my knowledge most prominently used in JavaScript. At a different time you will hear everything about those functions. For now, just accept the fact that functions are just objects that can be passed as argument to functions.

The above does not create a loop yet. We request a window refresh exactly once. But the callback function could be the function that also calls requestAnimationFrame(animate); - and with that we have our loop!

// RENDER LOOP
function animate() {
    
    ...
    requestAnimationFrame(animate);
}

animate();

As we want to re-render our scene with each refresh window call, we add the rendering to this animation function:

// RENDER LOOP
function animate() {
    
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}

animate();

This function is typically called animate even though in the above example it is more of a render function. We could also call it draw if our hearts are still with p5. Eventually, we will add animation behavior to the function that is why I introduce the name animate here already.

Now, that we have a render loop, we can also add interaction. Let’s start with simple scene navigation.

Orbit Control

OrbitControls enable a simple navigation setting that allows us to move around a target.

The Orbit Control functionality is not part of the main library but we need to load an additional module for it. three.js calls these Addons. Once again, for knowing how to install an addon you must look into the installation guide of the library that you are using. There is no just knowing it.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello three.js!</title>

    <link rel="stylesheet" type="text/css" href="../css/style.css" />

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.158.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.158.0/examples/jsm/"
            }
        }
    </script>
  </head>

  <body>
    <canvas id="canvasThree"></canvas>
    <script type="module" src="./scene.js"></script>
  </body>
</html>

The OrbitControls addon must also be imported explicitly in our JavaScript file. The export from the OrbitControls.js file is a named export and we can use that name to import exactly what we need:

import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

How should you know this? Well, read the three.js documentation, where it is explained to us how to import the class OrbitControls.

You could also save the import in an object in the same manner as we have done it with the three module:

import * as ORBIT from 'three/addons/controls/OrbitControls.js';

With the code above, you would need to add ORBIT. to the orbit specific commands in the following.

In general, I recommend to go with the installation, and import recommendation from the official documentation unless you have a good reason (and the knowledge) to do it differently.

Now, we can add those controls to the scene with

OrbitControls( object : Camera, domElement : HTMLDOMElement )

  • object: (required) The camera to be controlled. The camera must not be a child of another object, unless that object is the scene itself.
  • domElement: The HTML element used for event listeners (where to detect user input?).

This looks in our scene as followsL

// CONTROLS FOR NAVIGATION
const controls = new OrbitControls(camera, canvas);

Remember, in canvas we have saved the value of document.querySelector("#canvasThree");. As we might not have saved the canvas element explicitly in a variable, we could also more generically use:

// CONTROLS FOR NAVIGATION
const controls = new OrbitControls(camera, renderer.domElement);

For simple navigation this might already be enough. But Orbit Controls has properties such as enableDamping and autoRotate, which need to be updated, which can be done within our render loop with controls.update();. Keep in mind that also after any manual changes to the camera’s transform, controls.update(); would need to be called.

// RENDER LOOP
function animate() {
    
    controls.update();
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}

animate();

A Bit Prettier

With the above knowledge, you should be able to look up in the documentation and understand the following scene.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello three.js!</title>

    <style>
        body { margin: 0; }
    </style>

    <!-- Three.js -->
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.158.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.158.0/examples/jsm/"
            }
        }
    </script>

  </head>

  <body>
    <canvas id="canvasThree"></canvas>
    <script type="module" src="scene.js"></script>
  </body>
</html>

//scene.js

import * as THREE from 'three';

// We want to use the "OrbitCamera" addon provided by three.JS
// We need to import it from the folder and give the imported object (the addon) a name: "OrbitControls"
// This information is given to us by the library
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// SCENE
const scene = new THREE.Scene();

// A fog is implemented to give distance to the scene and smoothly transition from the bottom plane to the background color
scene.background = new THREE.Color('#ffc2c2');
scene.fog = new THREE.FogExp2(scene.background, 0.02);

// CAMERA
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 22;

// RENDERER
const canvas = document.querySelector("#canvasThree");
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);


// We want light to cast a shadow, so we have to enable a shadowMap with certain properties in the render pipeline
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

document.body.appendChild(renderer.domElement);


// CONTROLS FOR NAVIGATION
// Here the camera is given to OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);

// LIGHTING
// AMBIENT
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

// POINT LIGHT
const pointLight = new THREE.PointLight(0xfff4c9, 50);
pointLight.position.set(0, 4, 2);

// Enabling shadows for the pointlight
// Max and Min Distances are needed for the renderer
pointLight.castShadow = true;
pointLight.shadow.mapSize.width = 1024;
pointLight.shadow.mapSize.height = 1024;
pointLight.shadow.camera.near = 1;
pointLight.shadow.camera.far = 600;

// The radius is the "smoothness" of the light's shadow
pointLight.shadow.radius = 10;
scene.add(pointLight);


// A pointLightHelper draws a bounding box around the light to show us its position in the scene
const pointLightHelper = new THREE.PointLightHelper(pointLight, 1);
scene.add(pointLightHelper);

// GEOMETRY
// PLANE
const planeGeometry = new THREE.PlaneGeometry(300, 300);
const planeMaterial = new THREE.MeshStandardMaterial({ color: '#b3005a' });
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotation.x = -Math.PI / 2;
planeMesh.position.y = -10;
// Enable shadows on the plane
planeMesh.receiveShadow = true;
scene.add(planeMesh);

// ICOSAHEDRON
const icosaGeo = new THREE.IcosahedronGeometry(2.4, 0); //radius, detail
const icosaMat = new THREE.MeshStandardMaterial({ color: 0xff0080});
const icosa = new THREE.Mesh(icosaGeo, icosaMat);
// Make the icosa cast shadows
icosa.castShadow = true;
scene.add(icosa);

// ICOSAHEDRON 2
const icosaWireGeo = new THREE.IcosahedronGeometry(3.0, 0); //radius, detail
const icosaWireMat = new THREE.MeshStandardMaterial({ color: 0xffab00, wireframe: true });
const icosaWire = new THREE.Mesh(icosaWireGeo, icosaWireMat);
scene.add(icosaWire);

// CUBECIRCLE
const nCubes = 6;
const radius = 10;
const cubeSize = 5;
let angle = 0;
const step = (2 * Math.PI) / nCubes;
for(let i = 0; i < nCubes; i++) {
    const x = radius * Math.cos(angle);
    const z = radius * Math.sin(angle);
    const y = -10;
    const cubeGeometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMaterial = new THREE.MeshStandardMaterial({ color: 0xff8000, roughness:0.3, metalness: 0.5});
    const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
    
    // Make the cube cast shadows
    cube.receiveShadow = true;
    // Enable shadows on the cube
    cube.castShadow = true;
    cube.position.set(x,y,z);
    cube.lookAt(0,0,0);
    scene.add(cube);
    angle += step;
}

// ANIMATE/RENDER like draw() in p5
function animate() {
    requestAnimationFrame(animate);

    // Offset the elements over time

    icosa.rotation.x += 0.004;
    icosa.rotation.y += 0.007;
    icosaWire.rotation.x += 0.008;
    icosaWire.rotation.z += 0.005;


    const t = Date.now() / 3000;
    // move light in circle around center
    // change light height with sine curve
    const r = 3.0;
    const lx = r * Math.cos(t);
    const lz = r * Math.sin(t);
    pointLight.position.set(lx, pointLight.position.y, lz);

    renderer.render(scene, camera);
}

animate();

scene_advanced

Further Resources

three.js documentation

The official documentation is of course a good place to start looking for help. I consider the documentation well-made.

three.js examples

The three.js example site is a great start when wondering how to add further functionality. If you find an example you would like adapt in some way in your project, you can take a look at the three.js GitHub via the button on the bottom right of the scene. When you want to re-implement something from an example, make sure that you have made the same imports for the needed addons and such.


References

[1] WebGL definition
[2] Github Three.js
[3] Scene Graph
[4] FOV Image
[5] Clipping Plane
[6] Coordinate System
[7] three.js - Installation
[8] mdn web docs - Window: requestAnimationFrame() method


The End

💻 💫 🔺