From 3102e775fe236359cf9a131c633b308e0d8272e2 Mon Sep 17 00:00:00 2001 From: AoodyConcorde <119140050+AndyXeCM@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:21:22 +0800 Subject: [PATCH] Ensure camera prompt fires with user-gesture fallback --- README.md | 20 ++- index.html | 31 +++++ main.js | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 225 ++++++++++++++++++++++++++++++++ 4 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 index.html create mode 100644 main.js create mode 100644 styles.css diff --git a/README.md b/README.md index f809425..d5ba1b1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,20 @@ # StellarWeb -Stellar Website for testing + +一个使用 Three.js + MediaPipe 打造的实时 3D 粒子「土星」实验,支持手掌张合来控制星环的收束与扩散。 + +## 功能亮点 +- 手势驱动:张开手掌放大、扩散粒子,握拳收拢,实时平滑响应。 +- 物理直觉:粒子环按照开普勒定律计算角速度,展现近似真实的轨道运动。 +- 视觉表现:粒子构成的土星核心与环带,亮度会随尺度改变;极大时叠加高频布朗噪点,让粒子「炸开」。 +- 现代界面:全屏切换按钮、磨砂玻璃 HUD,摄像头画面微透叠加。 + +## 本地运行 +无需打包工具,直接用任意静态服务器打开根目录即可(需 HTTPS 或 localhost 以启用摄像头权限)。例如: + +```bash +python -m http.server 8000 +# 或 +npx serve . +``` + +然后在浏览器访问 `http://localhost:8000`,页面会自动触发摄像头授权;如未出现弹窗,点击页面任意位置或右上角的「重新尝试」按钮进行授权,即可体验手势交互。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..c68f499 --- /dev/null +++ b/index.html @@ -0,0 +1,31 @@ + + + + + + Saturn Particles + + + + +
+
+
+
+
Saturn Pulse
+
张开掌心放大群星,握拳收束光环
+
+
+ 正在主动申请摄像头权限…如未看到弹窗,轻触页面 + + +
+
+ +
+ + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..ac724e7 --- /dev/null +++ b/main.js @@ -0,0 +1,369 @@ +import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js'; +import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.165.0/examples/jsm/controls/OrbitControls.js'; + +const canvasHost = document.getElementById('canvas-host'); +const indicator = document.getElementById('gesture-indicator'); +const fullscreenBtn = document.getElementById('fullscreen-btn'); +const startBtn = document.getElementById('start-btn'); +const video = document.getElementById('input-video'); + +let renderer, scene, camera, controls; +let saturnSystem; +let gesture = { openness: 0.5, hasHand: false }; +let chaosClock = 0; +let hands, mpCamera; +let permissionTimeout; +let hasTriggeredGestureRequest = false; +let isRequesting = false; + +init(); + +function init() { + scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2('#02030c', 0.005); + + camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 2000); + camera.position.set(0, 30, 120); + + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.2; + renderer.outputEncoding = THREE.sRGBEncoding; + canvasHost.appendChild(renderer.domElement); + + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.06; + controls.autoRotate = true; + controls.autoRotateSpeed = 0.35; + + const hemiLight = new THREE.HemisphereLight('#a1d8ff', '#0b0b12', 0.9); + scene.add(hemiLight); + const pointLight = new THREE.PointLight('#8ac5ff', 4.5, 400, 2); + pointLight.position.set(0, 0, 0); + scene.add(pointLight); + + saturnSystem = new SaturnSystem(scene); + + requestCameraAccess(); + armUserGestureFallback(); + window.addEventListener('resize', onResize); + fullscreenBtn.addEventListener('click', toggleFullscreen); + startBtn.addEventListener('click', requestCameraAccess); + animate(); +} + +function requestCameraAccess() { + if (isRequesting) return; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + indicator.textContent = '当前环境无法访问摄像头(需 HTTPS / localhost)'; + return; + } + + isRequesting = true; + hasTriggeredGestureRequest = true; + startBtn.classList.add('hidden'); + indicator.textContent = '正在唤起浏览器的摄像头授权弹窗… 如未出现,请点击页面任意处'; + + clearTimeout(permissionTimeout); + permissionTimeout = setTimeout(() => { + if (!gesture.hasHand && video.srcObject == null) { + startBtn.textContent = '重新尝试授权'; + startBtn.classList.remove('hidden'); + indicator.textContent = '未检测到授权弹窗,已超时;请点击任意处或“重新尝试”,并检查浏览器权限'; + } + }, 2800); + + navigator.mediaDevices + .getUserMedia({ video: { facingMode: 'user', width: 640, height: 480 } }) + .then((stream) => { + video.srcObject = stream; + video.play().catch(() => {}); + startHandTracking(); + indicator.textContent = '已获得摄像头权限,正在启动手势跟踪…'; + isRequesting = false; + }) + .catch((err) => { + console.error('Camera access error', err); + indicator.textContent = '摄像头权限被拒绝或不可用'; + startBtn.textContent = '重新尝试授权'; + startBtn.classList.remove('hidden'); + isRequesting = false; + }); +} + +function armUserGestureFallback() { + const handler = () => { + if (!hasTriggeredGestureRequest && !video.srcObject) { + requestCameraAccess(); + } + }; + + const onceHandler = () => { + handler(); + window.removeEventListener('pointerdown', onceHandler); + window.removeEventListener('keydown', onceHandler); + }; + + window.addEventListener('pointerdown', onceHandler, { passive: true }); + window.addEventListener('keydown', onceHandler); +} + +function startHandTracking() { + if (!window.Hands || !window.Camera) { + indicator.textContent = '手势检测脚本加载失败'; + startBtn.classList.remove('hidden'); + return; + } + + if (!hands) { + hands = new Hands({ + locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4/${file}`, + }); + hands.onResults((results) => { + if (!results.multiHandLandmarks || !results.multiHandLandmarks.length) { + gesture.hasHand = false; + indicator.textContent = '将手掌举至画面中'; + return; + } + + const lm = results.multiHandLandmarks[0]; + gesture.hasHand = true; + const openness = computeOpenness(lm); + gesture.openness = THREE.MathUtils.lerp(gesture.openness, openness, 0.15); + indicator.textContent = `手掌张开度 ${(gesture.openness * 100).toFixed(0)}%`; + }); + } + + hands.setOptions({ + maxNumHands: 1, + modelComplexity: 1, + selfieMode: true, + minDetectionConfidence: 0.6, + minTrackingConfidence: 0.6, + }); + + if (mpCamera?.stop) { + mpCamera.stop(); + } + + mpCamera = new Camera(video, { + onFrame: async () => { + await hands.send({ image: video }); + }, + width: 640, + height: 480, + }); + mpCamera.start(); + indicator.textContent = '手势跟踪中'; + startBtn.classList.add('hidden'); +} + +function computeOpenness(landmarks) { + const wrist = landmarks[0]; + const indexTip = landmarks[8]; + const middleTip = landmarks[12]; + const ringTip = landmarks[16]; + const pinkyTip = landmarks[20]; + const thumbTip = landmarks[4]; + const palmCenter = average([wrist, landmarks[5], landmarks[9], landmarks[13], landmarks[17]]); + + const spread = + distance(indexTip, palmCenter) + + distance(middleTip, palmCenter) + + distance(ringTip, palmCenter) + + distance(pinkyTip, palmCenter) + + distance(thumbTip, palmCenter); + + const minSpread = 0.5; + const maxSpread = 2.2; + return THREE.MathUtils.clamp((spread - minSpread) / (maxSpread - minSpread), 0, 1); +} + +function distance(a, b) { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +function average(points) { + const sum = points.reduce( + (acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y, z: acc.z + p.z }), + { x: 0, y: 0, z: 0 } + ); + return { x: sum.x / points.length, y: sum.y / points.length, z: sum.z / points.length }; +} + +class SaturnSystem { + constructor(scene) { + this.scene = scene; + this.clock = new THREE.Clock(); + this.mu = 500.0; + this.baseScale = 58; + + this.sphere = this.createCore(); + this.ring = this.createRing(); + this.glow = this.createGlow(); + this.scene.add(this.sphere, this.ring, this.glow); + } + + createCore() { + const count = 2400; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + const phi = Math.random() * Math.PI * 2; + const costheta = Math.random() * 2 - 1; + const u = Math.random(); + const theta = Math.acos(costheta); + const r = Math.cbrt(u) * 18; + const x = r * Math.sin(theta) * Math.cos(phi); + const y = r * Math.sin(theta) * Math.sin(phi); + const z = r * Math.cos(theta); + positions.set([x, y, z], i * 3); + + const hue = 0.58 + Math.random() * 0.08; + const color = new THREE.Color().setHSL(hue, 0.55, 0.55 + Math.random() * 0.2); + colors.set([color.r, color.g, color.b], i * 3); + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + const mat = new THREE.PointsMaterial({ + size: 1.5, + transparent: true, + opacity: 0.55, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexColors: true, + }); + + return new THREE.Points(geo, mat); + } + + createRing() { + const count = 1500; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + this.radii = new Float32Array(count); + this.angles = new Float32Array(count); + this.inclinations = new Float32Array(count); + this.angularVelocities = new Float32Array(count); + + for (let i = 0; i < count; i++) { + const r = 26 + Math.random() * 28; + this.radii[i] = r; + this.angles[i] = Math.random() * Math.PI * 2; + this.inclinations[i] = THREE.MathUtils.degToRad((Math.random() - 0.5) * 14); + this.angularVelocities[i] = Math.sqrt(this.mu / Math.pow(r, 3)) * THREE.MathUtils.randFloat(0.8, 1.2); + + const hue = 0.58 + Math.random() * 0.1; + const color = new THREE.Color().setHSL(hue, 0.55, 0.65); + colors.set([color.r, color.g, color.b], i * 3); + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + const mat = new THREE.PointsMaterial({ + size: 1.2, + transparent: true, + opacity: 0.65, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexColors: true, + }); + + this.ringPositions = positions; + return new THREE.Points(geo, mat); + } + + createGlow() { + const geo = new THREE.SphereGeometry(28, 64, 64); + const mat = new THREE.MeshBasicMaterial({ + color: '#d9e7ff', + transparent: true, + opacity: 0.18, + side: THREE.BackSide, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.scale.setScalar(1.8); + return mesh; + } + + update(scaleNormalized) { + const dt = this.clock.getDelta(); + const coreScale = THREE.MathUtils.lerp(0.5, 1.65, scaleNormalized); + this.sphere.scale.setScalar(coreScale); + this.glow.scale.setScalar(2.4 * coreScale); + + const brightness = THREE.MathUtils.lerp(0.35, 1.2, scaleNormalized); + renderer.toneMappingExposure = 0.6 + brightness * 1.3; + this.sphere.material.opacity = 0.35 + brightness * 0.45; + + this.animateRing(dt, scaleNormalized, brightness); + this.sphere.rotation.y += 0.02 * dt; + this.glow.rotation.y += 0.01 * dt; + } + + animateRing(dt, scaleNormalized, brightness) { + const chaos = Math.max(0, scaleNormalized - 0.78); + chaosClock += dt * 40 * (1 + chaos * 2.5); + + const scaleFactor = THREE.MathUtils.lerp(0.85, 2.4, scaleNormalized); + const pos = this.ringPositions; + for (let i = 0; i < this.radii.length; i++) { + this.angles[i] += this.angularVelocities[i] * dt * (1.0 + scaleNormalized * 0.6); + const r = this.radii[i] * scaleFactor; + const angle = this.angles[i]; + const inc = this.inclinations[i]; + + let x = r * Math.cos(angle); + let z = r * Math.sin(angle); + let y = Math.sin(inc) * r * 0.08; + + if (chaos > 0.0001) { + const noiseAmp = chaos * chaos * 24; + x += (Math.sin(chaosClock * 0.9 + i) + Math.random() - 0.5) * noiseAmp; + y += (Math.cos(chaosClock * 1.1 + i * 1.7) + Math.random() - 0.5) * noiseAmp * 0.6; + z += (Math.sin(chaosClock * 1.3 + i * 0.7) + Math.random() - 0.5) * noiseAmp; + } + + pos[i * 3 + 0] = x; + pos[i * 3 + 1] = y; + pos[i * 3 + 2] = z; + } + + this.ring.geometry.attributes.position.needsUpdate = true; + this.ring.material.opacity = 0.4 + brightness * 0.5; + this.ring.material.size = THREE.MathUtils.lerp(1.2, 2.5, scaleNormalized); + } +} + +function animate() { + requestAnimationFrame(animate); + const openness = gesture.hasHand ? gesture.openness : 0.5 + Math.sin(performance.now() * 0.0007) * 0.25; + saturnSystem.update(openness); + controls.update(); + renderer.render(scene, camera); +} + +function onResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +} + +function toggleFullscreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + document.exitFullscreen(); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..3d63956 --- /dev/null +++ b/styles.css @@ -0,0 +1,225 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap'); + +:root { + color-scheme: dark; + --bg: radial-gradient(120% 120% at 20% 20%, #0d1b2a 0%, #05070f 55%, #010104 100%); + --glass: rgba(255, 255, 255, 0.08); + --glass-strong: rgba(255, 255, 255, 0.14); + --accent: #8ad7ff; + --accent-2: #c7a2ff; + --text: #eaf1ff; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Space Grotesk', 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + overflow: hidden; +} + +body::before { + content: ''; + position: fixed; + inset: -20% -10% auto auto; + width: 520px; + height: 520px; + background: radial-gradient(circle at 30% 30%, rgba(117, 209, 255, 0.22), rgba(0, 0, 0, 0)); + filter: blur(40px); + opacity: 0.9; + pointer-events: none; + mix-blend-mode: screen; +} + +#app { + position: relative; + width: 100vw; + height: 100vh; + background: var(--bg); +} + +#canvas-host { + position: absolute; + inset: 0; +} + +canvas { + display: block; +} + +.hud { + position: absolute; + left: 22px; + right: 22px; + top: 22px; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 14px; + padding: 16px 18px; + border-radius: 18px; + background: linear-gradient(140deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(18px) saturate(150%); + box-shadow: 0 18px 70px rgba(0, 0, 0, 0.4); + pointer-events: none; +} + +.hud__group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.hud__title { + font-size: 19px; + font-weight: 700; + letter-spacing: 0.05em; + display: flex; + align-items: center; + gap: 10px; +} + +.hud__title::before { + content: ''; + width: 12px; + height: 12px; + border-radius: 10px; + background: radial-gradient(circle at 30% 30%, #b7f1ff, #5cc0ff 70%); + box-shadow: 0 0 20px rgba(138, 215, 255, 0.6); +} + +.hud__subtitle { + font-size: 13px; + color: rgba(234, 241, 255, 0.8); + letter-spacing: 0.01em; +} + +.hud__status { + align-items: center; + text-align: right; + pointer-events: all; + gap: 0.6rem; +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + padding: 8px 12px; + border-radius: 999px; + background: linear-gradient(120deg, rgba(154, 214, 255, 0.2), rgba(199, 162, 255, 0.2)); + border: 1px solid rgba(255, 255, 255, 0.16); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); +} + +.status-chip::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: #8ad7ff; + box-shadow: 0 0 10px rgba(138, 215, 255, 0.8); +} + +.pill { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.06)); + color: #fff; + padding: 10px 16px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 800; + backdrop-filter: blur(12px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.pill:hover { + transform: translateY(-1px) scale(1.02); + border-color: rgba(255, 255, 255, 0.32); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.08)); +} + +.pill:active { + transform: translateY(0) scale(0.98); +} + +.pill.hidden { + display: none; +} + +#fullscreen-btn { + margin-top: 6px; + align-self: flex-end; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: linear-gradient(135deg, rgba(154, 214, 255, 0.25), rgba(240, 195, 255, 0.12)); + color: var(--text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} + +#fullscreen-btn:hover { + transform: translateY(-1px) scale(1.02); + border-color: rgba(154, 214, 255, 0.5); + background: linear-gradient(135deg, rgba(154, 214, 255, 0.35), rgba(240, 195, 255, 0.22)); +} + +#fullscreen-btn:active { + transform: translateY(0) scale(0.99); +} + +#input-video { + position: fixed; + width: 140px; + bottom: 24px; + right: 24px; + opacity: 0.12; + filter: grayscale(0.3) contrast(1.1); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(12px) saturate(140%); +} + +@media (max-width: 640px) { + .hud { + grid-template-columns: 1fr; + align-items: flex-start; + top: 12px; + left: 12px; + right: 12px; + } + + .hud__group { + width: 100%; + } + + #gesture-indicator { + text-align: left; + } + + #fullscreen-btn { + align-self: flex-start; + } + + #input-video { + width: 90px; + right: 12px; + bottom: 12px; + } +}