Skip to content

Instantly share code, notes, and snippets.

@18520339
Last active September 13, 2022 10:58
Show Gist options
  • Save 18520339/cecef19b9d2d6f28b333743cb09b7a7b to your computer and use it in GitHub Desktop.
Save 18520339/cecef19b9d2d6f28b333743cb09b7a7b to your computer and use it in GitHub Desktop.
Interactive 3D Character with Three.js
<!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>
// 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 };
}
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