From 8686c95f75b5097f1c19487e57131c31bc86b1cc Mon Sep 17 00:00:00 2001 From: AoodyConcorde <119140050+AndyXeCM@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:41:12 +0800 Subject: [PATCH] Add gesture-controlled Saturn particle experience --- README.md | 23 +++- app.js | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 28 +++++ styles.css | 122 ++++++++++++++++++++ 4 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/README.md b/README.md index f809425..8abcb22 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,23 @@ # StellarWeb -Stellar Website for testing + +A gesture-driven Three.js vignette that renders a living Saturn made from particles. Open your palm in front of the webcam to expand the rings; close your hand to condense them. The brightness of the planet breathes with its scale, and when it nears the viewport it picks up a chaotic Brownian jitter for a dramatic, cinematic feel. + +## Running locally + +This project is fully client-side. You can serve it with any static server; for example: + +```bash +npx serve . +# or +python -m http.server 8000 +``` + +Then open the printed URL in a modern browser that supports WebGL. Grant webcam permissions when prompted so the hand-tracking controller can read your palm. + +## Features + +- MediaPipe hand tracking that maps palm openness to planetary scale and dispersion. +- Particle Saturn core with Kepler-inspired orbital motion for the ring. +- Brightness modulation tied to size (small = dim, expanded = luminous). +- Chaos mode kicks in near the camera with jittery noise reminiscent of Brownian motion. +- Minimal HUD with fullscreen toggle and live gesture/brightness readouts. diff --git a/app.js b/app.js new file mode 100644 index 0000000..0ef5b7e --- /dev/null +++ b/app.js @@ -0,0 +1,327 @@ +import * as THREE from 'https://unpkg.com/three@0.161.0/build/three.module.js'; +import { FilesetResolver, HandLandmarker } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/vision_bundle.js'; + +const canvas = document.getElementById('bg'); +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); +renderer.setPixelRatio(window.devicePixelRatio || 1.2); +renderer.setSize(window.innerWidth, window.innerHeight); +renderer.setClearColor(0x000000, 0); + +const scene = new THREE.Scene(); +scene.background = new THREE.Color('#030611'); +scene.fog = new THREE.FogExp2('#030611', 0.003); + +const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2500); +camera.position.set(0, 22, 130); + +const ui = { + gesture: document.getElementById('gesture-status'), + brightness: document.getElementById('brightness-status'), + fullscreen: document.getElementById('fullscreen'), + video: document.getElementById('video'), +}; + +const clock = new THREE.Clock(); +const root = new THREE.Group(); +scene.add(root); + +const starfield = new THREE.Group(); +scene.add(starfield); + +const saturn = new THREE.Group(); +root.add(saturn); + +const params = { + minSpread: 0.62, + maxSpread: 2.6, + chaoticThreshold: 1.8, + baseBrightness: 0.28, + maxBrightness: 1.4, + keplerMu: 1200, +}; + +let coreParticles; +let ringParticles; +let ringState = []; +let brownianForce = 0; +let targetSpread = 1.1; +let currentSpread = 1.1; +let keplerTime = 0; +let coreBasePositions; + +function createStars() { + const starGeom = new THREE.BufferGeometry(); + const starCount = 2000; + const positions = new Float32Array(starCount * 3); + for (let i = 0; i < starCount; i += 1) { + const radius = THREE.MathUtils.randFloat(400, 1200); + const theta = THREE.MathUtils.randFloat(0, Math.PI * 2); + const phi = THREE.MathUtils.randFloat(0, Math.PI); + const x = radius * Math.sin(phi) * Math.cos(theta); + const y = radius * Math.cos(phi); + const z = radius * Math.sin(phi) * Math.sin(theta); + positions.set([x, y, z], i * 3); + } + starGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const starMat = new THREE.PointsMaterial({ + color: '#6fb6ff', + size: 1.1, + opacity: 0.8, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const stars = new THREE.Points(starGeom, starMat); + starfield.add(stars); +} + +function createCore() { + const particleCount = 4500; + const radius = 14; + const positions = new Float32Array(particleCount * 3); + const colors = new Float32Array(particleCount * 3); + for (let i = 0; i < particleCount; i += 1) { + const u = Math.random(); + const v = Math.random(); + const theta = 2 * Math.PI * u; + const phi = Math.acos(2 * v - 1); + const r = radius * Math.cbrt(Math.random()); + const x = r * Math.sin(phi) * Math.cos(theta); + const y = r * Math.cos(phi); + const z = r * Math.sin(phi) * Math.sin(theta); + positions.set([x, y, z], i * 3); + const hue = 0.6 + Math.random() * 0.08; + const lightness = 0.4 + Math.random() * 0.35; + const color = new THREE.Color().setHSL(hue, 0.85, lightness); + colors.set([color.r, color.g, color.b], i * 3); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + coreBasePositions = positions.slice(); + const material = new THREE.PointsMaterial({ + size: 1.35, + vertexColors: true, + transparent: true, + opacity: 0.9, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + coreParticles = new THREE.Points(geometry, material); + saturn.add(coreParticles); +} + +function createRing() { + const particleCount = 3200; + const positions = new Float32Array(particleCount * 3); + const colors = new Float32Array(particleCount * 3); + ringState = new Array(particleCount); + + for (let i = 0; i < particleCount; i += 1) { + const radius = THREE.MathUtils.randFloat(26, 56); + const angle = Math.random() * Math.PI * 2; + const height = THREE.MathUtils.randFloatSpread(4); + const x = radius * Math.cos(angle); + const z = radius * Math.sin(angle); + const y = height; + positions.set([x, y, z], i * 3); + const hue = 0.58 + Math.random() * 0.06; + const lightness = 0.55 + Math.random() * 0.3; + const color = new THREE.Color().setHSL(hue, 0.75, lightness); + colors.set([color.r, color.g, color.b], i * 3); + ringState[i] = { radius, angle, height }; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + const material = new THREE.PointsMaterial({ + size: 1.25, + vertexColors: true, + transparent: true, + opacity: 0.9, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + ringParticles = new THREE.Points(geometry, material); + saturn.add(ringParticles); +} + +function updateRing(delta, brightness) { + const positions = ringParticles.geometry.attributes.position.array; + keplerTime += delta; + const chaotic = currentSpread > params.chaoticThreshold; + brownianForce = THREE.MathUtils.lerp(brownianForce, chaotic ? 8.5 : 0.6, 0.05); + + for (let i = 0; i < ringState.length; i += 1) { + const state = ringState[i]; + const mu = params.keplerMu; + const omega = Math.sqrt(mu / Math.pow(state.radius, 3)); + state.angle += omega * delta * (1.1 + 0.4 * Math.sin(keplerTime * 0.4)); + const radius = state.radius * currentSpread; + const x = radius * Math.cos(state.angle); + const z = radius * Math.sin(state.angle); + const y = state.height * currentSpread * THREE.MathUtils.lerp(1, 0.35, brightness); + const noiseAmp = brownianForce * delta * 12; + positions[i * 3] = x + (Math.random() - 0.5) * noiseAmp; + positions[i * 3 + 1] = y + (Math.random() - 0.5) * noiseAmp * 0.5; + positions[i * 3 + 2] = z + (Math.random() - 0.5) * noiseAmp; + } + ringParticles.geometry.attributes.position.needsUpdate = true; +} + +function updateCore(brightness) { + const positions = coreParticles.geometry.attributes.position.array; + const len = positions.length / 3; + const noiseAmp = brownianForce * 0.6; + for (let i = 0; i < len; i += 1) { + const idx = i * 3; + const baseX = coreBasePositions[idx]; + const baseY = coreBasePositions[idx + 1]; + const baseZ = coreBasePositions[idx + 2]; + positions[idx] = baseX * currentSpread * 0.75 + (Math.random() - 0.5) * noiseAmp; + positions[idx + 1] = baseY * currentSpread * 0.75 + (Math.random() - 0.5) * noiseAmp; + positions[idx + 2] = baseZ * currentSpread * 0.75 + (Math.random() - 0.5) * noiseAmp; + } + coreParticles.material.opacity = THREE.MathUtils.clamp(brightness, 0.2, 1.0); + coreParticles.material.size = 1.1 + brightness * 1.5; + coreParticles.geometry.attributes.position.needsUpdate = true; +} + +function animate() { + const delta = clock.getDelta(); + const lerpFactor = 0.06; + currentSpread = THREE.MathUtils.lerp(currentSpread, targetSpread, lerpFactor); + const brightness = THREE.MathUtils.clamp( + params.baseBrightness + (currentSpread - params.minSpread) / (params.maxSpread - params.minSpread) * (params.maxBrightness - params.baseBrightness), + params.baseBrightness, + params.maxBrightness, + ); + + updateRing(delta, brightness); + updateCore(brightness); + + saturn.rotation.y += delta * 0.06; + starfield.rotation.y -= delta * 0.002; + + const lookAtPulse = Math.sin(clock.elapsedTime * 0.4) * 4; + camera.position.y = 22 + lookAtPulse; + camera.lookAt(new THREE.Vector3(0, 8, 0)); + + renderer.render(scene, camera); + ui.brightness.textContent = `${brightness.toFixed(2)}`; + requestAnimationFrame(animate); +} + +function onResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +} + +function setupFullscreen() { + ui.fullscreen.addEventListener('click', () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + document.exitFullscreen(); + } + }); +} + +function mapGestureToSpread(openness) { + targetSpread = THREE.MathUtils.lerp(params.minSpread, params.maxSpread, openness); +} + +function computeHandOpenness(landmarks) { + const palmIndices = [0, 5, 9, 13, 17]; + const palm = palmIndices.reduce((sum, idx) => { + const lm = landmarks[idx]; + sum.x += lm.x; sum.y += lm.y; sum.z += lm.z; return sum; + }, { x: 0, y: 0, z: 0 }); + palm.x /= palmIndices.length; + palm.y /= palmIndices.length; + palm.z /= palmIndices.length; + + const tipIndices = [4, 8, 12, 16, 20]; + let total = 0; + tipIndices.forEach((idx) => { + const lm = landmarks[idx]; + const dx = lm.x - palm.x; + const dy = lm.y - palm.y; + const dz = lm.z - palm.z; + total += Math.sqrt(dx * dx + dy * dy + dz * dz); + }); + const avg = total / tipIndices.length; + const openness = THREE.MathUtils.clamp((avg - 0.05) / 0.18, 0, 1); + return openness; +} + +let handLandmarker; +let lastVideoTime = -1; +let hasHand = false; + +async function setupHandTracking() { + try { + const filesetResolver = await FilesetResolver.forVisionTasks( + 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm', + ); + handLandmarker = await HandLandmarker.createFromOptions(filesetResolver, { + baseOptions: { + modelAssetPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/hand_landmarker.task', + }, + runningMode: 'VIDEO', + numHands: 1, + }); + + const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false }); + ui.video.srcObject = stream; + ui.video.onloadeddata = () => { + ui.gesture.textContent = 'Raise your hand in view'; + detectLoop(); + }; + } catch (err) { + console.error(err); + ui.gesture.textContent = 'Enable camera to control the system'; + } +} + +async function detectLoop() { + if (!handLandmarker) return; + const videoTime = ui.video.currentTime; + if (videoTime === lastVideoTime) { + requestAnimationFrame(detectLoop); + return; + } + lastVideoTime = videoTime; + + const results = handLandmarker.detectForVideo(ui.video, performance.now()); + if (results.landmarks.length > 0) { + hasHand = true; + const openness = computeHandOpenness(results.landmarks[0]); + mapGestureToSpread(openness); + ui.gesture.textContent = `Open ${Math.round(openness * 100)}%`; + } else { + if (hasHand) { + ui.gesture.textContent = 'Hand lost - drifting'; + } else { + ui.gesture.textContent = 'Searching for hand...'; + } + hasHand = false; + targetSpread = THREE.MathUtils.lerp(targetSpread, 1.15, 0.04); + } + requestAnimationFrame(detectLoop); +} + +function init() { + createStars(); + createCore(); + createRing(); + animate(); + setupFullscreen(); + setupHandTracking(); + window.addEventListener('resize', onResize); +} + +init(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..7acb15e --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + + Saturn Gesture Particles + + + + + + +
+
+
Saturn Gesture Field
+
Open your palm to amplify the ring; close to condense.
+
+
Gesture: Initializing camera...
+
Brightness: --
+
+
+ +
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..8b0ca7a --- /dev/null +++ b/styles.css @@ -0,0 +1,122 @@ +* { + box-sizing: border-box; +} + +:root { + --bg: radial-gradient(circle at 20% 20%, #0b1224 0%, #050812 55%, #01020a 100%); + --card: rgba(255, 255, 255, 0.06); + --text: #e9f1ff; + --muted: #9ab1d6; + --accent: #7dd1ff; +} + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #02040c; + color: var(--text); +} + +#bg { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + display: block; +} + +#ui { + position: fixed; + top: 24px; + left: 24px; + display: flex; + gap: 12px; + align-items: center; + z-index: 10; + backdrop-filter: blur(16px); +} + +.card { + padding: 14px 16px; + border-radius: 16px; + background: var(--card); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 50px rgba(0,0,0,0.35); + min-width: 280px; +} + +.title { + font-weight: 600; + letter-spacing: 0.01em; + margin-bottom: 6px; +} + +.subtitle { + font-size: 13px; + color: var(--muted); + margin-bottom: 10px; +} + +.stats { + display: grid; + gap: 4px; + font-size: 13px; +} + +.label { + color: var(--muted); + margin-right: 4px; +} + +#fullscreen { + border: none; + border-radius: 14px; + background: linear-gradient(135deg, #3f6bff 0%, #7df3ff 100%); + color: #001022; + font-weight: 600; + padding: 12px 14px; + cursor: pointer; + box-shadow: 0 10px 40px rgba(64, 152, 255, 0.4); + transition: transform 150ms ease, box-shadow 150ms ease; +} + +#fullscreen:hover { + transform: translateY(-2px); + box-shadow: 0 14px 50px rgba(64, 152, 255, 0.6); +} + +#fullscreen:active { + transform: translateY(0); +} + +#video { + position: fixed; + right: 16px; + bottom: 16px; + width: 140px; + height: 96px; + border-radius: 12px; + object-fit: cover; + opacity: 0.22; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + pointer-events: none; +} + +@media (max-width: 720px) { + #ui { + top: 12px; + left: 12px; + flex-direction: column; + align-items: flex-start; + } + + #video { + width: 120px; + height: 80px; + } +}