Skip to content

Instantly share code, notes, and snippets.

@swfh
Created May 5, 2025 22:08
Show Gist options
  • Save swfh/f794231ffcbb86c24b5a70a23b5a5ffa to your computer and use it in GitHub Desktop.
Save swfh/f794231ffcbb86c24b5a70a23b5a5ffa to your computer and use it in GitHub Desktop.
Carnival Spinner

Carnival Spinner

Step Right Up & Generate a Random Number from 1-10. Built with Blender & Threejs.

Three different types of animations:

  1. The wheel spinning is done with code.

  2. The rotating panel is done inside of Blender and controlled in threejs.

  3. The intro screen is created in After Effects and exported as JSON, loaded via lottie.

The shadows are all baked; the shadow from the structure onto the wheel is a separate png that displays just over the wheel; it is static so it gives the appearance of a realistic shadow cast on a moving object.

A Pen by Jared Stanley on CodePen.

License.

<div id="container">
<div id="lottie"> </div>
<div id="enter">
<button id="enter" onclick="start()">enter</button>
</div>
</div>
<canvas class="webgl"></canvas>
<script>
let start = () => {
document.getElementById("container").style.display = "none";
window.start3 = true;
anim.stop();
}
</script>
import * as THREE from "https://esm.sh/[email protected]";
import { OrbitControls } from "https://esm.sh/[email protected]/addons/controls/OrbitControls.js";
import { GLTFLoader } from "https://esm.sh/[email protected]/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "https://esm.sh/[email protected]/examples/jsm/loaders/DRACOLoader";
import { RGBELoader } from "https://esm.sh/[email protected]/examples/jsm/loaders/RGBELoader";
import Lottie from "https://esm.sh/lottie-web";
window.start3 = false;
let cameraSetup = {
cameraIsSettled: false,
cameraTgt: {
x: 0.11611507465368477,
y: 3.5939784540499456e-16,
z: 5.8682635668005005
},
totalIterations: 900,
iteration: 0,
leverTgt: 1
};
let init = () => {
window.addEventListener("touchstart", isDown);
window.addEventListener("mousedown", isDown);
window.addEventListener("mouseup", isUp);
window.addEventListener("touchend", isUp);
window.addEventListener("mousemove", isMove);
window.addEventListener("touchmove", isMove);
initLottie();
};
let anim = null;
let initLottie = () => {
anim = Lottie.loadAnimation({
container: document.getElementById("lottie"),
renderer: "svg",
loop: true,
autoplay: true,
path: "https://assets.codepen.io/262181/crnvlintro.json"
});
let loop = () => {
anim.goToAndPlay(120, true);
};
anim.addEventListener("loopComplete", loop);
};
let wheel;
let lever = null;
let hitArea = null;
let pullSign = null;
let ready = false;
let speed = 0;
let inc = 0.02;
let mouse = {
direction: null,
pressing: false,
curY: null,
isClicking: false,
dragStarted: false,
dragDistance: 0,
isSpinning: false
};
let revealAnim = {
isAnimating: false,
isShowing: false
};
let signSpinSpeed = 1;
let incSpeed = 12;
let resultSign = null;
let resultAnim = null;
let animAction = null;
let animMixer = null;
let numArr = [];
//
let deviceType = null;
//
const canvas = document.querySelector("canvas.webgl");
//
const scene = new THREE.Scene();
//
const textureLoader = new THREE.TextureLoader();
//
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("draco/");
//
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
//
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
//
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
deviceType = "mobile";
} else {
deviceType = "desktop";
}
const isDown = (e) => {
mouse.pressing = true;
let tgt = "";
if (deviceType == "mobile") {
tgt = e.targetTouches[0];
} else {
tgt = e;
}
mouse.x = tgt.clientX;
mouse.y = tgt.clientY;
//
pointer.x = (tgt.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(tgt.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
// calculate objects intersecting the picking ray
const intersects = raycaster.intersectObject(hitArea);
if (intersects.length > 0) {
// console.log("clicking");
//clicking the lever
e.preventDefault();
controls.enableRotate = false;
mouse.isClicking = true;
}
};
const isUp = (e) => {
mouse.pressing = false;
mouse.isClicking = false;
mouse.direction = null;
controls.enableRotate = true;
if (mouse.dragStarted) {
mouse.dragStarted = false;
lever.rotation.x = 1;
}
};
//
const isMove = (e) => {
// console.log("isMove");
let tgt = "";
if (deviceType == "mobile") {
tgt = e.targetTouches[0];
} else {
tgt = e;
}
mouse.x = tgt.clientX;
mouse.y = tgt.clientY;
// console.log(mouse.y);
if (mouse.pressing && mouse.isClicking) {
e.preventDefault();
controls.enableRotate = false;
if (mouse.curY == null) {
//first click
} else if (mouse.curY > tgt.clientY) {
// console.log("moving up");
mouse.direction = "up";
} else if (mouse.curY < tgt.clientY) {
// console.log("moving down");
if (mouse.direction != "down") {
mouse.direction = "down";
mouse.dragDistance = 0;
} else {
//already moving down, lets move the lever
let dist = tgt.clientY - mouse.curY;
mouse.dragStarted = true;
mouse.dragDistance += dist;
let r = Math.min(2, mouse.dragDistance / 100 + 1);
lever.rotation.x = r;
if (inc < 0.4) {
inc += r / 100;
}
}
}
mouse.curY = tgt.clientY;
} else {
mouse.mouseDirection = null;
mouse.curY = null;
return;
}
};
//
const sizes = { width: window.innerWidth, height: window.innerHeight };
window.addEventListener("resize", () => {
// Update sizes
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// Update camera
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// Update renderer
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});
const camera = new THREE.PerspectiveCamera(
35,
sizes.width / sizes.height,
0.1,
100
);
camera.position.set(10, -2.3, -15.4);
scene.add(camera);
// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.minDistance = 4;
controls.maxDistance = 7;
controls.maxPolarAngle = Math.PI / 2;
controls.maxAzimuthAngle = 0.785;
controls.minAzimuthAngle = -1.5;
//
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
//
const bakedTexture = textureLoader.load(
"https://assets.codepen.io/262181/baked.jpg"
);
bakedTexture.flipY = false;
bakedTexture.colorSpace = THREE.SRGBColorSpace;
// const wheelTexture = textureLoader.load('wheelv1.png')
const wheelTexture = textureLoader.load(
"https://assets.codepen.io/262181/wheel.jpg"
);
wheelTexture.flipY = false;
wheelTexture.colorSpace = THREE.SRGBColorSpace;
//
const shadowTexture = textureLoader.load(
"https://assets.codepen.io/262181/shadow.png"
);
shadowTexture.flipY = false;
shadowTexture.colorSpace = THREE.SRGBColorSpace;
//
const bakedMaterial = new THREE.MeshBasicMaterial({ map: bakedTexture });
const wheelMaterial = new THREE.MeshBasicMaterial({ map: wheelTexture });
// const shadowMaterial = new THREE.MeshBasicMaterial({ map: shadowTexture, transparent:true, opacity:0.5 })
const shadowMaterial = new THREE.MeshBasicMaterial({
map: shadowTexture,
transparent: true,
opacity: 0.5
});
const transpMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(0x000000),
transparent: true,
opacity: 0
});
//
gltfLoader.load("https://assets.codepen.io/262181/carnival.glb", (gltf) => {
// console.log(gltf);
gltf.scene.traverse((child) => {
// console.log(child.name);
if (child.name == "lever") {
lever = child;
lever.rotation.x = 2;
child.material = bakedMaterial;
} else if (child.name == "hitarea") {
hitArea = child;
child.material = transpMaterial;
} else if (child.name == "wheel") {
child.material = wheelMaterial;
wheel = child;
// window.wheel=wheel
ready = true;
} else if (child.name == "shadow") {
child.material = shadowMaterial;
// child.visible = false;
} else if (child.name == "sign_swing") {
//pull sign at intro
child.material = bakedMaterial;
pullSign = child;
} else if (child.name == "result_panel") {
// result animation
child.material = bakedMaterial;
initResultAnimation(child, gltf);
} else if (child.name.includes("num")) {
numArr.push(child);
child.material = bakedMaterial;
child.visible = false;
} else {
child.material = bakedMaterial;
}
});
scene.add(gltf.scene);
// console.log(numArr);
});
let initResultAnimation = (child, gltf) => {
resultSign = child;
resultAnim = THREE.AnimationClip.findByName(
gltf.animations,
"result_panelAction"
);
// resultAnim = gltf.animations[0]
animMixer = new THREE.AnimationMixer(resultSign);
// animMixer.addEventListener( 'loop', (e) => { console.log("loopDone")})
animMixer.addEventListener("finished", (e) => {
manageRevealAnim(e);
});
animAction = animMixer.clipAction(resultAnim);
animAction.reset();
animAction.clampWhenFinished = true;
animAction.timeScale = 1;
animAction.setLoop(THREE.LoopOnce, 1);
// animAction.play();
};
let manageRevealAnim = (e) => {
// console.log("finished");
if (revealAnim.isShowing == true) {
// //its now closed
if (!mouse.isSpinning) {
revealAnim.isShowing = false;
}
} else {
// //its now open
if (!mouse.isSpinning) {
revealAnim.isShowing = true;
}
}
revealAnim.isAnimating = false;
animAction.paused = true;
// console.log(revealAnim.isShowing);
};
//
const clock = new THREE.Clock();
//
let showNumber = (answer) => {
let itm = numArr[answer - 1];
numArr.forEach((n) => {
n.visible = false;
});
itm.visible = true;
// console.log(answer, numArr[answer-1]);
};
let easeOutCubic = (t, b, c, d) => {
t /= d;
t--;
return c * (t * t * t + 1) + b;
};
let easeInOutCubic = (t, b, c, d) => {
t /= d / 2;
if (t < 1) return (c / 2) * t * t * t + b;
t -= 2;
return (c / 2) * (t * t * t + 2) + b;
};
const tick = () => {
// const elapsedTime = clock.getElapsedTime()
// Update controls
controls.update();
//
// console.log(camera.position);
if (ready) {
// console.log((wheel.rotation.z)*(180/Math.PI) ) %360;;
// wheel.rotation.z = 0
if (window.start3) {
speed += inc;
if (inc > 0) {
if (!mouse.isSpinning) {
inc += 0.1;
}
wheel.rotation.z = speed;
inc -= 0.001;
mouse.isSpinning = true;
// animAction.reset();
if (revealAnim.isShowing) {
// console.log("should be closing");
animAction.paused = false;
revealAnim.isAnimating = true;
animAction.setLoop(THREE.LoopOnce);
animAction.timeScale = -1;
animAction.time = 0.5;
animAction.play();
revealAnim.isShowing = false;
} else {
//
}
} else {
let rad = (wheel.rotation.z * (180 / Math.PI)) % 360;
let answer = Math.floor(1 + rad / 36); // +1 for zero offset, /36 because there are 10 sections so 360/36 = 10
if (mouse.isSpinning == true) {
showNumber(answer);
mouse.isSpinning = false;
// console.log("should be opening");
animAction.paused = false;
revealAnim.isAnimating = true;
animAction.setLoop(THREE.LoopOnce);
animAction.timeScale = 1;
animAction.play();
}
}
if (incSpeed > 0) {
if (pullSign != null) {
pullSign.rotation.z = Math.sin(signSpinSpeed) / 3;
signSpinSpeed += incSpeed / 100;
incSpeed -= 0.068;
}
}
if (!cameraSetup.cameraIsSettled) {
// console.log("moving cam", cameraSetup.iteration, cameraSetup.totalIterations);
cameraSetup.iteration++;
if (cameraSetup.iteration < 100) {
let xx = easeOutCubic(
cameraSetup.iteration,
camera.position.x,
cameraSetup.cameraTgt.x - camera.position.x,
cameraSetup.totalIterations
);
let yy = easeOutCubic(
cameraSetup.iteration,
camera.position.y,
cameraSetup.cameraTgt.y - camera.position.y,
cameraSetup.totalIterations
);
let zz = easeOutCubic(
cameraSetup.iteration,
camera.position.z,
cameraSetup.cameraTgt.z - camera.position.z,
cameraSetup.totalIterations
);
camera.position.set(xx, yy, zz);
let leverR = easeInOutCubic(
cameraSetup.iteration,
lever.rotation.x,
cameraSetup.leverTgt - lever.rotation.x,
80
);
lever.rotation.x = leverR;
// console.log(cameraSetup.cameraTgt.y-camera.position.y);
// if((Math.abs(cameraSetup.cameraTgt.y)-Math.abs(camera.position.y))<0.0000001){
// cameraSetup.cameraIsSettled=true;
// console.log(cameraSetup.iteration);
// }
} else {
cameraSetup.cameraIsSettled = true;
}
}
}
}
//reveal animation
const delta = clock.getDelta();
if (animMixer) animMixer.update(delta);
// console.log(delta);
// Render
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
init();
tick();
window.camera = camera;
window.controls = controls;
window.anim = anim;
@import "https://fonts.googleapis.com/css2?family=Aleo&display=swap";
* {
font-family: Aleo, sans-serif;
font-weight: 400;
margin: 0;
padding: 0;
}
html,
body {
color: #9d988a;
font-size: 0.9em;
overflow: hidden;
}
.webgl {
position: fixed;
top: 0;
left: 0;
outline: none;
}
#container {
background-image: url(https://assets.codepen.io/262181/intro-bg.jpg);
background-size: cover;
background-position: center;
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 101;
position: absolute;
}
#lottie {
height: 40vh;
max-height: 400px;
z-index: 102;
display: flex;
}
#explanation {
display: flex;
width: 66vw;
max-width: 300px;
background-color: #00000040;
padding: 16px;
}
#enter {
width: auto;
}
button {
cursor: pointer;
margin-top: 4px;
padding: 4px;
background-color: #9d988a;
color: #6b2414;
border-radius: 4px;
border: none;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment