artist / index.html
mswhite's picture
Upload index.html
f44a9e3 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Cosmic Gesture Blocks</title>
<style>
:root {
font-family: "Space Grotesk", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f3f0ff;
background-color: #04020b;
}
body, html {
margin: 0;
padding: 0;
overflow: hidden;
height: 100%;
background: radial-gradient(circle at top, rgba(77, 32, 138, 0.35), transparent 55%),
radial-gradient(circle at bottom, rgba(25, 105, 172, 0.25), transparent 50%),
#04020b;
}
#three-root {
position: fixed;
inset: 0;
}
#overlay {
position: fixed;
top: 1.5rem;
left: 1.5rem;
max-width: 320px;
padding: 1rem 1.25rem;
background: rgba(6, 2, 17, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
backdrop-filter: blur(12px);
box-shadow: 0 15px 45px rgba(7, 0, 18, 0.65);
}
#overlay h1 {
margin: 0 0 0.35rem;
font-size: 1.25rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#overlay p, #overlay li {
margin: 0 0 0.35rem;
font-size: 0.95rem;
line-height: 1.4;
color: rgba(243, 240, 255, 0.9);
}
#overlay ul {
padding-left: 1.05rem;
margin: 0.5rem 0 0;
}
#status {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
padding: 0.65rem 1.25rem;
font-size: 0.9rem;
background: rgba(3, 4, 19, 0.65);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
transition: opacity 0.35s ease;
}
#fallback {
position: fixed;
inset: 0;
display: grid;
place-items: center;
text-align: center;
padding: 2rem;
background: rgba(4, 2, 11, 0.9);
color: #fff5fe;
z-index: 10;
}
#fallback button {
margin-top: 1rem;
padding: 0.5rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(120deg, #9a65ff, #ff63ae);
color: #fff;
cursor: pointer;
}
#inputVideo {
position: fixed;
width: 160px;
height: 120px;
bottom: 1rem;
right: 1rem;
opacity: 0.25;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
object-fit: cover;
pointer-events: none;
}
#inputVideo.hidden {
opacity: 0;
pointer-events: none;
}
@media (max-width: 768px) {
#overlay {
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 1.5rem);
max-width: none;
}
#status {
width: calc(100% - 2rem);
}
#inputVideo {
width: 120px;
height: 90px;
}
}
</style>
</head>
<body>
<div id="three-root"></div>
<div id="overlay">
<h1>Cosmic Gesture Blocks</h1>
<p>Float through a constellation of iridescent blocks and sculpt them with simple hand poses:</p>
<ul>
<li><strong>Open palm</strong> – select & highlight nearest block</li>
<li><strong>Pinch</strong> – grab & drag the block in midair</li>
<li><strong>Rotate wrist</strong> – spin a grabbed block</li>
<li><strong>Fist</strong> – shatter block into sparkling shards</li>
<li><strong>Wave</strong> – pan the camera left/right</li>
<li><strong>Thumbs up</strong> – duplicate the current block</li>
</ul>
</div>
<video id="inputVideo" class="hidden" autoplay playsinline muted></video>
<!-- Three.js -->
<script type="module">
import * as THREE from "https://unpkg.com/three@0.152.2/build/three.module.js";
import { OrbitControls } from "https://unpkg.com/three@0.152.2/examples/jsm/controls/OrbitControls.js"; // used only as fallback if gestures inactive
</script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script>
<script type="module">
import * as THREE from "https://unpkg.com/three@0.152.2/build/three.module.js";
import { OrbitControls } from "https://unpkg.com/three@0.152.2/examples/jsm/controls/OrbitControls.js";
const root = document.getElementById("three-root");
const statusEl = document.getElementById("status");
const fallbackEl = document.getElementById("fallback");
const videoEl = document.getElementById("inputVideo");
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x04020b, 0.022);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputEncoding = THREE.sRGBEncoding;
root.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 1.2, 8);
const fallbackControls = new OrbitControls(camera, renderer.domElement);
fallbackControls.enabled = false;
fallbackControls.enableDamping = true;
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xb0d2ff, 0.85);
keyLight.position.set(10, 12, 8);
scene.add(keyLight);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const clock = new THREE.Clock();
const blocks = [];
let selectedBlock = null;
let highlightPulse = 0;
let grabbing = false;
let grabbedDistance = 0;
let lastAngle = 0;
let lastGesture = "";
let lastThumbsUp = 0;
let lastExplosion = 0;
let lastWave = 0;
let lastWristX = null;
const particles = [];
function createStarField() {
const count = 1200;
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const radius = 50 * Math.random() + 10;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = radius * Math.cos(phi);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.2,
transparent: true,
opacity: 0.7,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const stars = new THREE.Points(geometry, material);
scene.add(stars);
}
createStarField();
function randomColor() {
const hues = [210, 250, 290];
const h = hues[Math.floor(Math.random() * hues.length)] + THREE.MathUtils.randFloatSpread(10);
return new THREE.Color(`hsl(${h}, 75%, 65%)`);
}
function createBlock(color = null, position = null) {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const baseColor = color || randomColor();
const material = new THREE.MeshPhysicalMaterial({
color: baseColor,
transmission: 0.7,
opacity: 0.8,
transparent: true,
roughness: 0.05,
metalness: 0.85,
clearcoat: 1,
clearcoatRoughness: 0.1,
emissive: baseColor.clone().multiplyScalar(0.15)
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(position || new THREE.Vector3(
THREE.MathUtils.randFloatSpread(6),
THREE.MathUtils.randFloat(0.4, 3.2),
THREE.MathUtils.randFloatSpread(6)
));
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
mesh.userData.base = mesh.position.clone();
mesh.userData.phase = Math.random() * Math.PI * 2;
// Halo for highlighting
const haloMaterial = new THREE.MeshBasicMaterial({
color: baseColor,
transparent: true,
opacity: 0,
side: THREE.BackSide,
blending: THREE.AdditiveBlending
});
const halo = new THREE.Mesh(geometry.clone().scale(1.1, 1.1, 1.1), haloMaterial);
halo.visible = false;
mesh.add(halo);
mesh.userData.halo = halo;
scene.add(mesh);
blocks.push(mesh);
return mesh;
}
const initialCount = THREE.MathUtils.randInt(10, 15);
for (let i = 0; i < initialCount; i++) createBlock();
function selectBlockFromPointer() {
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(blocks);
if (intersects.length > 0) {
highlightBlock(intersects[0].object);
}
}
function highlightBlock(mesh) {
if (selectedBlock === mesh) return;
if (selectedBlock) selectedBlock.userData.halo.visible = false;
selectedBlock = mesh;
if (selectedBlock) selectedBlock.userData.halo.visible = true;
}
function updateHaloPulses(delta) {
if (!selectedBlock) return;
highlightPulse += delta * 3;
const intensity = (Math.sin(highlightPulse) + 1) * 0.35 + 0.2;
selectedBlock.userData.halo.material.opacity = intensity;
}
function beginGrab(angle) {
grabbing = true;
grabbedDistance = camera.position.distanceTo(selectedBlock.position);
lastAngle = angle;
}
function endGrab() {
grabbing = false;
}
function moveGrabbedBlock() {
raycaster.setFromCamera(pointer, camera);
const target = raycaster.ray.origin.clone().add(raycaster.ray.direction.clone().multiplyScalar(grabbedDistance));
selectedBlock.position.lerp(target, 0.6);
selectedBlock.userData.base.copy(selectedBlock.position);
}
function rotateGrabbedBlock(angle) {
const delta = angle - lastAngle;
selectedBlock.rotation.y += delta * 5;
lastAngle = angle;
}
function explodeBlock(block) {
const now = performance.now();
if (!block || now - lastExplosion < 600) return;
lastExplosion = now;
const particleCount = 120;
const positions = new Float32Array(particleCount * 3);
const velocities = [];
for (let i = 0; i < particleCount; i++) {
const dir = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(1),
THREE.MathUtils.randFloatSpread(1),
THREE.MathUtils.randFloatSpread(1)
).normalize().multiplyScalar(THREE.MathUtils.randFloat(0.5, 2));
velocities.push(dir);
positions[i * 3] = block.position.x;
positions[i * 3 + 1] = block.position.y;
positions[i * 3 + 2] = block.position.z;
}
const pGeometry = new THREE.BufferGeometry();
pGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const pMaterial = new THREE.PointsMaterial({
size: 0.08,
vertexColors: false,
color: block.material.color,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const bundle = new THREE.Points(pGeometry, pMaterial);
bundle.userData = { velocities, life: 1.2 };
scene.add(bundle);
particles.push(bundle);
scene.remove(block);
const idx = blocks.indexOf(block);
if (idx !== -1) blocks.splice(idx, 1);
selectedBlock = null;
}
function duplicateBlock() {
const now = performance.now();
if (!selectedBlock || now - lastThumbsUp < 900) return;
lastThumbsUp = now;
const clone = createBlock(selectedBlock.material.color.clone(), selectedBlock.position.clone().add(new THREE.Vector3(0.4, 0.4, -0.4)));
highlightBlock(clone);
}
function panCamera(delta) {
camera.position.x -= delta * 8;
camera.lookAt(0, 1, 0);
}
function updateParticles(delta) {
for (let i = particles.length - 1; i >= 0; i--) {
const bundle = particles[i];
const { velocities } = bundle.userData;
bundle.userData.life -= delta;
const positions = bundle.geometry.attributes.position.array;
for (let j = 0; j < velocities.length; j++) {
positions[j * 3] += velocities[j].x * delta * 3;
positions[j * 3 + 1] += velocities[j].y * delta * 3;
positions[j * 3 + 2] += velocities[j].z * delta * 3;
velocities[j].multiplyScalar(0.98);
}
bundle.geometry.attributes.position.needsUpdate = true;
bundle.material.opacity = Math.max(bundle.userData.life / 1.2, 0);
if (bundle.userData.life <= 0) {
scene.remove(bundle);
particles.splice(i, 1);
}
}
}
function animateBlocks(time) {
blocks.forEach((mesh) => {
const t = time + mesh.userData.phase;
mesh.position.y = mesh.userData.base.y + Math.sin(t) * 0.3;
mesh.rotation.x += 0.002;
mesh.rotation.z += 0.0015;
});
}
function render() {
const delta = clock.getDelta();
const elapsed = clock.elapsedTime;
animateBlocks(elapsed);
updateHaloPulses(delta);
updateParticles(delta);
if (!handActive) {
fallbackControls.enabled = true;
fallbackControls.update();
} else {
fallbackControls.enabled = false;
}
renderer.render(scene, camera);
requestAnimationFrame(render);
}
render();
/* ---------- Gesture logic ---------- */
let handActive = false;
function setPointerFromLandmark(lm) {
pointer.x = lm.x * 2 - 1;
pointer.y = -(lm.y * 2 - 1);
}
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y, (a.z - b.z) || 0);
}
function isFingerExtended(lm, tip, pip) {
return lm[tip].y < lm[pip].y - 0.01;
}
function gestureClassifier(lm) {
const pinch = distance(lm[4], lm[8]) < 0.035;
const openPalm = [8, 12, 16, 20].every((tip, idx) => isFingerExtended(lm, tip, tip - 2));
const fist = [8, 12, 16, 20].every((tip) => lm[tip].y > lm[tip - 2].y - 0.005);
const thumbsUp =
lm[4].y < lm[0].y - 0.05 &&
[8, 12, 16, 20].every((tip) => lm[tip].y > lm[tip - 2].y + 0.015);
return { pinch, openPalm, fist, thumbsUp };
}
function wristAngle(lm) {
const index = lm[8];
const pinky = lm[20];
return Math.atan2(index.y - pinky.y, index.x - pinky.x);
}
function handleResults(results) {
if (!results.multiHandLandmarks || !results.multiHandLandmarks.length) {
handActive = false;
statusEl.textContent = "Show your hand to control the blocks ✋";
lastWristX = null;
return;
}
handActive = true;
statusEl.textContent = "";
videoEl.classList.remove("hidden");
const lm = results.multiHandLandmarks[0];
setPointerFromLandmark(lm[8]);
const { pinch, openPalm, fist, thumbsUp } = gestureClassifier(lm);
// Wave detection
if (lastWristX !== null) {
const deltaX = lm[0].x - lastWristX;
if (Math.abs(deltaX) > 0.07 && performance.now() - lastWave > 700) {
lastWave = performance.now();
panCamera(deltaX);
}
}
lastWristX = lm[0].x;
if (thumbsUp) {
duplicateBlock();
lastGesture = "thumbsup";
} else if (fist) {
explodeBlock(selectedBlock);
lastGesture = "fist";
} else if (pinch && selectedBlock) {
const angle = wristAngle(lm);
if (!grabbing) beginGrab(angle);
moveGrabbedBlock();
rotateGrabbedBlock(angle);
lastGesture = "pinch";
} else {
if (grabbing) endGrab();
if (openPalm && lastGesture !== "open") {
selectBlockFromPointer();
lastGesture = "open";
} else if (!openPalm) {
lastGesture = "";
}
}
}
const hands = new Hands({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`,
});
hands.setOptions({
maxNumHands: 1,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.5,
modelComplexity: 1,
});
hands.onResults(handleResults);
const mpCamera = new Camera(videoEl, {
onFrame: async () => {
await hands.send({ image: videoEl });
},
width: 640,
height: 480,
});
mpCamera
.start()
.then(() => {
statusEl.textContent = "Gesture controls ready – wave to explore.";
})
.catch((err) => {
console.error(err);
fallbackEl.hidden = false;
statusEl.textContent = "";
});
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>