Check out the tutorial here!
A Pen by Kyle Wetton on CodePen.
Check out the tutorial here!
A Pen by Kyle Wetton on CodePen.
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<!-- Imporant meta information to make the page as rigid as possible on mobiles, to avoid unintentional zooming on the page itself --> | |
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | |
<title>Chair Tutorial</title> | |
<link href="https://fonts.googleapis.com/css?family=Raleway:500,700&display=swap" rel="stylesheet"> | |
<!-- We add the loader CSS here so that assets don't pop in before the bundled CSS is loaded --> | |
<style> | |
.loading { | |
position: fixed; | |
z-index: 50; | |
width: 100%; | |
height: 100%; | |
top: 0; left: 0; | |
background: #f1f1f1; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
.loader{ | |
-webkit-perspective: 120px; | |
-moz-perspective: 120px; | |
-ms-perspective: 120px; | |
perspective: 120px; | |
width: 100px; | |
height: 100px; | |
} | |
.loader:before{ | |
content: ""; | |
position: absolute; | |
left: 25px; | |
top: 25px; | |
width: 50px; | |
height: 50px; | |
background-color: #ff0000; | |
animation: flip 1s infinite; | |
} | |
@keyframes flip { | |
0% { | |
transform: rotate(0); | |
} | |
50% { | |
transform: rotateY(180deg); | |
} | |
100% { | |
transform: rotateY(180deg) rotateX(180deg); | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<!-- The loading element overlays all else until the model is loaded, at which point we remove this element from the DOM --> | |
<div class="loading" id="js-loader"><div class="loader"></div></div> | |
<!-- These toggle the the different parts of the chair that can be edited, note data-option is the key that links to the name of the part in the 3D file --> | |
<div class="options"> | |
<div class="option --is-active" data-option="legs"> | |
<img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/legs.svg" alt=""/> | |
</div> | |
<div class="option" data-option="cushions"> | |
<img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/cushions.svg" alt=""/> | |
</div> | |
<div class="option" data-option="base"> | |
<img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/base.svg" alt=""/> | |
</div> | |
<div class="option" data-option="supports"> | |
<img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/supports.svg" alt=""/> | |
</div> | |
<div class="option" data-option="back"> | |
<img src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/back.svg" alt=""/> | |
</div> | |
</div> | |
<!-- Just a quick notice to the user that it can be interacted with --> | |
<span class="drag-notice" id="js-drag-notice">Drag to rotate 360°</span> | |
<!-- The canvas element is used to draw the 3D scene --> | |
<canvas id="c"></canvas> | |
<!-- This section currently only shows a message. Named controls as I encourage you once completing this tutorial to add the ability to filter which textures and colours are allowed on which parts ( you can't really have wood cushions can you? ) --> | |
<div class="controls"> | |
<div class="info"> | |
<div class="info__message"> | |
<p><strong> Grab </strong> to rotate chair. <strong> Scroll </strong> to zoom. <strong> Drag </strong> swatches to view more.</p> | |
</div> | |
</div> | |
<!-- This tray will be filled with colors via JS, and the ability to slide this panel will be added in with a lightweight slider script (no dependency used for this) --> | |
<div id="js-tray" class="tray"> | |
<div id="js-tray-slide" class="tray__slide"></div> | |
</div> | |
</div> | |
<!-- The main three.js file --> | |
<script src='https://unpkg.com/three@0.127.0/build/three.js'></script> | |
<!-- This brings in the ability to load custom 3D objects in the .gltf file format. Blender allows the ability to export to this format out the box --> | |
<script src='https://cdn.jsdelivr.net/gh/mrdoob/three.js@r92/examples/js/loaders/GLTFLoader.js'></script> | |
<!-- This is a simple to use extension for three.js that activates all the rotating, dragging and zooming controls we need for both mouse and touch, there isn't a clear CDN for this that I can find --> | |
<script src='https://threejs.org/examples/js/controls/OrbitControls.js'></script> | |
</body> | |
</html> |
const LOADER = document.getElementById('js-loader'); | |
const DRAG_NOTICE = document.getElementById('js-drag-notice'); | |
const TRAY = document.getElementById('js-tray-slide'); | |
var theModel; | |
const MODEL_PATH = "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/chair.glb"; | |
var loaded = false; | |
var cameraFar = 5; | |
var activeOption = 'legs'; | |
const colors = [ | |
{ | |
texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/wood_.jpg', | |
size: [2,2,2], | |
shininess: 60 | |
}, | |
{ | |
texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/fabric_.jpg', | |
size: [4, 4, 4], | |
shininess: 0 | |
}, | |
{ | |
texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/pattern_.jpg', | |
size: [8, 8, 8], | |
shininess: 10 | |
}, | |
{ | |
texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/denim_.jpg', | |
size: [3, 3, 3], | |
shininess: 0 | |
}, | |
{ | |
texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/quilt_.jpg', | |
size: [6, 6, 6], | |
shininess: 0 | |
}, | |
{ | |
color: '131417' | |
}, | |
{ | |
color: '374047' | |
}, | |
{ | |
color: '5f6e78' | |
}, | |
{ | |
color: '7f8a93' | |
}, | |
{ | |
color: '97a1a7' | |
}, | |
{ | |
color: 'acb4b9' | |
}, | |
{ | |
color: 'DF9998', | |
}, | |
{ | |
color: '7C6862' | |
}, | |
{ | |
color: 'A3AB84' | |
}, | |
{ | |
color: 'D6CCB1' | |
}, | |
{ | |
color: 'F8D5C4' | |
}, | |
{ | |
color: 'A3AE99' | |
}, | |
{ | |
color: 'EFF2F2' | |
}, | |
{ | |
color: 'B0C5C1' | |
}, | |
{ | |
color: '8B8C8C' | |
}, | |
{ | |
color: '565F59' | |
}, | |
{ | |
color: 'CB304A' | |
}, | |
{ | |
color: 'FED7C8' | |
}, | |
{ | |
color: 'C7BDBD' | |
}, | |
{ | |
color: '3DCBBE' | |
}, | |
{ | |
color: '264B4F' | |
}, | |
{ | |
color: '389389' | |
}, | |
{ | |
color: '85BEAE' | |
}, | |
{ | |
color: 'F2DABA' | |
}, | |
{ | |
color: 'F2A97F' | |
}, | |
{ | |
color: 'D85F52' | |
}, | |
{ | |
color: 'D92E37' | |
}, | |
{ | |
color: 'FC9736' | |
}, | |
{ | |
color: 'F7BD69' | |
}, | |
{ | |
color: 'A4D09C' | |
}, | |
{ | |
color: '4C8A67' | |
}, | |
{ | |
color: '25608A' | |
}, | |
{ | |
color: '75C8C6' | |
}, | |
{ | |
color: 'F5E4B7' | |
}, | |
{ | |
color: 'E69041' | |
}, | |
{ | |
color: 'E56013' | |
}, | |
{ | |
color: '11101D' | |
}, | |
{ | |
color: '630609' | |
}, | |
{ | |
color: 'C9240E' | |
}, | |
{ | |
color: 'EC4B17' | |
}, | |
{ | |
color: '281A1C' | |
}, | |
{ | |
color: '4F556F' | |
}, | |
{ | |
color: '64739B' | |
}, | |
{ | |
color: 'CDBAC7' | |
}, | |
{ | |
color: '946F43' | |
}, | |
{ | |
color: '66533C' | |
}, | |
{ | |
color: '173A2F' | |
}, | |
{ | |
color: '153944' | |
}, | |
{ | |
color: '27548D' | |
}, | |
{ | |
color: '438AAC' | |
} | |
] | |
const BACKGROUND_COLOR = 0xf1f1f1; | |
// Init the scene | |
const scene = new THREE.Scene(); | |
// Set background | |
scene.background = new THREE.Color(BACKGROUND_COLOR ); | |
scene.fog = new THREE.Fog(BACKGROUND_COLOR, 20, 100); | |
const canvas = document.querySelector('#c'); | |
// Init the renderer | |
const renderer = new THREE.WebGLRenderer({canvas, antialias: true}); | |
renderer.shadowMap.enabled = true; | |
renderer.setPixelRatio(window.devicePixelRatio); | |
document.body.appendChild(renderer.domElement); | |
// Add a camera | |
var camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 1000); | |
camera.position.z = cameraFar; | |
camera.position.x = 0; | |
// Initial material | |
const INITIAL_MTL = new THREE.MeshPhongMaterial( { color: 0xf1f1f1, shininess: 10 } ); | |
const INITIAL_MAP = [ | |
{childID: "back", mtl: INITIAL_MTL}, | |
{childID: "base", mtl: INITIAL_MTL}, | |
{childID: "cushions", mtl: INITIAL_MTL}, | |
{childID: "legs", mtl: INITIAL_MTL}, | |
{childID: "supports", mtl: INITIAL_MTL}, | |
]; | |
// Init the object loader | |
var loader = new THREE.GLTFLoader(); | |
loader.load(MODEL_PATH, function(gltf) { | |
theModel = gltf.scene; | |
theModel.traverse((o) => { | |
if (o.isMesh) { | |
o.castShadow = true; | |
o.receiveShadow = true; | |
} | |
}); | |
// Set the models initial scale | |
theModel.scale.set(2,2,2); | |
theModel.rotation.y = Math.PI; | |
// Add the model to the scene | |
theModel.position.y = -1; | |
// Set initial textures | |
for (let object of INITIAL_MAP) { | |
initColor(theModel, object.childID, object.mtl); | |
} | |
scene.add(theModel); | |
// Remove the loader | |
LOADER.remove(); | |
}, undefined, function(error) { | |
console.error(error) | |
}); | |
// Function - Add the textures to the models | |
function initColor(parent, type, mtl) { | |
parent.traverse((o) => { | |
if (o.isMesh) { | |
if (o.name.includes(type)) { | |
o.material = mtl; | |
o.nameID = type; // Set a new property to identify this object | |
} | |
} | |
}); | |
} | |
// Add lights | |
var hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 0.61 ); | |
hemiLight.position.set( 0, 50, 0 ); | |
// Add hemisphere light to scene | |
scene.add( hemiLight ); | |
var dirLight = new THREE.DirectionalLight( 0xffffff, 0.54 ); | |
dirLight.position.set( -8, 12, 8 ); | |
dirLight.castShadow = true; | |
dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024); | |
// Add directional Light to scene | |
scene.add( dirLight ); | |
// Floor | |
var floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1); | |
var floorMaterial = new THREE.MeshPhongMaterial({ | |
color: 0xeeeeee, // This color is manually dialed in to match the background color | |
shininess: 0 | |
}); | |
var floor = new THREE.Mesh(floorGeometry, floorMaterial); | |
floor.rotation.x = -0.5 * Math.PI; | |
floor.receiveShadow = true; | |
floor.position.y = -1; | |
scene.add(floor); | |
// Add controls | |
var controls = new THREE.OrbitControls( camera, renderer.domElement ); | |
controls.maxPolarAngle = Math.PI / 2; | |
controls.minPolarAngle = Math.PI / 3; | |
controls.enableDamping = true; | |
controls.enablePan = false; | |
controls.dampingFactor = 0.1; | |
controls.autoRotate = false; // Toggle this if you'd like the chair to automatically rotate | |
controls.autoRotateSpeed = 0.2; // 30 | |
function animate() { | |
controls.update(); | |
renderer.render(scene, camera); | |
requestAnimationFrame(animate); | |
if (resizeRendererToDisplaySize(renderer)) { | |
const canvas = renderer.domElement; | |
camera.aspect = canvas.clientWidth / canvas.clientHeight; | |
camera.updateProjectionMatrix(); | |
} | |
if (theModel != null && loaded == false) { | |
initialRotation(); | |
DRAG_NOTICE.classList.add('start'); | |
} | |
} | |
animate(); | |
// Function - New resizing method | |
function resizeRendererToDisplaySize(renderer) { | |
const canvas = renderer.domElement; | |
var width = window.innerWidth; | |
var height = window.innerHeight; | |
var canvasPixelWidth = canvas.width / window.devicePixelRatio; | |
var canvasPixelHeight = canvas.height / window.devicePixelRatio; | |
const needResize = canvasPixelWidth !== width || canvasPixelHeight !== height; | |
if (needResize) { | |
renderer.setSize(width, height, false); | |
} | |
return needResize; | |
} | |
// Disable scrolling .. (??) | |
window.onscroll = function () { window.scrollTo(0, 0); }; | |
// Function - Build Colors | |
function buildColors(colors) { | |
for (let [i, color] of colors.entries()) { | |
let swatch = document.createElement('div'); | |
swatch.classList.add('tray__swatch'); | |
if (color.texture) | |
{ | |
swatch.style.backgroundImage = "url(" + color.texture + ")"; | |
} else | |
{ | |
swatch.style.background = "#" + color.color; | |
} | |
swatch.setAttribute('data-key', i); | |
TRAY.append(swatch); | |
} | |
} | |
buildColors(colors); | |
// Select Option | |
const options = document.querySelectorAll(".option"); | |
for (const option of options) { | |
option.addEventListener('click',selectOption); | |
} | |
function selectOption(e) { | |
let option = e.target; | |
activeOption = e.target.dataset.option; | |
for (const otherOption of options) { | |
otherOption.classList.remove('--is-active'); | |
} | |
option.classList.add('--is-active'); | |
} | |
// Swatches | |
const swatches = document.querySelectorAll(".tray__swatch"); | |
for (const swatch of swatches) { | |
swatch.addEventListener('click', selectSwatch); | |
} | |
function selectSwatch(e) { | |
let color = colors[parseInt(e.target.dataset.key)]; | |
let new_mtl; | |
if (color.texture) { | |
let txt = new THREE.TextureLoader().load(color.texture); | |
txt.repeat.set( color.size[0], color.size[1], color.size[2]); | |
txt.wrapS = THREE.RepeatWrapping; | |
txt.wrapT = THREE.RepeatWrapping; | |
new_mtl = new THREE.MeshPhongMaterial( { | |
map: txt, | |
shininess: color.shininess ? color.shininess : 10 | |
}); | |
} | |
else | |
{ | |
new_mtl = new THREE.MeshPhongMaterial({ | |
color: parseInt('0x' + color.color), | |
shininess: color.shininess ? color.shininess : 10 | |
}); | |
} | |
setMaterial(theModel, activeOption, new_mtl); | |
} | |
function setMaterial(parent, type, mtl) { | |
parent.traverse((o) => { | |
if (o.isMesh && o.nameID != null) { | |
if (o.nameID == type) { | |
o.material = mtl; | |
} | |
} | |
}); | |
} | |
// Function - Opening rotate | |
let initRotate = 0; | |
function initialRotation() { | |
initRotate++; | |
if (initRotate <= 120) { | |
theModel.rotation.y += Math.PI / 60; | |
} else { | |
loaded = true; | |
} | |
} | |
var slider = document.getElementById('js-tray'), sliderItems = document.getElementById('js-tray-slide'), difference; | |
function slide(wrapper, items) { | |
var posX1 = 0, | |
posX2 = 0, | |
posInitial, | |
threshold = 20, | |
posFinal, | |
slides = items.getElementsByClassName('tray__swatch'); | |
// Mouse events | |
items.onmousedown = dragStart; | |
// Touch events | |
items.addEventListener('touchstart', dragStart); | |
items.addEventListener('touchend', dragEnd); | |
items.addEventListener('touchmove', dragAction); | |
function dragStart (e) { | |
e = e || window.event; | |
posInitial = items.offsetLeft; | |
difference = sliderItems.offsetWidth - slider.offsetWidth; | |
difference = difference * -1; | |
if (e.type == 'touchstart') { | |
posX1 = e.touches[0].clientX; | |
} else { | |
posX1 = e.clientX; | |
document.onmouseup = dragEnd; | |
document.onmousemove = dragAction; | |
} | |
} | |
function dragAction (e) { | |
e = e || window.event; | |
if (e.type == 'touchmove') { | |
posX2 = posX1 - e.touches[0].clientX; | |
posX1 = e.touches[0].clientX; | |
} else { | |
posX2 = posX1 - e.clientX; | |
posX1 = e.clientX; | |
} | |
if (items.offsetLeft - posX2 <= 0 && items.offsetLeft - posX2 >= difference) { | |
items.style.left = (items.offsetLeft - posX2) + "px"; | |
} | |
} | |
function dragEnd (e) { | |
posFinal = items.offsetLeft; | |
if (posFinal - posInitial < -threshold) { | |
} else if (posFinal - posInitial > threshold) { | |
} else { | |
items.style.left = (posInitial) + "px"; | |
} | |
document.onmouseup = null; | |
document.onmousemove = null; | |
} | |
} | |
slide(slider, sliderItems); |
body, | |
html { | |
height: 100%; | |
margin: 0; | |
padding: 0; | |
font-family: 'Raleway', sans-serif; | |
font-size: 14px; | |
color: #444444; | |
} | |
* { | |
touch-action: manipulation; | |
} | |
*, | |
*:before, | |
*:after { | |
box-sizing: border-box; | |
} | |
body { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
width: 100%; | |
height: 100%; | |
position: fixed; | |
top: 0; | |
left: 0; | |
overflow: hidden; | |
} | |
#c { | |
width: 100%; | |
height: 100%; | |
display: block; | |
top: 0; | |
left: 0; | |
} | |
.controls { | |
position: absolute; | |
bottom: 0; | |
width: 100%; | |
} | |
.options { | |
position: absolute; | |
left: 0; | |
} | |
.option { | |
background-size: cover; | |
background-position: 50%; | |
background-color: white; | |
margin-bottom: 3px; | |
padding: 10px; | |
height: 55px; | |
width: 55px; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
cursor: pointer; | |
} | |
.option:hover { | |
border-left: 5px solid white; | |
width: 58px; | |
} | |
.option.--is-active { | |
border-right: 3px solid red; | |
width: 58px; | |
cursor: default; | |
} | |
.option.--is-active:hover { | |
border-left: none; | |
} | |
.option img { | |
height: 100%; | |
width: auto; | |
pointer-events: none; | |
} | |
.info { | |
padding: 0 1em; | |
display: flex; | |
justify-content: flex-end; | |
} | |
.info p { | |
margin-top: 0; | |
} | |
.tray { | |
width: 100%; | |
height: 50px; | |
position: relative; | |
overflow-x: hidden; | |
} | |
.tray__slide { | |
position: absolute; | |
display: flex; | |
left: 0; | |
transform: translateX(-50%); | |
animation: wheelin 1s 2s ease-in-out forwards; | |
} | |
.tray__swatch { | |
transition: 0.1s ease-in; | |
height: 50px; | |
min-width: 50px; | |
flex: 1; | |
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3); | |
background-size: cover; | |
background-position: center; | |
} | |
.tray__swatch:nth-child(5n+5) { | |
margin-right: 20px; | |
} | |
.drag-notice { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
padding: 2em; | |
width: 10em; | |
height: 10em; | |
box-sizing: border-box; | |
font-size: 0.9em; | |
font-weight: 800; | |
text-transform: uppercase; | |
text-align: center; | |
border-radius: 5em; | |
background: white; | |
position: absolute; | |
} | |
.drag-notice.start { | |
animation: popout 0.25s 3s forwards; | |
} | |
@keyframes popout { | |
to { | |
transform: scale(0); | |
} | |
} | |
@keyframes wheelin { | |
to { | |
transform: translateX(0); | |
} | |
} | |
@media (max-width: 960px) { | |
.options { | |
top: 0; | |
} | |
.info { | |
padding: 0 1em 1em 0; | |
} | |
.info__message { | |
display: flex; | |
align-items: flex-end; | |
} | |
.info__message p { | |
margin: 0; | |
font-size: 0.7em; | |
} | |
} | |
@media (max-width: 720px) { | |
.info { | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
padding: 0 1em 1em; | |
} | |
.info__message { | |
margin-bottom: 1em; | |
} | |
} | |
@media (max-width: 680px) { | |
.info { | |
padding: 1em 2em; | |
} | |
.info__message { | |
display: none; | |
} | |
.options { | |
bottom: 50px; | |
} | |
.option { | |
margin-bottom: 1px; | |
padding: 5px; | |
height: 45px; | |
width: 45px; | |
display: flex; | |
} | |
.option.--is-active { | |
border-right: 2px solid red; | |
width: 47px; | |
} | |
.option img { | |
height: 100%; | |
width: auto; | |
pointer-events: none; | |
} | |
} |
Hi Kyle,
Great tutorial - thanks for sharing it.
However, the demo in Codepen is not working. Neither the downloaded local code.
Do you know what could be wrong with it?