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;
+ }
+}