8/13

6. Structuring code

Use of functions

As in any programming language, it is essential that you split large code structure into functions. It will ease readability of your code, its debug, maintenance.

The following code proposes another 3D scene with four objects (cube, sphere, cylinder, and a plane) and the use of shadows.


"use strict";


// ************************************* //
// Functions definition
// ************************************* //

// Create an empty scene with a camera, spotlight, and a renderer
function initEmptyScene(sceneElements) {

    // ************************** //
    // Create the 3D scene
    // ************************** //
    sceneElements.sceneGraph = new THREE.Scene();

    // ************************** //
    // Add camera
    // ************************** //
    const width = window.innerWidth;
    const height = window.innerHeight;
    const camera = new THREE.PerspectiveCamera(45, width/height, 0.1, 500);
    sceneElements.camera = camera;
    camera.position.set(-5, 5, 5);
    camera.lookAt(0, 0, 0);


    // ************************** //
    // Add an ambient light
    // ************************** //
    const ambientLight = new THREE.AmbientLight( 'rgb(255, 255, 255)', 0.2 );
    sceneElements.sceneGraph.add(ambientLight);

    // ************************** //
    // Add spotlight (with shadow)
    // ************************** //
    const spotLight = new THREE.SpotLight('rgb(255, 255, 255)', 0.8);
    spotLight.position.set(-5, 8, 0);
    sceneElements.sceneGraph.add(spotLight);

    // Setup shadow properties for the spotlight
    spotLight.castShadow = true;
    spotLight.shadow.mapSize.width = 2048;
    spotLight.shadow.mapSize.height = 2048;


    // ************************** //
    // Create renderer (with shadow map)
    // ************************** //
    const renderer = new THREE.WebGLRenderer( {antialias:true} );
    sceneElements.renderer = renderer;
    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setClearColor('rgb(255, 255, 150)', 1.0);
    renderer.setSize( width, height );

    // Setup shadowMap property
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;

    // ************************** //
    // Add the render image in the HTML DOM
    // ************************** //
    const htmlElement = document.querySelector("#Tag3DScene");
    htmlElement.appendChild(renderer.domElement);
}

// Create and insert in the scene graph the shapes of the 3D scene
function load3DObjects(sceneGraph) {

    // ************************** //
    // Create a ground plane
    // ************************** //
    const planeGeometry = new THREE.PlaneGeometry( 6, 6 );
    const planeMaterial = new THREE.MeshPhongMaterial( {color:'rgb(200, 200, 200)', side:THREE.DoubleSide} );
    const planeObject = new THREE.Mesh( planeGeometry, planeMaterial );
    sceneGraph.add( planeObject );

    // Change orientation of the plane using rotation
    planeObject.rotateOnAxis(new THREE.Vector3(1,0,0), Math.PI/2 );
    // Set shadow property
    planeObject.receiveShadow = true;



    // ************************** //
    // Create a cube
    // ************************** //
    const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
    const cubeMaterial = new THREE.MeshPhongMaterial( {color:'rgb(255,0,0)'} );
    const cubeObject = new THREE.Mesh( cubeGeometry, cubeMaterial );
    sceneGraph.add(cubeObject);

    // Set position of the cube
    cubeObject.translateY(0.5);
    // Set shadow property
    cubeObject.castShadow = true;
    cubeObject.receiveShadow = true;


    // ************************** //
    // Create a sphere
    // ************************** //
    const sphereGeometry = new THREE.SphereGeometry(0.5, 32, 32);
    const sphereMaterial = new THREE.MeshPhongMaterial( {color:'rgb(180,180,255)'} );
    const sphereObject = new THREE.Mesh( sphereGeometry, sphereMaterial );
    sceneGraph.add(sphereObject);

    // Set position of the sphere
    sphereObject.translateX(-1.2).translateY(0.5).translateZ(-0.5);
    // Set shadow property
    sphereObject.castShadow = true;


    // ************************** //
    // Create a cylinder
    // ************************** //
    const cylinderGeometry = new THREE.CylinderGeometry(0.2, 0.2, 1.5, 25, 1);
    const cylinderMaterial = new THREE.MeshPhongMaterial( {color:'rgb(200,255,150)'} );
    const cylinderObject = new THREE.Mesh( cylinderGeometry, cylinderMaterial );
    sceneGraph.add(cylinderObject);

    // Set position of the cylinder
    cylinderObject.translateX(0.5).translateY(0.75).translateZ(1.5);
    // Set shadow property
    cylinderObject.castShadow = true;
}


function render(sceneElements) {
    sceneElements.renderer.render(sceneElements.sceneGraph, sceneElements.camera);
}




// ************************************* //
// Beginning of the Script
// ************************************* //


// General variable storing the scene graph, and elements usefull to render the scene (camera, renderer, etc)
const sceneElements = {
    sceneGraph : null,
    camera     : null,
    renderer   : null,
};


// Three functions are called
//  1. Initialize an empty scene
//  2. Add elements within the scene
//  3. Render the scene
initEmptyScene(sceneElements);
load3DObjects(sceneElements.sceneGraph);
render(sceneElements);

Notes on code

General structure

  • In this code, we defined three functions [1]

    1. To initialize an empty scene with all the basic elements (Three.js graph scene, camera, lighting, rendering, etc).

    2. To create the shapes within the 3D scene.

    3. To compute the rendering

  • After the definition of the functions, the general variables used for the 3D scene are defined, and functions are called.

