<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>3D Particle System - Gesture Control</title>
<style>
body { margin: 0; background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
#overlay { position: absolute; top: 20px; left: 20px; color: #00ff00; z-index: 10; pointer-events: none; text-shadow: 1px 1px 2px #000; }
#controls { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; z-index: 100; background: rgba(0,0,0,0.8); padding: 30px; border: 2px solid #00ff00; border-radius: 10px; }
button { padding: 15px 40px; font-size: 20px; cursor: pointer; background: #00ff00; border: none; font-weight: bold; color: #000; transition: 0.3s; }
button:hover { background: #008800; color: #fff; }
#error-msg { color: #ff4444; margin-top: 20px; display: none; max-width: 400px; line-height: 1.5; }
video { display: none; }
</style>
</head>
<body>
<div id="overlay">
STATUS: <span id="st">WAITING</span><br>
GESTURE: <span id="gs">-</span><br>
SPREAD: <span id="sp">0</span>
</div>
<div id="controls">
<h2 style="color:#00ff00; margin-top:0;">3D 粒子交互系统</h2>
<p style="color:#ccc;">需要摄像头权限以识别手势</p>
<button id="start-btn">开启摄像头并启动</button>
<div id="error-msg">
<strong>无法访问摄像头:</strong><br>
1. 请确保使用 <b>HTTPS</b> 或 <b>localhost</b> 访问。<br>
2. 请在浏览器地址栏点击“锁”图标,重新允许摄像头权限。<br>
3. 确保没有其他程序占用摄像头。
</div>
</div>
<video id="v" playsinline></video>
<!-- 库文件 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
<script>
const video = document.getElementById('v');
const startBtn = document.getElementById('start-btn');
const errorMsg = document.getElementById('error-msg');
const st = document.getElementById('st');
const gs = document.getElementById('gs');
const sp = document.getElementById('sp');
let scene, camera, renderer, points;
const COUNT = 15000; // 粒子数量
const targets = new Float32Array(COUNT * 3);
let curGesture = -1;
let spreadVal = 0;
// 离屏Canvas用于文字转换
const tCvs = document.createElement('canvas');
const tCtx = tCvs.getContext('2d');
tCvs.width = 400; tCvs.height = 400;
function setTargetShape(text) {
tCtx.clearRect(0, 0, 400, 400);
tCtx.fillStyle = 'white';
tCtx.font = 'bold 100px Arial';
tCtx.textAlign = 'center';
tCtx.textBaseline = 'middle';
tCtx.fillText(text, 200, 200);
const pixels = tCtx.getImageData(0, 0, 400, 400).data;
const pts = [];
for (let y = 0; y < 400; y += 2) {
for (let x = 0; x < 400; x += 2) {
if (pixels[(y * 400 + x) * 4] > 128) {
pts.push({x: (x - 200) * 0.7, y: (200 - y) * 0.7});
}
}
}
// 填充粒子目标
for (let i = 0; i < COUNT; i++) {
const p = pts[i % pts.length];
targets[i * 3] = p.x;
targets[i * 3 + 1] = p.y;
targets[i * 3 + 2] = (Math.random() - 0.5) * 10;
}
}
function initThree() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 120;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(COUNT * 3);
const col = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
pos[i*3] = (Math.random()-0.5)*500;
pos[i*3+1] = (Math.random()-0.5)*500;
pos[i*3+2] = (Math.random()-0.5)*500;
// 蓝青色系
col[i*3] = 0.1; col[i*3+1] = 0.7 + Math.random()*0.3; col[i*3+2] = 1.0;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
points = new THREE.Points(geo, new THREE.PointsMaterial({
size: 1.0, vertexColors: true, blending: THREE.AdditiveBlending, transparent: true, opacity: 0.8
}));
scene.add(points);
setTargetShape("h()");
}
const handLogic = new Hands({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
handLogic.setOptions({
maxNumHands: 1,
modelComplexity: 1,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5
});
handLogic.onResults(res => {
if (!res.multiHandLandmarks || res.multiHandLandmarks.length === 0) {
st.innerText = "LOST";
spreadVal *= 0.95; // 失去检测时缓慢收缩
return;
}
st.innerText = "TRACKING";
const lm = res.multiHandLandmarks[0];
// 1. 手势判断 (计数伸出的手指)
let count = 0;
if (lm[8].y < lm[6].y) count++; // 食指
if (lm[12].y < lm[10].y) count++; // 中指
if (lm[16].y < lm[14].y) count++; // 无名指
if (count !== curGesture && count >= 1 && count <= 3) {
curGesture = count;
if (count === 1) { setTargetShape("h()"); gs.innerText = "1: h()"; }
else if (count === 2) { setTargetShape("()"); gs.innerText = "2: ()"; }
else if (count === 3) { setTargetShape("()"); gs.innerText = "3: ()"; }
}
// 2. 张合控制 (计算指尖到掌心的距离)
const wrist = lm[0];
const fingerTip = lm[12];
const dist = Math.hypot(fingerTip.x - wrist.x, fingerTip.y - wrist.y);
// 映射距离到扩散系数
const targetSpread = Math.max(0, (dist - 0.2) * 250);
spreadVal += (targetSpread - spreadVal) * 0.1;
sp.innerText = Math.round(spreadVal);
});
const camHelper = new Camera(video, {
onFrame: async () => { await handLogic.send({ image: video }); },
width: 640, height: 480
});
startBtn.addEventListener('click', () => {
errorMsg.style.display = 'none';
camHelper.start()
.then(() => {
document.getElementById('controls').style.display = 'none';
})
index.html
index.html