The code below is for an interactive 3D animation built with javascript, using the three.js Library and the Web Audio API. An accompanying blog post and link to the demo itself can be found at http://inmosystems.com/blog/surrogate-rings-webgl-three-js
////////////////////////////////////////////////////////////////////////////////
// Setup Audio
////////////////////////////////////////////////////////////////////////////////
var audioContext;
var sampleUrls = [
'audio/main-loop.mp3', // 0
'audio/glitch-1.mp3', // 1
'audio/glitch-2.mp3', // 2
'audio/glitch-3.mp3', // 3
'audio/glitch-4.mp3', // 4
'audio/glitch-5.mp3', // 5
'audio/bell-a.mp3', // 6
'audio/bell-b.mp3', // 7
'audio/bell-d.mp3', // 8
'audio/bell-e.mp3', // 9
'audio/bell-fSharp.mp3', // 10
'audio/bell-g.mp3' // 11
];
var audioEnabled = false;
function audioInit() {
// Create audio context
try {
audioContext = new (window.AudioContext || window.webAudioContext || window.webkitAudioContext)();
}
catch(e) {
alert('The Web Audio API is not supported in this browser');
}
// Load various sounds using BufferLoader class
audioBufferLoader = new BufferLoader(audioContext, sampleUrls, doneLoadingSounds);
audioBufferLoader.load();
// Once Sounds are all loaded...
function doneLoadingSounds(bufferList) {
// Begin looped playback of main-loop.mp3
playBufferSound(0, true);
// Initiate Animation
animate();
// Remove loading screen
document.querySelector("#loading").style.display = "none";
audioEnabled = true;
}
}
function playBufferSound(soundPos, loop) { // soundPos = position of sound in loaded buffer array
// create and configure node
var audioSourceNode = audioContext.createBufferSource();
audioSourceNode.buffer = audioBufferLoader.bufferList[soundPos];
audioSourceNode.connect(audioContext.destination);
if (loop == true) audioSourceNode.loop = true;
audioSourceNode.start(0);
}
// BufferLoader Class
function BufferLoader(context, urlList, callback) {
this.context = context;
this.urlList = urlList;
this.onload = callback;
this.bufferList = new Array();
this.loadCount = 0;
}
BufferLoader.prototype.loadBuffer = function(url, index) {
// Load buffer asynchronously
var request = new XMLHttpRequest();
request.open("GET", url, true);
request.responseType = "arraybuffer";
var loader = this;
request.onload = function() {
// Asynchronously decode the audio file data in request.response
loader.context.decodeAudioData(
request.response,
function(buffer) {
if (!buffer) {
alert('error decoding file data: ' + url);
return;
}
loader.bufferList[index] = buffer;
if (++loader.loadCount == loader.urlList.length)
loader.onload(loader.bufferList);
},
function(error) {
console.error('decodeAudioData error', error);
}
);
}
request.onerror = function() {
alert('BufferLoader: XHR error');
}
request.send();
}
BufferLoader.prototype.load = function() {
for (var i = 0; i < this.urlList.length; ++i)
this.loadBuffer(this.urlList[i], i);
}
////////////////////////////////////////////////////////////////////////////////
// Setup Basic Scene
////////////////////////////////////////////////////////////////////////////////
var camera, scene, renderer, composer;
var windowScale;
var clock = new THREE.Clock();
var oContainer = document.querySelector('#outerContainer');
var width = oContainer.offsetWidth;
var height = oContainer.offsetHeight;
// Create the scene
scene = new THREE.Scene();
// Create an ambient light
var light = new THREE.AmbientLight(0x777777);
scene.add( light );
// Create a directional light
var directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(400, 0, 800);
scene.add(directionalLight);
// Create a camera
camera = new THREE.PerspectiveCamera(75, width / height, 1, 1000);
camera.position.z = 270;
// Create the renderer
renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 1);
document.querySelector('#outerContainer').appendChild(renderer.domElement);
// Window re-size
function onWindowResize() {
width = oContainer.offsetWidth;
height = oContainer.offsetHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
//console.log(window.innerWidth + " | " + window.innerHeight + " -|- " + width + " | " + height);
}
window.addEventListener('resize', onWindowResize, false);
////////////////////////////////////////////////////////////////////////////////
// Create & Add Objects
////////////////////////////////////////////////////////////////////////////////
// Create a gradient coloured plane with strobe effect for the background,
// black at the top with two alternating (strobing) tones at the bottom.
var geometry = new THREE.PlaneGeometry(5000, 2500, 0, 0);
var wallGradientTop = new THREE.Color(0x000000);
var wallGradientBottom1 = new THREE.Color(0x00333b);
var wallGradientBottom2 = new THREE.Color(0x002e37);
var wallGradientSwitch = wallGradientBottom1;
geometry.faces[0].vertexColors = [wallGradientTop, wallGradientBottom1, wallGradientTop];
geometry.faces[1].vertexColors = [wallGradientBottom1, wallGradientBottom1, wallGradientTop];
var wallStrobeClock = 0.0; // initialize to 0.0
var wallStrobeRate = 0.03; // strobe interval in seconds
var material = new THREE.MeshBasicMaterial({color: 0xffffff, vertexColors: THREE.VertexColors});
var wall = new THREE.Mesh(geometry, material);
wall.position.z = -500;
scene.add(wall);
// Create basic cube geometry and materials
var geometry = new THREE.BoxGeometry(20,20,20,0,0);
var cubeTexture = THREE.ImageUtils.loadTexture('img/cubeface.png');
var cubeMaterial = new THREE.MeshLambertMaterial({map: cubeTexture});
// Uncomment the line below for alternate base cube material with specular effect
// var cubeMaterial = new THREE.MeshPhongMaterial({color: 0xCCCCCC, map: cubeTexture, specular: 0x19797e});
var cubeTextureActive = THREE.ImageUtils.loadTexture('img/cubeface2-white.png');
var cubeMaterialActive = new THREE.MeshLambertMaterial({map: cubeTextureActive});
var cubePulseTexture = new THREE.ImageUtils.loadTexture('img/cubefacePulseSprite.png');
var cubePulseAnimator = new TextureAnimator( cubePulseTexture, 50, 1, 50, 30 ); // texture, #horiz, #vert, #total, duration.
var cubePulseMaterial = new THREE.MeshBasicMaterial({ map: cubePulseTexture});
// Create Geometry
var allCubes = [];
surrogateRings = new THREE.Object3D();
var ring = [];
var ringSleeve = [];
for (var i=0; i<10; i++) {
ring[i] = new THREE.Object3D();
// Build and position a single ring.
buildRing(ring[i]);
ring[i].rotation.x = 90*Math.PI/180;
ring[i].rotation.y = -30*Math.PI/180;
ring[i].position.y = 100;
// Position each ring instance in a circle formation
ringSleeve[i] = new THREE.Object3D();
ringSleeve[i].add(ring[i]);
ringSleeve[i].rotation.z = i*(360/10)*Math.PI/180;
// Collect everything in a final object
surrogateRings.add(ringSleeve[i]);
}
// Add final parent object to scene
scene.add(surrogateRings);
// Helper function: Builds a single ring of cubes
function buildRing(currentRing) {
var cube = [];
var cubeSleeve = [];
var cubeCount = 12; // Reducing cubeCount reduces number of cubes in each ring
var cubeDist = cubeCount; // Can be used to modify distribution of cubes in each ring, try commented out line below...
// var cubeCount = 7; var cubeDist = 26;
for (var i=0; i < cubeCount; i++) {
cube[i] = new THREE.Mesh(geometry, cubeMaterial);
cube[i].position.y = 60;
cubeSleeve[i] = new THREE.Object3D();
cubeSleeve[i].add(cube[i]);
cubeSleeve[i].rotation.z = i*(360/cubeDist)*Math.PI/180;
currentRing.add(cubeSleeve[i]);
allCubes.push(cube[i]);
}
}
// Helper function: TextureAnimator (by Lee Stemkoski)... https://stemkoski.github.io/Three.js/Texture-Animation.html
function TextureAnimator(texture, tilesHoriz, tilesVert, numTiles, tileDispDuration)
{
// note: texture passed by reference, will be updated by the update function.
this.tilesHorizontal = tilesHoriz;
this.tilesVertical = tilesVert;
// how many images does this spritesheet contain?
// usually equals tilesHoriz * tilesVert, but not necessarily,
// if there at blank tiles at the bottom of the spritesheet.
this.numberOfTiles = numTiles;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set( 1 / this.tilesHorizontal, 1 / this.tilesVertical );
// how long should each image be displayed?
this.tileDisplayDuration = tileDispDuration;
// how long has the current image been displayed?
this.currentDisplayTime = 0;
// which image is currently being displayed?
this.currentTile = 0;
this.update = function( milliSec )
{
this.currentDisplayTime += milliSec;
while (this.currentDisplayTime > this.tileDisplayDuration)
{
this.currentDisplayTime -= this.tileDisplayDuration;
this.currentTile++;
if (this.currentTile == this.numberOfTiles)
this.currentTile = 0;
var currentColumn = this.currentTile % this.tilesHorizontal;
texture.offset.x = currentColumn / this.tilesHorizontal;
var currentRow = Math.floor( this.currentTile / this.tilesHorizontal );
texture.offset.y = currentRow / this.tilesVertical;
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Post-Processing Shaders
////////////////////////////////////////////////////////////////////////////////
composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
var glitchEffect = new THREE.ShaderPass(THREE.DigitalGlitch);
glitchEffect.uniforms[ 'byp' ].value=1;
composer.addPass(glitchEffect);
var filmEffect = new THREE.ShaderPass(THREE.FilmShader);
filmEffect.uniforms[ 'grayscale' ].value = 0;
filmEffect.uniforms[ 'nIntensity' ].value = 0.4;
filmEffect.uniforms[ 'sIntensity' ].value = 0.5;
filmEffect.uniforms[ 'sCount' ].value = 1300;
composer.addPass(filmEffect);
var vignetteEffect = new THREE.ShaderPass(THREE.VignetteShader);
vignetteEffect.uniforms[ 'offset' ].value = 1.1;
vignetteEffect.renderToScreen = true;
composer.addPass(vignetteEffect);
////////////////////////////////////////////////////////////////////////////////
// Glitch Events
////////////////////////////////////////////////////////////////////////////////
var glitchActive, glitchNum, glitchTimeOut;
var glitchClock = 0.0;
var glitchInterval = 4.0;
var glitchVals = [
{
amount: 45, // Used by renderGlitchFrame() as: Math.random()/amount
seed_xy: 0.8, // Used by renderGlitchFrame() as: THREE.Math.randFloat(-seed_xy,seed_xy)
duration: 230 // Milliseconds
},
{
amount: 100,
seed_xy: 0.6,
duration: 300
},
{
amount: 60,
seed_xy: 0.3,
duration: 400
},
{
amount: 90,
seed_xy: 1,
duration: 150
},
{
amount: 90,
seed_xy: 1,
duration: 640
}
];
function triggerGlitchEvent() {
glitchActive = true;
playBufferSound(glitchNum+1);
clearTimeout(glitchTimeOut);
glitchTimeOut = setTimeout(function() {
glitchEffect.uniforms[ 'byp' ].value = 1;
glitchActive = false;
}, glitchVals[glitchNum].duration);
}
function renderGlitchFrame() {
glitchEffect.uniforms[ 'byp' ].value = 0; // disable bypass
glitchEffect.uniforms[ 'seed' ].value = Math.random();
glitchEffect.uniforms[ 'amount' ].value = Math.random()/glitchVals[glitchNum].amount;
glitchEffect.uniforms[ 'angle' ].value = THREE.Math.randFloat(-Math.PI,Math.PI);
glitchEffect.uniforms[ 'distortion_x' ].value = THREE.Math.randFloat(0,1);
glitchEffect.uniforms[ 'distortion_y' ].value = THREE.Math.randFloat(0,1);
glitchEffect.uniforms[ 'seed_x' ].value = THREE.Math.randFloat(-glitchVals[glitchNum].seed_xy,glitchVals[glitchNum].seed_xy);
glitchEffect.uniforms[ 'seed_y' ].value = THREE.Math.randFloat(-glitchVals[glitchNum].seed_xy,glitchVals[glitchNum].seed_xy);
}
////////////////////////////////////////////////////////////////////////////////
// Object Picking
////////////////////////////////////////////////////////////////////////////////
var pickVector = new THREE.Vector3();
var raycaster = new THREE.Raycaster();
var textureTimeouts ={};
function objectPick(event) {
// check first if audio is initialised.
if (audioEnabled == false)
return;
pickVector.set(
((event.clientX || event.touches[0].pageX) / width) * 2 - 1, // x
- ((event.clientY || event.touches[0].pageY) / height) * 2 + 1, // y
0.5 // z = 0.5 important!
);
pickVector.unproject(camera);
raycaster.set(camera.position, pickVector.sub( camera.position ).normalize());
var intersects = raycaster.intersectObjects( surrogateRings.children, true );
if (intersects.length > 0) {
var pickedID = intersects[0].object.id.toString();
clearTimeout(textureTimeouts[pickedID]);
textureTimeouts[pickedID] = setTimeout(function() {
intersects[0].object.material = cubePulseMaterial;
}, 2500);
intersects[0].object.material = cubeMaterialActive;
playBufferSound(Math.floor(Math.random() * (12 - 6)) + 6);
}
}
document.addEventListener( 'click', objectPick, false );
//document.addEventListener( 'touchstart', objectPick, false ); ...was causing double cube selects on touch screen devices.
////////////////////////////////////////////////////////////////////////////////
// Animate
////////////////////////////////////////////////////////////////////////////////
// Animate
function animate() {
requestAnimationFrame(animate);
update();
render();
}
// Update loop
function update() {
var delta = clock.getDelta();
// BG strobe effect
wallStrobeClock += delta;
if (wallStrobeClock > wallStrobeRate) {
wallStrobeClock = 0.0;
wallGradientSwitch = (wallGradientSwitch == wallGradientBottom1) ? wallGradientBottom2 : wallGradientBottom1;
wall.geometry.faces[0].vertexColors = [wallGradientTop, wallGradientSwitch, wallGradientTop];
wall.geometry.faces[1].vertexColors = [wallGradientSwitch, wallGradientSwitch, wallGradientTop];
wall.geometry.colorsNeedUpdate = true;
}
// Rotate Rings
for (var i=0; i glitchInterval) {
glitchClock = 0.0;
glitchNum = Math.floor(Math.random()*glitchVals.length);
triggerGlitchEvent();
}
if (glitchActive) renderGlitchFrame();
}
// Render loop
function render() {
// Render scene using Effect Composer
composer.render();
}
// Initial Play button activated Bootstrap, accomodates iOS requirement for user interaction before audio can be initialized.
document.getElementById("clickToPlay").addEventListener("click", play, false);
function play() {
document.getElementById("clickToPlay").style.display = "none";
audioInit();
}
////////////////////////////////////////////////////////////////////////////////
// Debug Triggers: Glitch Effect & Random Cube Picking
////////////////////////////////////////////////////////////////////////////////
// Number keys 1-5 manually trigger Glitch events
// [Shift] + R key picks a random cube for texture activation
document.onkeypress = function(e) {
var glitchKeyStroke;
var activateRandomCubeKeyStroke;
var clearAllCubesKeyStroke;
switch (e.keyCode) {
case 49:
glitchNum = 0;
glitchKeyStroke = true;
break;
case 50:
glitchNum = 1;
glitchKeyStroke = true;
break;
case 51:
glitchNum = 2;
glitchKeyStroke = true;
break;
case 52:
glitchNum = 3;
glitchKeyStroke = true;
break;
case 53:
glitchNum = 4;
glitchKeyStroke = true;
break;
case 82:
activateRandomCubeKeyStroke = true;
break;
}
if (glitchKeyStroke) {
glitchActive = true;
playBufferSound(glitchNum+1);
clearTimeout(glitchTimeOut);
glitchTimeOut = setTimeout(function() {
glitchEffect.uniforms[ 'byp' ].value = 1;
glitchActive = false;
}, glitchVals[glitchNum].duration);
}
if (activateRandomCubeKeyStroke) {
allCubes[Math.floor(Math.random() * allCubes.length)].material = cubePulseMaterial;
// play random bell sound
playBufferSound(Math.floor(Math.random() * 6) + 6);
}
};