In addition, this one adds the following elements with respect to the first one

  • Antialiasing for the rendering

  • Use of shadows. Shadows are not automatically handled in OpenGL (/projective) rendering and requires dedicated techniques. Three.js implement the technique of Shadow Mapping. In Three.js, you should explicitly setup

    • Shadow Mapping in the renderer

    • Make the lights casting shadows (and here setup the size of the texture map associated to the shadow)

    • Define for each object of the scene if its cast and/or receive shadow.

  • Create new 3D primitives (plane, sphere, cylinder) and change their orientation using rotateOnAxis(axis: Vector3, angle: Float) function [2].

Variables

Note that the variables of the scene (camera, sceneGraphe, etc) are defined after the functions, and are passed as parameters to them. It is also possible to define the variable before, and use them as global variables: in this case, they don’t have to be parameters of the functions

// Example of using sceneElements as global variables
const sceneElements = {
    sceneGraph : null,
    camera     : null,
    renderer   : null,
};
// Functions don't need to receive parameters
function initEmptyScene( ) { ... }
function load3DObjects( ) { ... }
function render( ) { ... }

However, in large code, the number of parameters may increase a lot. Handling correctly a large number of global parameter is both hard to read [3], and error-prone [4]. As good practice principle, it is preferable to limit the use of global variables when it is reasonably possible.

Object Three.js

All Three.js objects are instantiated using the syntax new THREE.ObjectName. Don’t forget the operator new when instanciating a new object [5].

One of the commonly used object in Three.js is the 3D vector: THREE.Vector3, containing (x,y,z) coordinates.

Reference, copy and clone

Take care than all JavaScript objects are handled by reference. Affecting one object to two variables, leads to variables referring to the same object.

Declaring b=a doesn’t create a new object as a copy, but only assign to the same object.

Example with Vector3 object:

  const a = new THREE.Vector3(0, 1, 0);
  const b = a; // b refers to the same object than a
  b.x = 5;
  console.log(a); // Display (5, 1, 0)

To copy an object into another one, Three.js defines the method .copy(v: Vector3)

  const a = new THREE.Vector3(1, 2, 3);
  const b = new THREE.Vector3(0, 0, 0);
  b.copy(a); // the vector b receives a copy of the (x,y,z) values from vector a
  console.log(b); // Display (1, 2, 3)

  b.y = 8;
  console.log(a); // Display (1, 2, 3) - modifying b doesn't modify a
  console.log(b); // Display (1, 8, 3)

A shortcut to create a new object, and then copy the values of an existing object can be done by the Three.js method .clone()

  const a = new THREE.Vector3(1, 2, 3);
  const b = a.clone(); // equivalent to b = new THREE.Vector3(); b.copy(a);

  b.y = 8;
  console.log(a); // Display (1, 2, 3) - modifying b doesn't modify a
  console.log(b); // Display (1, 8, 3)

As a side note, pay attention that some Three.js functions receive Vector3 as parameters, while others expect 3 (x,y,z) float coordinates. Check carefully the expected parameters: JavaScript automatically adapt the type of the variables, thus sending a Vector3 object into a function expecting three (x,y,z) values will assign x to the Vector3, and undefined to y and z (leads to unexpected result and hard to debug code).

DoubleSide rendering

Three.js renders by default only one side of 3D shape (conventionally called positive side oriented in the direction of the normal).

Thus in our case, the plane could be by default only from one side and not from the other. Take care that this is a common mistake explaining why some object may not appear on the screen.

In order to be able to see shapes independently of their orientation, you can add the request to display both side with the syntax side:THREE.DoubleSide in the material associated with the object.

Use of external file

It may be interesting to split the code into multiple files. This can help readability of each file, as well as reusability.

  • The following webpage (src) split the previous JavaScript code into two parts

    1. A helper code (helper.js) containing the initialization and rendering functions. These functions can typically be used for other programs as they are independent of the specific scene.

    2. The main code (scene.js) handling the general variables and the creation of the 3D shapes within the scene.

Extra notes

  • helper.js is included in the header of the HTML file. This inclusion should be placed after the inclusion of Three.js, and before scene.js.

  • In this example, helper.js contains a dictionary where elements are functions. This principle enables the use of the syntax helper.functionName(…​) when called, which imitates the principle of library namespace (avoids function name conflicts from different libraries). [6]

  • In the file scene.js the function load3DObjects is called before its definition. This is possible in JavaScript, all functions from the same file are parsed by the engine before executing the code.

// call of the function
functionName(parameters...);


...


// definition of the function
function functionName(parameters...) {
    ... definition of the function ...
}

Exercise

Modify your code (either in the one or multiple file) to obtain the following result

Hints

  • The cylinders have only been rotated with respect to their initial position.

  • Don’t forget that parameters about cylinder constructor are documented on the Three.js website.

  • Don’t forget that only one side of the object is displayed by default.


1. Note that, depending on the code size and genericity (remember that functions may receive an arbitrary number of parameters), more functions could have been defined, ex. to initialize each element, or create each shape.
2. Three.js primitives are defined in some axis system, possibly centered around the origin. You may need to test and understand their initial orientation before applying rotation to them
3. ex. we don’t know which parameter is used or modified by a function looking at its header
4. typically in forgetting the name of global variables, and redefining them locally
5. Otherwise, the code is executed (hard to debug), but the constructor is called as a regular functions, without creating a new object, which is probably not what you expect
6. In the case where the functions are simply copied, they can be called as if they were defined in the same file.