Last active
September 13, 2022 10:58
-
-
Save 18520339/cecef19b9d2d6f28b333743cb09b7a7b to your computer and use it in GitHub Desktop.
Interactive 3D Character with Three.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en" class="no-js"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>Interactive 3D Character with Three.js | Codrops</title> | |
<link rel="stylesheet" type="text/css" href="style.css" /> | |
<script> | |
document.documentElement.className = 'js'; | |
let supportsCssVars = () => { | |
let e, t = document.createElement('style'); | |
return ( | |
(t.innerHTML = 'root: { --tmp-var: bold; }'), | |
document.head.appendChild(t), | |
(e = !! ( | |
window.CSS && | |
window.CSS.supports && | |
window.CSS.supports('font-weight', 'var(--tmp-var)') | |
)), | |
t.parentNode.removeChild(t), | |
e | |
); | |
}; | |
supportsCssVars() || alert('Please view this demo in a modern browser that supports CSS Variables.'); | |
</script> | |
</head> | |
<body> | |
<div class="loading" id="js-loader"> | |
<div class="loader"></div> | |
</div> | |
<div class="wrapper"> | |
<!-- The canvas element is used to draw the 3D scene --> | |
<canvas id="c"></canvas> | |
</div> | |
<!-- The main Three.js file --> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/108/three.min.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> | |
<!-- partial --> | |
<script src="script.js"></script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Set our main variables | |
let scene, | |
renderer, | |
camera, | |
model, // Our character | |
neck, // Reference to the neck bone in the skeleton | |
waist, // Reference to the waist bone in the skeleton | |
possibleAnims, // Animations found in our file | |
mixer, // THREE.js animations mixer | |
idle, // Idle, the default state our character returns to | |
clock = new THREE.Clock(), // Used for anims, which run to a clock instead of frame rate | |
currentlyAnimating = false, // Used to check whether characters neck is being used in another anim | |
raycaster = new THREE.Raycaster(), // Used to detect the click on our character | |
loaderAnim = document.getElementById('js-loader'); | |
init(); | |
update(); | |
window.addEventListener('click', e => raycast(e)); | |
window.addEventListener('touchend', e => raycast(e, true)); | |
function init() { | |
const MODEL_PATH = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy_lightweight.glb'; | |
const canvas = document.querySelector('#c'); | |
const backgroundColor = 0xf1f1f1; | |
// Init the scene | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(backgroundColor); | |
scene.fog = new THREE.Fog(backgroundColor, 60, 100); | |
// Init the renderer | |
renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
renderer.shadowMap.enabled = true; | |
renderer.setPixelRatio(window.devicePixelRatio); | |
document.body.appendChild(renderer.domElement); | |
// Add a camera | |
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.11); | |
camera.position.z = 30; | |
camera.position.x = 0; | |
camera.position.y = -3; | |
let stacy_txt = new THREE.TextureLoader().load('https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy.jpg'); | |
stacy_txt.flipY = false; | |
const stacy_mtl = new THREE.MeshPhongMaterial({ | |
map: stacy_txt, | |
color: 0xffffff, | |
skinning: true, | |
}); | |
var loader = new THREE.GLTFLoader(); | |
loader.load( | |
MODEL_PATH, | |
gltf => { | |
model = gltf.scene; | |
let fileAnimations = gltf.animations; | |
model.traverse(o => { | |
if (o.isMesh) { | |
o.castShadow = true; | |
o.receiveShadow = true; | |
o.material = stacy_mtl; | |
} | |
// Reference the neck and waist bones | |
if (o.isBone && o.name === 'mixamorigNeck') neck = o; | |
if (o.isBone && o.name === 'mixamorigSpine') waist = o; | |
}); | |
model.scale.set(7, 7, 7); | |
model.position.y = -11; | |
scene.add(model); | |
loaderAnim.remove(); | |
let clips = fileAnimations.filter(val => val.name !== 'idle'); | |
mixer = new THREE.AnimationMixer(model); | |
possibleAnims = clips.map(val => { | |
let clip = THREE.AnimationClip.findByName(clips, val.name); | |
clip.tracks.splice(3, 3); | |
clip.tracks.splice(9, 3); | |
return mixer.clipAction(clip); | |
}); | |
let idleAnim = THREE.AnimationClip.findByName(fileAnimations, 'idle'); | |
idleAnim.tracks.splice(3, 3); | |
idleAnim.tracks.splice(9, 3); | |
idle = mixer.clipAction(idleAnim); | |
idle.play(); | |
}, | |
undefined, // We don't need this function | |
console.error | |
); | |
// Add lights | |
let hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.61); | |
hemiLight.position.set(0, 50, 0); | |
// Add hemisphere light to scene | |
scene.add(hemiLight); | |
let d = 8.25; | |
let dirLight = new THREE.DirectionalLight(0xffffff, 0.54); | |
dirLight.position.set(-8, 12, 8); | |
dirLight.castShadow = true; | |
dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024); | |
dirLight.shadow.camera.near = 0.1; | |
dirLight.shadow.camera.far = 1500; | |
dirLight.shadow.camera.left = d * -1; | |
dirLight.shadow.camera.right = d; | |
dirLight.shadow.camera.top = d; | |
dirLight.shadow.camera.bottom = d * -1; | |
// Add directional Light to scene | |
scene.add(dirLight); | |
// Floor | |
let floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1); | |
let floorMaterial = new THREE.MeshPhongMaterial({ color: 0xeeeeee, shininess: 0 }); | |
let floor = new THREE.Mesh(floorGeometry, floorMaterial); | |
floor.rotation.x = -0.5 * Math.PI; | |
floor.receiveShadow = true; | |
floor.position.y = -11; | |
scene.add(floor); | |
let geometry = new THREE.SphereGeometry(8, 32, 32); | |
let material = new THREE.MeshBasicMaterial({ color: 0x9bffaf }); // 0xf2ce2e | |
let sphere = new THREE.Mesh(geometry, material); | |
sphere.position.z = -15; | |
sphere.position.y = -2.5; | |
sphere.position.x = -0.25; | |
scene.add(sphere); | |
} | |
function update() { | |
if (mixer) mixer.update(clock.getDelta()); | |
if (resizeRendererToDisplaySize(renderer)) { | |
const canvas = renderer.domElement; | |
camera.aspect = canvas.clientWidth / canvas.clientHeight; | |
camera.updateProjectionMatrix(); | |
} | |
renderer.render(scene, camera); | |
requestAnimationFrame(update); | |
} | |
function resizeRendererToDisplaySize(renderer) { | |
const canvas = renderer.domElement; | |
let width = window.innerWidth; | |
let height = window.innerHeight; | |
let canvasPixelWidth = canvas.width / window.devicePixelRatio; | |
let canvasPixelHeight = canvas.height / window.devicePixelRatio; | |
const needResize = canvasPixelWidth !== width || canvasPixelHeight !== height; | |
if (needResize) renderer.setSize(width, height, false); | |
return needResize; | |
} | |
function raycast(e, touch = false) { | |
var mouse = {}; | |
if (touch) { | |
mouse.x = 2 * (e.changedTouches[0].clientX / window.innerWidth) - 1; | |
mouse.y = 1 - 2 * (e.changedTouches[0].clientY / window.innerHeight); | |
} else { | |
mouse.x = 2 * (e.clientX / window.innerWidth) - 1; | |
mouse.y = 1 - 2 * (e.clientY / window.innerHeight); | |
} | |
// update the picking ray with the camera and mouse position | |
raycaster.setFromCamera(mouse, camera); | |
// calculate objects intersecting the picking ray | |
var intersects = raycaster.intersectObjects(scene.children, true); | |
if (intersects[0]) { | |
var object = intersects[0].object; | |
if (object.name === 'stacy') { | |
if (!currentlyAnimating) { | |
currentlyAnimating = true; | |
playOnClick(); | |
} | |
} | |
} | |
} | |
// Get a random animation, and play it | |
function playOnClick() { | |
let anim = Math.floor(Math.random() * possibleAnims.length) + 0; | |
playModifierAnimation(idle, 0.25, possibleAnims[anim], 0.25); | |
} | |
function playModifierAnimation(from, fSpeed, to, tSpeed) { | |
to.setLoop(THREE.LoopOnce); | |
to.reset(); | |
to.play(); | |
from.crossFadeTo(to, fSpeed, true); | |
setTimeout(() => { | |
from.enabled = true; | |
to.crossFadeTo(from, tSpeed, true); | |
currentlyAnimating = false; | |
}, to._clip.duration * 1000 - (tSpeed + fSpeed) * 1000); | |
} | |
document.addEventListener('mousemove', function (e) { | |
var mousecoords = getMousePos(e); | |
if (neck && waist) { | |
moveJoint(mousecoords, neck, 50); | |
moveJoint(mousecoords, waist, 30); | |
} | |
}); | |
function getMousePos(e) { | |
return { x: e.clientX, y: e.clientY }; | |
} | |
function moveJoint(mouse, joint, degreeLimit) { | |
let degrees = getMouseDegrees(mouse.x, mouse.y, degreeLimit); | |
joint.rotation.y = THREE.Math.degToRad(degrees.x); | |
joint.rotation.x = THREE.Math.degToRad(degrees.y); | |
console.log(joint.rotation.x); | |
} | |
function getMouseDegrees(x, y, degreeLimit) { | |
let dx = 0, | |
dy = 0, | |
xdiff, | |
xPercentage, | |
ydiff, | |
yPercentage; | |
let w = { x: window.innerWidth, y: window.innerHeight }; | |
// Left (Rotates neck left between 0 and -degreeLimit) | |
// 1. If cursor is in the left half of screen | |
if (x <= w.x / 2) { | |
// 2. Get the difference between middle of screen and cursor position | |
xdiff = w.x / 2 - x; | |
// 3. Find the percentage of that difference (percentage toward edge of screen) | |
xPercentage = (xdiff / (w.x / 2)) * 100; | |
// 4. Convert that to a percentage of the maximum rotation we allow for the neck | |
dx = ((degreeLimit * xPercentage) / 100) * -1; | |
} | |
// Right (Rotates neck right between 0 and degreeLimit) | |
if (x >= w.x / 2) { | |
xdiff = x - w.x / 2; | |
xPercentage = (xdiff / (w.x / 2)) * 100; | |
dx = (degreeLimit * xPercentage) / 100; | |
} | |
// Up (Rotates neck up between 0 and -degreeLimit) | |
if (y <= w.y / 2) { | |
ydiff = w.y / 2 - y; | |
yPercentage = (ydiff / (w.y / 2)) * 100; | |
// Note that I cut degreeLimit in half when she looks up | |
dy = ((degreeLimit * 0.5 * yPercentage) / 100) * -1; | |
} | |
// Down (Rotates neck down between 0 and degreeLimit) | |
if (y >= w.y / 2) { | |
ydiff = y - w.y / 2; | |
yPercentage = (ydiff / (w.y / 2)) * 100; | |
dy = (degreeLimit * yPercentage) / 100; | |
} | |
return { x: dx, y: dy }; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body, | |
html { | |
margin : 0; | |
padding: 0; | |
} | |
* { | |
touch-action: manipulation; | |
} | |
*, | |
*::before, | |
*::after { | |
box-sizing: border-box; | |
} | |
body { | |
position : relative; | |
font-family : -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; | |
-webkit-font-smoothing : antialiased; | |
-moz-osx-font-smoothing: grayscale; | |
width : 100%; | |
height : 100vh; | |
background : #f1f1f1; | |
} | |
.action { | |
position : absolute; | |
bottom : 2rem; | |
width : 100%; | |
text-align : center; | |
color : #d97043; | |
font-style : italic; | |
z-index : 10; | |
pointer-events: none; | |
} | |
.wrapper { | |
display : flex; | |
flex-direction : column; | |
justify-content: center; | |
align-items : center; | |
} | |
#c { | |
position: absolute; | |
top : 0; | |
width : 100%; | |
height : 100%; | |
display : block; | |
} | |
.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: #9bffaf; | |
animation : flip 1s infinite; | |
} | |
@keyframes flip { | |
0% { transform: rotate(0); } | |
50% { transform: rotateY(180deg); } | |
100% { transform: rotateY(180deg) rotateX(180deg); } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Reference: https://tympanus.net/codrops/2019/10/14/how-to-create-an-interactive-3d-character-with-three-js/