// 全局变量 let scene, camera, renderer; let particles, geometry, material; let particleCount = 20000; // 粒子数量 let currentPositions = new Float32Array(particleCount * 3); let targetPositions = new Float32Array(particleCount * 3); let basePositions = new Float32Array(particleCount * 3); // 存储当前形状的基础位置(未受手势影响) let colors = new Float32Array(particleCount * 3); let handOpenness = 0.5; // 0: 闭合, 1: 张开 (默认0.5) let isHandDetected = false; let clock = new THREE.Clock(); let countdownInterval; // UI 参数 const params = { model: '爱心', color: '#ff0077', pointSize: 0.15, mixColor: true, // 是否混合颜色 handInfluence: 2.0 // 手势影响强度 }; const models = ['爱心', '花朵', '土星', '烟花', '山水', '数字倒数']; // 初始化 init(); initHands(); animate(); function init() { // 1. 场景 scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x000000, 0.02); // 2. 相机 camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.z = 30; // 3. 渲染器 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); document.getElementById('canvas-container').appendChild(renderer.domElement); // 4. 粒子系统 geometry = new THREE.BufferGeometry(); // 初始化位置 (随机分布) for (let i = 0; i < particleCount; i++) { currentPositions[i * 3] = (Math.random() - 0.5) * 100; currentPositions[i * 3 + 1] = (Math.random() - 0.5) * 100; currentPositions[i * 3 + 2] = (Math.random() - 0.5) * 100; colors[i * 3] = 1; colors[i * 3 + 1] = 1; colors[i * 3 + 2] = 1; } geometry.setAttribute('position', new THREE.BufferAttribute(currentPositions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // 材质 material = new THREE.PointsMaterial({ size: params.pointSize, vertexColors: true, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.8 }); particles = new THREE.Points(geometry, material); scene.add(particles); // 5. 初始化第一个模型 updateShape('爱心'); // 6. UI 面板 const gui = new lil.GUI({ container: document.getElementById('ui-container') }); const statusObj = { status: '等待摄像头...' }; gui.add(statusObj, 'status').name('状态').listen().disable(); gui.add(params, 'model', models).onChange(updateShape).name('模型选择'); gui.addColor(params, 'color').onChange(updateColor).name('粒子颜色'); gui.add(params, 'pointSize', 0.01, 1).onChange(v => material.size = v).name('粒子大小'); gui.add(params, 'handInfluence', 0, 5).name('手势强度'); // 把 statusObj 挂到全局以便更新 window.statusObj = statusObj; // 全屏按钮 document.getElementById('fullscreen-btn').addEventListener('click', toggleFullScreen); // 窗口大小调整 window.addEventListener('resize', onWindowResize); } // === 粒子形状生成逻辑 === function updateShape(shapeType) { // 清除倒计时(如果有) if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } params.model = shapeType; let newPos; // 重置颜色(如果是从彩色模式切换回来) updateColor(params.color); switch (shapeType) { case '爱心': newPos = getHeartPoints(); break; case '花朵': newPos = getFlowerPoints(); break; case '土星': newPos = getSaturnPoints(); break; case '烟花': newPos = getFireworksPoints(); break; case '山水': newPos = getMountainPoints(); break; case '数字倒数': startCountdown(); return; // 倒计时有特殊逻辑,不直接设置静态位置 default: newPos = getHeartPoints(); } // 设置目标位置 for (let i = 0; i < particleCount * 3; i++) { // 如果新生成的点不够,就归零或随机;如果多了,多余的忽略 if (i < newPos.length) { basePositions[i] = newPos[i]; } else { basePositions[i] = (Math.random() - 0.5) * 50; // 散落 } } // 烟花特效特殊处理:给随机颜色 if (shapeType === '烟花') { const colorArr = geometry.attributes.color.array; for (let i = 0; i < particleCount; i++) { const color = new THREE.Color(); color.setHSL(Math.random(), 1.0, 0.5); colorArr[i * 3] = color.r; colorArr[i * 3 + 1] = color.g; colorArr[i * 3 + 2] = color.b; } geometry.attributes.color.needsUpdate = true; } } // 1. 爱心 (参数方程) function getHeartPoints() { const pts = []; for (let i = 0; i < particleCount; i++) { // 心形曲面或线条,这里用稍微立体的分布 let t = Math.random() * Math.PI * 2; let u = Math.random() * Math.PI; // 用于立体填充 // 基础 2D 心形 // x = 16sin^3(t) // y = 13cos(t) - 5cos(2t) - 2cos(3t) - cos(4t) // 稍微随机化 t 以填充内部 let r = Math.sqrt(Math.random()); let x = 16 * Math.pow(Math.sin(t), 3); let y = 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t); // 扩展到 3D: z 轴做一定的厚度 let z = (Math.random() - 0.5) * 5; // 缩放 x *= r; y *= r; z *= r; pts.push(x, y, z); } return pts; } // 2. 花朵 (极坐标玫瑰线拓展) function getFlowerPoints() { const pts = []; for (let i = 0; i < particleCount; i++) { const theta = Math.random() * Math.PI * 2; const phi = Math.random() * Math.PI; // 3D 球形分布基础 // 玫瑰线 r = cos(k*theta) const k = 4; // 4瓣 let r = Math.abs(Math.cos(k * theta)) * 10 + 2; // 添加花蕊 if (Math.random() > 0.8) { r = Math.random() * 2; // 花蕊集中在中心 } const x = r * Math.sin(phi) * Math.cos(theta); const y = r * Math.sin(phi) * Math.sin(theta); const z = r * Math.cos(phi) * 0.5; // 压扁一点 pts.push(x, y, z); } return pts; } // 3. 土星 (球体 + 环) function getSaturnPoints() { const pts = []; const sphereCount = particleCount * 0.4; const ringCount = particleCount * 0.6; // 星体 for (let i = 0; i < sphereCount; i++) { const r = 8; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const x = r * Math.sin(phi) * Math.cos(theta); const y = r * Math.sin(phi) * Math.sin(theta); const z = r * Math.cos(phi); pts.push(x, y, z); } // 光环 for (let i = 0; i < ringCount; i++) { const innerR = 12; const outerR = 20; const r = innerR + Math.random() * (outerR - innerR); const theta = Math.random() * Math.PI * 2; const x = r * Math.cos(theta); const z = r * Math.sin(theta); const y = (Math.random() - 0.5) * 0.5; // 环很薄 // 稍微倾斜 // 绕 x 轴旋转 30度 const angle = Math.PI / 6; const y_rot = y * Math.cos(angle) - z * Math.sin(angle); const z_rot = y * Math.sin(angle) + z * Math.cos(angle); pts.push(x, y_rot, z_rot); } return pts; } // 4. 烟花 (球形爆炸) function getFireworksPoints() { const pts = []; for (let i = 0; i < particleCount; i++) { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const r = Math.random() * 25; // 扩散半径大 // 更多粒子集中在边缘,模拟爆炸冲击波 const rFinal = Math.pow(r/25, 0.5) * 25; const x = rFinal * Math.sin(phi) * Math.cos(theta); const y = rFinal * Math.sin(phi) * Math.sin(theta); const z = rFinal * Math.cos(phi); pts.push(x, y, z); } return pts; } // 5. 山水 (波浪地形) function getMountainPoints() { const pts = []; const width = 60; const depth = 40; for (let i = 0; i < particleCount; i++) { const x = (Math.random() - 0.5) * width; const z = (Math.random() - 0.5) * depth; // 多重正弦波模拟山脉 let y = Math.sin(x * 0.2) * 4 + Math.cos(z * 0.3) * 3 + Math.sin(x*0.5 + z*0.5) * 2; y -= 10; // 整体下移 // 底部淡出或增加粒子密度 pts.push(x, y, z); } return pts; } // 6. 数字倒数 function startCountdown() { if (countdownInterval) clearInterval(countdownInterval); let num = 5; updateNumber(num); countdownInterval = setInterval(() => { num--; if (num < 0) num = 5; // 循环 updateNumber(num); }, 1000); } function updateNumber(n) { // 使用 Canvas 生成文字点阵 const text = n.toString(); const cvs = document.createElement('canvas'); cvs.width = 100; cvs.height = 100; const ctx = cvs.getContext('2d'); ctx.fillStyle = '#000000'; // bg ctx.fillRect(0,0,100,100); ctx.fillStyle = '#ffffff'; // text ctx.font = 'bold 80px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 50, 50); const data = ctx.getImageData(0, 0, 100, 100).data; const validPixels = []; for (let y = 0; y < 100; y++) { for (let x = 0; x < 100; x++) { const index = (y * 100 + x) * 4; if (data[index] > 128) { // 亮度判断 validPixels.push({x: x - 50, y: -(y - 50)}); // y翻转 } } } // 映射到粒子 if (validPixels.length === 0) return; for (let i = 0; i < particleCount * 3; i+=3) { const pid = i / 3; // 随机取一个像素点 const p = validPixels[pid % validPixels.length]; // 加上一些随机抖动填补空隙 const scale = 0.4; basePositions[i] = p.x * scale + (Math.random() - 0.5) * 0.5; basePositions[i+1] = p.y * scale + (Math.random() - 0.5) * 0.5; basePositions[i+2] = (Math.random() - 0.5) * 2; } } // === 通用功能 === function updateColor(hexStr) { if (params.model === '烟花') return; // 烟花保持彩色 const color = new THREE.Color(hexStr); const colorArr = geometry.attributes.color.array; for (let i = 0; i < particleCount; i++) { colorArr[i * 3] = color.r; colorArr[i * 3 + 1] = color.g; colorArr[i * 3 + 2] = color.b; } geometry.attributes.color.needsUpdate = true; } // === MediaPipe Hands === async function initHands() { const videoElement = document.getElementById('input_video'); const statusDiv = document.getElementById('loading'); // 1. 初始化 MediaPipe 实例 const hands = new Hands({locateFile: (file) => { return `https://unpkg.com/@mediapipe/hands/${file}`; }}); hands.setOptions({ maxNumHands: 1, modelComplexity: 1, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 }); hands.onResults(onHandResults); // 2. 尝试获取摄像头流 (原生 getUserMedia) try { statusDiv.textContent = '正在请求摄像头权限...'; // 检查浏览器支持 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('您的浏览器不支持摄像头访问'); } const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } }); videoElement.srcObject = stream; // 等待视频元数据加载完成 await new Promise((resolve) => { videoElement.onloadedmetadata = () => { resolve(); }; }); await videoElement.play(); statusDiv.textContent = '加载 AI 模型中...'; // 3. 启动检测循环 async function detectionLoop() { // 确保视频正在播放且有数据 if (videoElement.currentTime > 0 && !videoElement.paused && !videoElement.ended) { await hands.send({image: videoElement}); } requestAnimationFrame(detectionLoop); } // 发送第一帧以预热模型 await hands.send({image: videoElement}); statusDiv.style.display = 'none'; detectionLoop(); } catch (err) { console.error('Camera/MediaPipe Error:', err); statusDiv.innerHTML = `

启动失败

无法访问摄像头或加载 AI 模型。

错误信息: ${err.message || err.name}

请尝试:

`; // 显示重试按钮 const retryBtn = document.createElement('button'); retryBtn.textContent = '重试'; retryBtn.style.marginTop = '10px'; retryBtn.style.padding = '5px 15px'; retryBtn.onclick = () => location.reload(); statusDiv.appendChild(retryBtn); } } function onHandResults(results) { if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { isHandDetected = true; if (window.statusObj) window.statusObj.status = '已识别手势'; const landmarks = results.multiHandLandmarks[0]; // 计算张合度 // 使用拇指指尖(4)和食指指尖(8)的距离 // 并参考手腕(0)到中指根部(9)的距离作为归一化参考 const thumbTip = landmarks[4]; const indexTip = landmarks[8]; const wrist = landmarks[0]; const middleBase = landmarks[9]; const dist = Math.hypot(thumbTip.x - indexTip.x, thumbTip.y - indexTip.y, thumbTip.z - indexTip.z); const refDist = Math.hypot(wrist.x - middleBase.x, wrist.y - middleBase.y, wrist.z - middleBase.z); // 归一化距离 let ratio = dist / (refDist * 1.5); // 1.5 是经验系数 ratio = Math.max(0, Math.min(1, ratio)); // clamp to 0-1 // 平滑处理 handOpenness += (ratio - handOpenness) * 0.1; } else { isHandDetected = false; if (window.statusObj) window.statusObj.status = '未检测到手'; // 没检测到手时,缓慢回到默认状态 (0.5 或 0) handOpenness += (0.5 - handOpenness) * 0.05; } } // === 动画循环 === function animate() { requestAnimationFrame(animate); const time = clock.getElapsedTime(); const positions = geometry.attributes.position.array; // 旋转场景 particles.rotation.y += 0.002; // 根据手势计算缩放/扩散因子 // handOpenness: 0 (捏合) -> 1 (张开) // 目标:张开时,粒子扩散(位置 * scale > 1);捏合时,粒子聚集(位置 * scale < 1) // 或者:张开时,粒子变大;捏合时,粒子变小 // 这里我们做一个呼吸效果: // scale 范围从 0.5 到 2.0 // 另外加上随机扰动,张开时扰动大 const targetScale = 0.5 + handOpenness * params.handInfluence; // 0.5 ~ 2.5 const spread = handOpenness * 2.0; // 扩散噪点幅度 for (let i = 0; i < particleCount; i++) { const ix = i * 3; const iy = i * 3 + 1; const iz = i * 3 + 2; let tx = basePositions[ix]; let ty = basePositions[iy]; let tz = basePositions[iz]; // 应用手势缩放 tx *= targetScale; ty *= targetScale; tz *= targetScale; // 应用扩散/噪点 (模拟活跃度) // 加上基于时间的波动 tx += Math.sin(time + i) * spread; ty += Math.cos(time + i) * spread; tz += Math.sin(time + i * 0.5) * spread; // 线性插值移动当前位置到目标位置 positions[ix] += (tx - positions[ix]) * 0.1; positions[iy] += (ty - positions[iy]) * 0.1; positions[iz] += (tz - positions[iz]) * 0.1; } geometry.attributes.position.needsUpdate = true; renderer.render(scene, camera); } // === 辅助 === function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } function toggleFullScreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen(); } else { if (document.exitFullscreen) { document.exitFullscreen(); } } }