Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Single Hand Particle System</title> | |
| <style> | |
| body { margin: 0; overflow: hidden; background-color: #080808; font-family: 'Inter', system-ui, sans-serif; } | |
| #webcam-feed { display: none; } | |
| #glass-panel { | |
| position: absolute; | |
| top: 24px; | |
| left: 24px; | |
| width: 300px; | |
| padding: 24px; | |
| background: rgba(20, 20, 20, 0.7); | |
| backdrop-filter: blur(12px); | |
| border-radius: 20px; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| color: #ffffff; | |
| z-index: 100; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); | |
| } | |
| h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 2px; color: #888; margin: 0 0 16px 0; } | |
| .control-row { margin-bottom: 16px; } | |
| label { display: block; font-size: 12px; margin-bottom: 8px; color: #ccc; font-weight: 500; } | |
| select, input[type="color"] { | |
| width: 100%; | |
| padding: 10px; | |
| border-radius: 8px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| background: rgba(0,0,0,0.3); | |
| color: white; | |
| outline: none; | |
| font-size: 13px; | |
| transition: border-color 0.2s; | |
| } | |
| select:hover { border-color: #00ffff; } | |
| .metric-display { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 11px; | |
| color: #666; | |
| margin-top: 4px; | |
| } | |
| .status-indicator { | |
| margin-top: 20px; | |
| padding-top: 20px; | |
| border-top: 1px solid rgba(255,255,255,0.1); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 12px; | |
| color: #00ff9d; | |
| } | |
| .led { width: 6px; height: 6px; background: #00ff9d; border-radius: 50%; box-shadow: 0 0 8px #00ff9d; } | |
| .led.off { background: #ff3333; box-shadow: 0 0 8px #ff3333; } | |
| #overlay-msg { | |
| position: absolute; | |
| bottom: 30px; | |
| width: 100%; | |
| text-align: center; | |
| color: rgba(255,255,255,0.4); | |
| font-size: 12px; | |
| pointer-events: none; | |
| } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script> | |
| </head> | |
| <body> | |
| <div id="glass-panel"> | |
| <h2>Controller</h2> | |
| <div class="control-row"> | |
| <label>Geometry</label> | |
| <select id="geometry-selector"> | |
| <option value="galaxy">Spiral Galaxy</option> | |
| <option value="heart">Digital Heart</option> | |
| <option value="dna">DNA Helix</option> | |
| <option value="sphere">Quantum Sphere</option> | |
| <option value="cube">Hyper Cube</option> | |
| </select> | |
| </div> | |
| <div class="control-row"> | |
| <label>Color Tone</label> | |
| <input type="color" id="color-selector" value="#00ffff"> | |
| </div> | |
| <div class="status-indicator"> | |
| <div id="cam-led" class="led off"></div> | |
| <span id="cam-status">Initializing AI...</span> | |
| </div> | |
| <div style="margin-top: 15px; font-size: 11px; color: #888; line-height: 1.6;"> | |
| <strong>Gestures:</strong><br> | |
| • 🤏 Pinch to Scale<br> | |
| • ✋ Move to Rotate<br> | |
| • ✊ Fist to Explode | |
| </div> | |
| </div> | |
| <div id="overlay-msg">Please allow camera access to interact</div> | |
| <video id="webcam-feed"></video> | |
| <script> | |
| const CONFIG = { | |
| particleCount: 36000, | |
| particleSize: 0.01, | |
| baseColor: 0x00ffff, | |
| camWidth: 640, | |
| camHeight: 480 | |
| }; | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x050505); | |
| scene.fog = new THREE.FogExp2(0x050505, 0.03); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100); | |
| camera.position.z = 6; | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| document.body.appendChild(renderer.domElement); | |
| const positions = new Float32Array(CONFIG.particleCount * 3); | |
| const targetPositions = new Float32Array(CONFIG.particleCount * 3); | |
| const geometry = new THREE.BufferGeometry(); | |
| for(let i=0; i<CONFIG.particleCount * 3; i++) { | |
| positions[i] = (Math.random() - 0.5) * 10; | |
| targetPositions[i] = positions[i]; | |
| } | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| size: CONFIG.particleSize, | |
| color: CONFIG.baseColor, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false, | |
| transparent: true, | |
| opacity: 0.8 | |
| }); | |
| const particleSystem = new THREE.Points(geometry, material); | |
| scene.add(particleSystem); | |
| const state = { | |
| shape: 'galaxy', | |
| scale: 1.0, | |
| rotationX: 0, | |
| rotationY: 0, | |
| chaos: 0, | |
| smoothedScale: 1.0, | |
| smoothedRotX: 0, | |
| smoothedRotY: 0, | |
| smoothedChaos: 0 | |
| }; | |
| const generators = { | |
| sphere: (i) => { | |
| const u = Math.random(); | |
| const v = Math.random(); | |
| const theta = 2 * Math.PI * u; | |
| const phi = Math.acos(2 * v - 1); | |
| const r = 2.5; | |
| return { | |
| x: r * Math.sin(phi) * Math.cos(theta), | |
| y: r * Math.sin(phi) * Math.sin(theta), | |
| z: r * Math.cos(phi) | |
| }; | |
| }, | |
| heart: (i) => { | |
| const t = Math.random() * Math.PI * 2; | |
| const x = 16 * Math.pow(Math.sin(t), 3); | |
| const y = 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t); | |
| return { x: x * 0.15, y: y * 0.15, z: (Math.random()-0.5) * 2 }; | |
| }, | |
| galaxy: (i) => { | |
| const arms = 5; | |
| const spin = i / CONFIG.particleCount * arms; | |
| const r = (i / CONFIG.particleCount) * 5; | |
| const angle = spin * Math.PI * 2; | |
| const drift = Math.random() * 0.5; | |
| return { | |
| x: Math.cos(angle) * r + drift, | |
| y: (Math.random() - 0.5) * (2 - r/3), | |
| z: Math.sin(angle) * r + drift | |
| }; | |
| }, | |
| dna: (i) => { | |
| const t = (i / CONFIG.particleCount) * 10 * Math.PI; | |
| const radius = 1.5; | |
| const strand = i % 2 === 0 ? 1 : -1; | |
| return { | |
| x: Math.cos(t + strand * Math.PI) * radius, | |
| y: (i / CONFIG.particleCount - 0.5) * 10, | |
| z: Math.sin(t + strand * Math.PI) * radius | |
| }; | |
| }, | |
| cube: (i) => { | |
| const s = 3; | |
| return { | |
| x: (Math.random() - 0.5) * s, | |
| y: (Math.random() - 0.5) * s, | |
| z: (Math.random() - 0.5) * s | |
| }; | |
| } | |
| }; | |
| function morphShape() { | |
| const generator = generators[state.shape]; | |
| for(let i=0; i<CONFIG.particleCount; i++) { | |
| const pos = generator(i); | |
| targetPositions[i*3] = pos.x; | |
| targetPositions[i*3+1] = pos.y; | |
| targetPositions[i*3+2] = pos.z; | |
| } | |
| } | |
| morphShape(); | |
| document.getElementById('geometry-selector').addEventListener('change', (e) => { | |
| state.shape = e.target.value; | |
| morphShape(); | |
| }); | |
| document.getElementById('color-selector').addEventListener('input', (e) => { | |
| particleSystem.material.color.set(e.target.value); | |
| }); | |
| function handleHandResults(results) { | |
| const led = document.getElementById('cam-led'); | |
| const txt = document.getElementById('cam-status'); | |
| if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { | |
| led.classList.remove('off'); | |
| txt.innerText = "Hand Active"; | |
| const hand = results.multiHandLandmarks[0]; | |
| // 1. Rotation (Hand Position) | |
| // Wrist is landmark 0 | |
| const wrist = hand[0]; | |
| // Normalize -0.5 to 0.5 | |
| state.rotationY = (wrist.x - 0.5) * 3; | |
| state.rotationX = (wrist.y - 0.5) * 3; | |
| // 2. Scale (Pinch Distance: Thumb Tip 4 vs Index Tip 8) | |
| const thumb = hand[4]; | |
| const index = hand[8]; | |
| const pinchDist = Math.sqrt( | |
| Math.pow(thumb.x - index.x, 2) + | |
| Math.pow(thumb.y - index.y, 2) | |
| ); | |
| // Map 0.02 (close) -> 0.1 (small) to 0.15 (far) -> 2.0 (big) | |
| state.scale = THREE.MathUtils.mapLinear(pinchDist, 0.02, 0.2, 0.5, 2.5); | |
| state.scale = THREE.MathUtils.clamp(state.scale, 0.5, 3.0); | |
| // 3. Chaos (Fist Detection) | |
| // Check average distance of tips to wrist | |
| const tips = [8, 12, 16, 20]; | |
| let avgDist = 0; | |
| tips.forEach(t => { | |
| const tip = hand[t]; | |
| avgDist += Math.sqrt(Math.pow(tip.x - wrist.x, 2) + Math.pow(tip.y - wrist.y, 2)); | |
| }); | |
| avgDist /= 4; | |
| // If tips are close to wrist, it's a fist | |
| if(avgDist < 0.12) { | |
| state.chaos = 1.0; // Explosion | |
| } else { | |
| state.chaos = 0.0; | |
| } | |
| } else { | |
| led.classList.add('off'); | |
| txt.innerText = "No Hand Detected"; | |
| state.rotationX = 0; | |
| state.rotationY = 0; | |
| state.scale = 1.0; | |
| state.chaos = 0; | |
| } | |
| } | |
| const hands = new Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`}); | |
| hands.setOptions({ | |
| maxNumHands: 1, | |
| modelComplexity: 1, | |
| minDetectionConfidence: 0.6, | |
| minTrackingConfidence: 0.6 | |
| }); | |
| hands.onResults(handleHandResults); | |
| const videoElem = document.getElementById('webcam-feed'); | |
| const cameraObj = new Camera(videoElem, { | |
| onFrame: async () => { await hands.send({image: videoElem}); }, | |
| width: CONFIG.camWidth, | |
| height: CONFIG.camHeight | |
| }); | |
| cameraObj.start(); | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = clock.getElapsedTime(); | |
| const positionsAttr = particleSystem.geometry.attributes.position; | |
| const posArray = positionsAttr.array; | |
| // Smooth Physics | |
| state.smoothedScale += (state.scale - state.smoothedScale) * 0.1; | |
| state.smoothedRotX += (state.rotationX - state.smoothedRotX) * 0.1; | |
| state.smoothedRotY += (state.rotationY - state.smoothedRotY) * 0.1; | |
| state.smoothedChaos += (state.chaos - state.smoothedChaos) * 0.1; | |
| particleSystem.rotation.y = state.smoothedRotY + dt * 0.1; | |
| particleSystem.rotation.x = state.smoothedRotX; | |
| const expansion = state.smoothedScale; | |
| const jitter = state.smoothedChaos * 0.8; | |
| for(let i=0; i<CONFIG.particleCount; i++) { | |
| const ix = i * 3; | |
| const iy = ix + 1; | |
| const iz = ix + 2; | |
| let tx = targetPositions[ix] * expansion; | |
| let ty = targetPositions[iy] * expansion; | |
| let tz = targetPositions[iz] * expansion; | |
| if(jitter > 0.01) { | |
| tx += (Math.random() - 0.5) * jitter * 5; | |
| ty += (Math.random() - 0.5) * jitter * 5; | |
| tz += (Math.random() - 0.5) * jitter * 5; | |
| } | |
| posArray[ix] += (tx - posArray[ix]) * 0.1; | |
| posArray[iy] += (ty - posArray[iy]) * 0.1; | |
| posArray[iz] += (tz - posArray[iz]) * 0.1; | |
| } | |
| positionsAttr.needsUpdate = true; | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |