commit c83d5ded36489455e6c50f317c9b26e11759db28 Author: admin Date: Mon Dec 8 14:22:41 2025 +0800 Initial commit diff --git a/index.html b/index.html new file mode 100644 index 0000000..42c820a --- /dev/null +++ b/index.html @@ -0,0 +1,40 @@ + + + + + + 交互式3D粒子系统 + + + + + + + + + + + + + + +
加载中... (请允许摄像头权限)
+ + + + + + +
+ + +
+
+ +
+ +
+ + + + \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..8e2fe5c --- /dev/null +++ b/main.js @@ -0,0 +1,570 @@ + +// 全局变量 +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(); + } + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..159198b --- /dev/null +++ b/style.css @@ -0,0 +1,80 @@ +body { + margin: 0; + overflow: hidden; + background-color: #000; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +#canvas-container { + width: 100vw; + height: 100vh; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +#ui-container { + position: absolute; + top: 20px; + right: 20px; + z-index: 10; + pointer-events: none; /* 让鼠标穿透到 canvas,除非点到 UI 元素 */ +} + +#ui-container > * { + pointer-events: auto; +} + +.control-group { + margin-bottom: 10px; + text-align: right; +} + +#fullscreen-btn { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.4); + color: white; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s; + font-size: 14px; + backdrop-filter: blur(5px); +} + +#fullscreen-btn:hover { + background: rgba(255, 255, 255, 0.4); +} + +#loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 24px; + z-index: 20; + pointer-events: none; + text-shadow: 0 0 10px rgba(0, 255, 255, 0.7); +} + +/* 调整 lil-gui 的样式使其更现代 */ +.lil-gui { + --background-color: rgba(20, 20, 30, 0.8); + --text-color: #eee; + --title-background-color: #2a2a3a; + --widget-color: #444; + --hover-color: #555; + --focus-color: #666; + --number-color: #2cc9ff; + --string-color: #a2ff00; + backdrop-filter: blur(10px); + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); +} + +.lil-gui .title { + border-top-left-radius: 8px; + border-top-right-radius: 8px; +}