This fifth workshop (in week six) is aimed at helping you understand how we can undertake a few different tasks to useful to us while designing our projects. So, we will:
It’s little bit silly to have a flamingo and a dancing robot, but here’s what we’re going to be building in the first two tasks to show you how to load models and how to drive movements with both pre-defined animations and with real-time sound properties:
Where code is provided, you are expected to write it out yourself rather than copying and pasting it. We know this is tempting, but the point of a university education is for you to learn the necessary skills for your field, and copying and pasting code will not be on any job descriptions.
Right, we’re going to use a starter project again this week. So please fork the project below. it’s basically just our normal template but we’ve added some models for you to work with. Take a look around the project linked below and fork it so that you can start working on it. You can see at the top of index.js that we have already imported our GLTF loader that we’re going to use to load the models in our models folder.
OK, as usual let’s get going by adding some global variables towards the top of our index.js file:
let modelLoaded;
let robot, flamingo;
let loader;
let mixers;
Now, in init() just below where we remove the overlay, let’s start our Tone js instance and initialise our modelLoaded boolean to false. This will be set to true once our models have been loaded and we can then use them in the rest of our code.
Tone.start();
modelLoaded = false;
OK let’s initialise mixers to an array and make a call to loadModels function. The mixers array will be an array that will hold our model’s animations. The main bulk of our code is now going to be in the loadModels function. You will be getting an error now sayin that loadModels()
is not defined. So let’s move onto the next step.
mixers = [];
loadModels();
Now, at the very bottom of our index.js file, let’s define our loadModels function to instantiate our new gltfLoader:
function loadModels()
{
loader = new GLTFLoader();
}
We’re going to continue working in our loadModels function and define two callbacks that will process our models once they are loaded by the GLTFLoader.
We’re going to give you the first callback for loading models and animations, then we’d like you to have a go at a simpler callback for loading models without animations.
There’s quite a bit of code in this onLoadAnimation
function, so comments are provided to explain what is going on:
// this callback handles loading a flamingo GLTF model with animation data
const onLoadAnimation = function (gltf, position) {
flamingo = gltf.scene.children[0]; // look for the first child of the scene contained in the gltf - this is our flamingo model
flamingo.scale.multiplyScalar(0.125); // scale our model to make it smaller
flamingo.position.copy(position); // set the desired position
const animation = gltf.animations[0]; // get animation data from the gltf file and assign it to a varible called animation
const mixer = new THREE.AnimationMixer(flamingo); //create a new ThreeJS animation mixer and pass our flamingo model to it
mixers.push(mixer); // add our animation mixer to our mixers array
const action = mixer.clipAction(animation); // pass the animation data to the animation scheduler in the animation mixer
action.play(); // start the animation
scene.add(flamingo); // add our animated flamingo model to our scene
};
Adding this code will not (yet!) display our flamingo - since we haven’t yet added code to import the gltf file… Read on, to find out how to handle static (robot) models, very soon we’ll be able to see the fruit of our labour. 😊
Ok, now you’ve seen how to load models and their animations. Can you try and write your own callback called onLoadStatic
that loads the static (i.e no animation data) robot GLTF model?:
robot
1.125
to make it a bit bigger (similar size to the birds)modelLoaded
boolean flag to “true”Be sure you write onLoadStatic
as a const function declaration inside loadModels
(just like the onLoadAnimation
above).
HINT All of the functionality described above, apart from the setting of the modelLoaded
flag can be adapted from the onLoadAnimation
callback function definition.
const onLoadStatic = function (gltf, position)
{
robot = gltf.scene.children[0]; // assign the first child of the scene contained in the gltf file to a variable called robot
robot.scale.multiplyScalar(1.125);
robot.position.copy(position); //copy the position passed from the load function call
modelLoaded = true; // once our model has loaded, set our modelLoaded boolean flag to true
scene.add(robot); // add our model to the scene
};
Let’s add a couple more const callback functions that will help tell us that the models are loading and if there are any errors do this in loadModels
right next to where we have just added onLoadAnimation
and onLoadStatic
:
// the loader will report the loading progress to this function
const onProgress = function ()
{
console.log("progress");
};
// the loader will send any error messages to this function
const onError = function (errorMessage)
{
console.log(errorMessage);
};
And now beneath all of these callback functions, but still in the loadModels function, let’s create some initial positions for our models, then actually invoke the loader.
This code below will set up the animated flamingo loader with all the associated callbacks, but it’s up to you to have a go at the robot:
// desired position of our flamingo
const flamingoPosition = new THREE.Vector3(-7.5, 0, -10);
// load the GLTF file with all required callback functions
loader.load(
"models/Flamingo.glb", // specify our file path
function (gltf) {
// specify the callback function to call once the model has loaded
onLoadAnimation(gltf, flamingoPosition);
},
onProgress, // specify progress callback
onError // specify error callback
);
So, now you’ve made the flamingo load, you need to write code to load the robot model and put it at a position of 0, 0, 0 i.e right in the middle of our scene. You will need a variable named robotPos
and should call the onLoadStatic
callback.
Hopefully this should have been smooth sailing for you. Do ask for help if you need it.
const robotPos = new THREE.Vector3(0, 0, 0);
loader.load(
"models/robot.gltf", // robot file path
function (gltf) {
onLoadStatic(gltf, robotPos); // remember the robot model has no animation
},
onProgress, // specify progress callback
onError // specify error callback
);
You may notice that our flamingo is a .glb file and the robot is .gltf. They’re essentially just different ways of formatting model data. Take a look here to see the difference.
function loadModels()
{
loader = new GLTFLoader();
// this function loads a flamingo model with animation data
// note we're passing in a position parameter
const onLoadAnimation = function (gltf, position) {
flamingo = gltf.scene.children[0]; // assign the first child of the scene contained in the gltf file to a variable called flamingo
flamingo.scale.multiplyScalar(0.125); // scale our model to make it smaller
flamingo.position.copy(position); //copy the position passed from the load function call
const animation = gltf.animations[0]; // get the animation
const mixer = new THREE.AnimationMixer(flamingo); //create a new animation mixer and assign pass our new model to it
mixers.push(mixer); // add our animation mixer to our mixers array
const action = mixer.clipAction(animation); // pass the animation to the animation scheduler in the animation mixer
action.play(); // start the animation scheduling
scene.add(flamingo); // add our model to our scene
};
// this function loads a robot model without animation
const onLoadStatic = function (gltf, position) {
robot = gltf.scene.children[0]; // assign the first child of the scene contained in the gltf file to a variable called robot
robot.scale.multiplyScalar(1.125);
robot.position.copy(position); //copy the position passed from the load function call
modelLoaded = true; // once our model has loaded, set our modelLoaded boolean flag to true
scene.add(robot); // add our model to the scene
};
// the loader will report the loading progress to this function
const onProgress = function () {
console.log("progress");
};
// the loader will send any error messages to this function, and we'll log
// them to to console
const onError = function (errorMessage) {
console.log(errorMessage);
};
const flamingoPosition = new THREE.Vector3(-7.5, 0, -10); // create new vector for the position of our flamingo
loader.load(
// call the loader's load function
"models/Flamingo.glb", // specify our file path
function (gltf) {
// specify the callback function to call once the model has loaded
onLoadAnimation(gltf, flamingoPosition);
},
onProgress, // specify progress callback
onError // specify error callback
);
const robotPos = new THREE.Vector3(0, 0, 0);
loader.load(
// call the loader's load function
"models/robot.gltf", // specify our file path
function (gltf) {
// specify the callback function to call once the model has loaded
onLoadStatic(gltf, robotPos);
},
onProgress, // specify progress callback
onError // specify error callback
);
}
Right, hopefully you can now see some models in your scene! But please do ask a tutor for some assistance if you can’t see anything!
The only thing is, our flamingo doesn’t seem to be doing anything. So now we need to actually animate it.
We just need to update our animation driving it with our delta variable. Add the following lines INSIDE the time dependent “if statement” in our update function:
for (let i = 0; i < mixers.length; i++) {
mixers[i].update(delta);
}
// our update function
function update()
{
orbit.update();
//update stuff in here
delta += clock.getDelta();
if (delta > interval)
{
// The draw or time dependent code are here
// iterate through animation mixers
for (let i = 0; i < mixers.length; i++)
{
mixers[i].update(delta);
}
delta = delta % interval;
}
}
Now you should be able to see your flamingo flapping in a nice smooth animation!
Right, we’re not going to show you exactly how to do this bit. We’re starting to take the training wheels off. But here is what needs to happen to make the robot dance back and forth to the beat.
Remember you can always check the code in the example at the very top of this page to see a worked solution - but do have a go at working thru the process of figuring it out first
We need to take the level of our audio signal coming from a Tone.Player and apply it to the z position of our robot model.
In our init() function, we will need to do the following:
player
at the top of index.js so we can assign it later. We’ve provided you with a couple of sound files in the project sounds/
folder, but you can obviously use your own if they are small .mp3s. Examine the example at the very top of this page to find out how to create a new Tone.Player()
..toDestination()
method.0.8
. Remember, we access the meter’s properties by using the dot (.) operator..connect()
method, the syntax for this can be found in the first example on the webpage linked above from Tone.js documentation.Now, in our time controlled code inside our update()
function i.e inside if (delta > interval)
, we will need to do the following:
meter.getValue()
and store the metered value.mapLinear
lower and upper input -60 and -12.mapLinear
lower and upper output to 0.0 and 4.0.Now we’re going to try a new little project project, it’s a sphere synthesiser that plays a note and moves the sphere with the mouse. The note changes when the mouse is moved on the x axis and the volume of the synth is mapped to the y position of the mouse.
For this task, please use the starter sandbox below:
OK, once again, let’s get going by adding some global variables towards the top of our index.js file:
let geometry, material;
let planeGeometry, planeMaterial;
let planet, plane;
let mouseDown;
let raycaster, mouse, intersects;
let synth, synthNotes;
let effect, reverb;
Just below where we remove the overlay in our init() function, let’s initialise some of those variable. First we’ll start tone; then initialise our mouseDown boolean flag to false, then we’ll may a delay effect and a reverb effect.
Tone.start(); // ensure Tone starts and that audio will be processed
mouseDown = false; // initialise mouse down to be false
effect = new Tone.FeedbackDelay().toDestination(); // create a delay effect and connect it to the master output
reverb = new Tone.Reverb({
// connect a reverb effect and connect it to the master output
decay: 2, // decay time of 2 seconds.
wet: 1.0, // fully wet signal
preDelay: 0.25 // pre-delay time of 0.25 seconds
}).toDestination();
The rest of our main audio stuff can now be added directly below. We’re going to create an array with some musical note data; then we’ll make a synth that has a bit of glide. Finally we’ll connect the synth to the effects.
synthNotes = [
// create an array with some choice notes in it
"C2",
"E2",
"G2",
"A2",
"C3",
"D3",
"E3",
"G3",
"A3",
"B3",
"C4",
"D4",
"E4",
"G4",
"A4",
"B4",
"C5"
];
synth = new Tone.MonoSynth().toDestination(); // create an instance of a monosynth and connect it to the master output
synth.set({
// set some default settings
portamento: 0.1, // a bit of glide
volume: -10, // reduce the level by 10dB
oscillator: {
// set the oscillator type to sawtooth
type: "sawtooth"
},
envelope: {
// set the envelope settings
attack: 0.005,
release: 2.0,
sustain: 0.5
}
});
synth.connect(effect); //connect the synth to the delay
synth.connect(reverb); //connect the synth to the reverb
OK great, now, just below where we add the lights to our scene in init() let’s initialise the stuff we need for our mouse interactions. They are a Raycaster, the intersects array and a mouse position vector.
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
intersects = [];
OK our visual aids in this scene are going to be quite simple. We’re going to add a ground plane and a planet that we will use as our synthesiser controller.
planeGeometry = new THREE.PlaneGeometry(7, 7);
planeMaterial = new THREE.MeshPhongMaterial({
color: 0x919191,
side: THREE.DoubleSide
});
plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.position.set(0, -0.5001, 0);
plane.receiveShadow = true;
plane.rotation.set(Math.PI / 2, 0, 0); // rotate around the x axis to become flat
plane.shadowColor = 0xffffff;
scene.add(plane);
geometry = new THREE.SphereGeometry(1.5, 32, 32);
material = new THREE.MeshPhongMaterial({
color: 0xffffff,
wireframe: false
});
planet = new THREE.Mesh(geometry, material);
scene.add(planet);
planet.castShadow = true;
Now, towards the bottom of the init() function, just above where we added our onWindowResize event listener, let’s add some new event listeners. These will listen for mouse events. We’re using the “pointer” terminology here just to avoid any possible conflicts with our OrbitControls object.
// add our event listeners for pointer as opposed to mouse as this stops conflicts with OrbitControls
window.addEventListener("pointermove", move, false);
window.addEventListener("pointerdown", triggerAttack, false);
window.addEventListener("pointerup", triggerRelease, false);
OK, you should now be seeing an error that the event handler functions don’t exit. Let’s start to define them at at the bottom of our index.js file, below the onWindowResize function.
First of all, let’s handle when the mouse point is down. Just to test, let’s use console.log statements to make sure they’re working:
function triggerAttack(event)
{
console.log("down");
}
function move(event)
{
}
function triggerRelease()
{
console.log("up");
}
Assuming that’s working, let’s put the rest of the code in. For triggerAttack
. Again, there’s a fair bit going on so please read the comments for explanations
function triggerAttack(event) {
console.log("down");
raycaster.setFromCamera(mouse, camera); // create our ray
intersects = raycaster.intersectObject(planet); // test whether our ray is intersecting with our planet object
if (intersects.length > 0) {
//if there is something in our array
mouseDown = true; // set mouseDown boolean flag to true
}
const note =
synthNotes[
Math.round((event.clientX / sceneWidth) * (synthNotes.length - 1)) // constrain our mouseX position using Math.round to create an integer index that can be used to pick a note from our note array
];
if (mouseDown) {
// Make the sphere follow the mouse
let vector = new THREE.Vector3(mouse.x, mouse.y, 0.5); // create a new 3D vector using our mouse position
vector.unproject(camera); // project mouse vector into world space using camera's normalised device coordinate space
let dir = vector.sub(camera.position).normalize(); // Create a direction vector based on subtracting our camera's position from our mouse position
let distance = -camera.position.z / dir.z; // derive distance from the negative z position of the camera divided by our direction's z position
let pos = camera.position.clone().add(dir.multiplyScalar(distance)); //create a new position based on adding the direction vector scaled by the distance vector, to the camera's position vector
planet.position.copy(pos); // copy our new position into the planet's position vector
synth.triggerAttack(note); // trigger the envelope on the synthesiser
planet.material.color.setHex(0xff00ff); // change the colour of our planet to purpley pink
}
}
Our move
function is pretty similar to the triggerAttack
one.
The main difference now being that instead of triggering the envelope on the synth, we’re updating the note and the volume based on the mouse position
We do this ONLY if the mouse is down, since we don’t want to hear sound when the mouse is moving around before clicking.
function move(event) {
mouse.x = (event.clientX / sceneWidth) * 2 - 1; // convert our mouse x position to be a value between -1.0 and 1.0
mouse.y = -(event.clientY / sceneHeight) * 2 + 1; // convert our mouse y position to be a value between -1.0 and 1.0
const note =
synthNotes[
Math.round((event.clientX / sceneWidth) * (synthNotes.length - 1))
]; // constrain our mouseX position using Math.round to create an integer index that can be used to pick a note from our note array
if (mouseDown) {
// Make the sphere follow the mouse
let vector = new THREE.Vector3(mouse.x, mouse.y, 0.5); // create a new 3D vector using our mouse position
vector.unproject(camera); // project mouse vector into world space using camera's normalised device coordinate space
let dir = vector.sub(camera.position).normalize(); // Create a direction vector based on subtracting our camera's position from our mouse position
let distance = -camera.position.z / dir.z; // derive distance from the negative z position of the camera divided by our direction's z position
let pos = camera.position.clone().add(dir.multiplyScalar(distance)); //create a new position based on adding the direction vector scaled by the distance vector, to the camera's position vector
planet.position.copy(pos); // copy our new position into the planet's position vector
synth.setNote(note); // update our synthesiser's note
let volume = THREE.MathUtils.mapLinear(
// map the y position to volume of the synth. Output range is in decibels
mouse.y, //input value
-1.0, // lower input range
1.0, // upper input range
-60, // lower output range
-3 // upper output range
);
synth.volume.linearRampTo(volume, 0.01); // set new volume level with a ramp of 0.01 seconds
}
}
Our final function now simply releases the envelope and returns the planet to the default grey colour.
function triggerRelease() {
mouseDown = false; // set mouseDown flag to false
console.log("up");
synth.triggerRelease(Tone.now()); // trigger the release phase of our synth's envelope
planet.material.color.setHex(0x919191); // return planet's colour to it grey
}
OK so hopefully now you have made a couple of little fun projects this week! Here are some stretch goals:
Nice one for making both little projects! 😀
Super important task: go to file->export to .zip
in your codesandbox and download your project as a zip.
Remember, files in the cloud are just files on someone elses computer. It is critical to keep a good backup.