用摄像头捕捉手势实现酷炫的粒子效果!
Published:
今天干了个很有意思的东西, 通过摄像头追踪手势的握拳和张开, 来渲染图案的粒子效果, 建议在 PC 网页上浏览.
📢 网页需要读取你的摄像头信息, 通过摄像头识别你的手握拳 (伸展) 来收缩 (散开) 粒子, 所以需要通过一次权限!
顺带附上源码, 欢迎体验:
---
layout: null
permalink: /particles/
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js 手势交互粒子系统 v4.7 (Smaller Heart)</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- MediaPipe -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
<!-- Three.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
body { margin: 0; overflow: hidden; background-color: #020205; color: white; font-family: 'Segoe UI', sans-serif; }
#canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
#video-input { position: absolute; top: 0; left: 0; visibility: hidden; width: 320px; height: 240px; transform: scaleX(-1); }
.ui-panel {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
background: rgba(10, 10, 15, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 24px;
border-radius: 20px;
width: 300px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
transition: opacity 0.3s;
}
.btn-shape {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #aaa;
padding: 8px 14px;
margin: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.5px;
}
.btn-shape:hover, .btn-shape.active {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.4);
color: white;
box-shadow: 0 0 15px rgba(255, 255, 255, 0.1);
text-shadow: 0 0 8px rgba(255,255,255,0.5);
}
.btn-shape.active {
background: rgba(100, 200, 255, 0.2);
border-color: rgba(100, 200, 255, 0.6);
color: #e0f2fe;
box-shadow: 0 0 20px rgba(100, 200, 255, 0.2);
}
#loading {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
text-align: center;
pointer-events: none;
}
.spinner {
width: 40px; height: 40px;
border: 3px solid rgba(255,255,255,0.1);
border-top: 3px solid #00d2ff;
border-radius: 50%;
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin: 0 auto 15px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* 摄像头预览窗口样式 */
#cam-preview {
position: absolute;
bottom: 24px;
right: 24px;
width: 180px;
height: 135px;
z-index: 5;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
background: #000;
opacity: 0.9;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
transition: opacity 0.3s;
display: none; /* 默认隐藏 */
}
</style>
</head>
<body>
<div id="loading">
<div class="spinner"></div>
<div class="text-gray-400 text-xs tracking-[0.2em] uppercase">System Initializing</div>
</div>
<video id="video-input"></video>
<div id="canvas-container"></div>
<canvas id="cam-preview"></canvas>
<div class="ui-panel text-white">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-300 via-purple-300 to-pink-300 tracking-wide">ASTRO · DUST</h2>
<div class="h-2 w-2 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]" id="status-dot"></div>
</div>
<p class="text-[10px] text-gray-400 mb-6 uppercase tracking-wider leading-relaxed">
Gestural Field Control<br>
<span class="text-gray-500">Pinch to Gather · Release to Scatter</span>
</p>
<div class="mb-6">
<label class="block text-[10px] uppercase text-gray-500 mb-3 font-bold tracking-widest">Topology</label>
<div class="flex flex-wrap -m-1">
<button class="btn-shape active" onclick="switchShape('text')">SJTU</button>
<button class="btn-shape" onclick="switchShape('heart')">Heart</button>
<button class="btn-shape" onclick="switchShape('saturn')">Saturn</button>
</div>
</div>
<div class="mb-4">
<label class="block text-[10px] uppercase text-gray-500 mb-3 font-bold tracking-widest">Visual Effects</label>
<!-- 颜色选择器 -->
<div class="flex items-center justify-between mb-3 bg-white/5 p-2 rounded-lg border border-white/5">
<span class="text-xs text-gray-400 font-medium">Particle Color</span>
<div class="flex items-center space-x-2">
<span class="text-[10px] text-gray-500 font-mono" id="colorCode">#40c9ff</span>
<input type="color" id="colorPicker" value="#40c9ff" class="w-5 h-5 rounded cursor-pointer border-none bg-transparent">
</div>
</div>
<!-- 摄像头开关 -->
<div class="flex items-center justify-between mb-3 bg-white/5 p-2 rounded-lg border border-white/5">
<span class="text-xs text-gray-400 font-medium">Camera Feed</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="camToggle" class="sr-only peer">
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-500"></div>
</label>
</div>
</div>
<div class="mt-4 text-[10px] text-gray-600 font-mono text-center">
Render Scale: <span id="pixel-ratio">2.0x</span>
</div>
</div>
<!-- ================= 顶点着色器 (Vertex Shader) ================= -->
<script type="x-shader/x-vertex" id="vertexshader">
attribute float size;
attribute float random;
uniform float time;
uniform float pixelRatio;
uniform float handOpenness;
varying float vAlpha;
varying vec3 vColor;
void main() {
vec3 pos = position;
// [可调参数] 波动幅度
float wave = sin(time * 2.0 + pos.x * 0.1) * 0.1 * handOpenness;
pos.y += wave;
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
float dist = -mvPosition.z;
// [可调参数] 粒子透视大小
gl_PointSize = size * (0.8 + 0.4 * random) * pixelRatio * (300.0 / dist);
gl_Position = projectionMatrix * mvPosition;
// [可调参数] 呼吸/闪烁速度 (1.5 = 慢速)
float twinkle = sin(time * 1.5 + random * 10.0);
// [可调参数] 基础透明度
vAlpha = 0.5 + 0.5 * twinkle;
}
</script>
<!-- ================= 片元着色器 (Fragment Shader) ================= -->
<script type="x-shader/x-fragment" id="fragmentshader">
uniform vec3 color;
varying float vAlpha;
void main() {
vec2 xy = gl_PointCoord.xy - vec2(0.5);
float r = length(xy);
// 硬切割:实心圆
if (r > 0.5) discard;
// 极微小的抗锯齿
float edge = 1.0 - smoothstep(0.05, 0.08, r);
gl_FragColor = vec4(color, vAlpha * edge);
}
</script>
<script>
// ================= 全局配置 (CONFIGURATION) =================
const CONFIG = {
particleCount: 30000,
baseSize: 1.2,
defaultColor: new THREE.Color(0x40c9ff),
cameraZ: 25,
scatterRadius: 35
};
const STATE = {
currentShape: 'text',
targetPositions: new Float32Array(CONFIG.particleCount * 3),
randomOffsets: new Float32Array(CONFIG.particleCount * 3),
handOpenness: 1.0,
handPosition: { x: 0.5, y: 0.5 },
handDetected: false,
time: 0
};
// ================= THREE.JS 核心设置 =================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x020205);
scene.fog = new THREE.FogExp2(0x020205, 0.015);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.z = CONFIG.cameraZ;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
const pixelRatio = Math.max(window.devicePixelRatio, 2.0);
renderer.setPixelRatio(pixelRatio);
document.getElementById('pixel-ratio').innerText = pixelRatio.toFixed(1) + 'x';
document.getElementById('canvas-container').appendChild(renderer.domElement);
// ================= 材质设置 =================
const shaderMaterial = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
color: { value: CONFIG.defaultColor },
pixelRatio: { value: pixelRatio },
handOpenness: { value: 1.0 }
},
vertexShader: document.getElementById('vertexshader').textContent,
fragmentShader: document.getElementById('fragmentshader').textContent,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
// ================= 粒子几何体生成 =================
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(CONFIG.particleCount * 3);
const randoms = new Float32Array(CONFIG.particleCount);
const sizes = new Float32Array(CONFIG.particleCount);
for (let i = 0; i < CONFIG.particleCount; i++) {
const r = CONFIG.scatterRadius * (0.5 + Math.random());
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i*3] = r * Math.sin(phi) * Math.cos(theta);
positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
positions[i*3+2] = r * Math.cos(phi);
STATE.randomOffsets[i*3] = positions[i*3];
STATE.randomOffsets[i*3+1] = positions[i*3+1];
STATE.randomOffsets[i*3+2] = positions[i*3+2];
randoms[i] = Math.random();
sizes[i] = CONFIG.baseSize * (0.5 + Math.random());
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('random', new THREE.BufferAttribute(randoms, 1));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const particles = new THREE.Points(geometry, shaderMaterial);
scene.add(particles);
// ================= 形状生成逻辑 =================
function getPointsFromText(textString) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 200;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFFFFF';
ctx.font = '900 100px "Segoe UI", Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(textString, canvas.width / 2, canvas.height / 2);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const validPoints = [];
for (let y = 0; y < canvas.height; y += 3) {
for (let x = 0; x < canvas.width; x += 3) {
if (data[(y * canvas.width + x) * 4] > 50) {
const worldX = (x - canvas.width / 2) * 0.06;
const worldY = -(y - canvas.height / 2) * 0.06;
validPoints.push({x: worldX, y: worldY});
}
}
}
return validPoints;
}
function generateTargetPositions(shapeType) {
const targets = new Float32Array(CONFIG.particleCount * 3);
const count = CONFIG.particleCount;
let textPoints = [];
if (shapeType === 'text') {
textPoints = getPointsFromText("I ❤️ SJTU");
} else if (shapeType === 'heart') {
textPoints = getPointsFromText("❤️");
}
for (let i = 0; i < count; i++) {
let x, y, z;
const idx = i * 3;
if (shapeType === 'heart') {
if (textPoints.length > 0) {
const p = textPoints[i % textPoints.length];
// [修改] 缩放比例从 3.5 调整为 2.5 (缩小约 30%)
const scale = 2.5;
x = p.x * scale;
// [修改] Y轴坐标减去 2.5,使图案整体稍微下移
y = p.y * scale - 2.5;
z = (Math.random() - 0.5) * 3.0 * scale;
} else {
x = 0; y = 0; z = 0;
}
} else if (shapeType === 'saturn') {
const ratio = i / count;
if (ratio < 0.6) {
const phi = Math.acos(-1 + (2 * i / (count * 0.6)));
const theta = Math.sqrt((count * 0.6) * Math.PI) * phi;
const r = 5;
x = r * Math.cos(theta) * Math.sin(phi);
y = r * Math.sin(theta) * Math.sin(phi);
z = r * Math.cos(phi);
} else {
const angle = Math.random() * Math.PI * 2;
const r = 8 + Math.random() * 6;
x = r * Math.cos(angle);
z = r * Math.sin(angle);
y = (Math.random() - 0.5) * 0.3;
const tilt = 0.4;
const y_new = y * Math.cos(tilt) - x * Math.sin(tilt);
const x_new = y * Math.sin(tilt) + x * Math.cos(tilt);
x = x_new;
y = y_new;
}
} else if (shapeType === 'text') {
if (textPoints.length > 0) {
const p = textPoints[i % textPoints.length];
x = p.x;
y = p.y;
z = (Math.random() - 0.5) * 2.0;
} else {
x = (Math.random() - 0.5) * 20;
y = (Math.random() - 0.5) * 20;
z = (Math.random() - 0.5) * 20;
}
}
targets[idx] = x;
targets[idx + 1] = y;
targets[idx + 2] = z;
}
return targets;
}
STATE.targetPositions = generateTargetPositions(STATE.currentShape);
// ================= MEDIA PIPE 手势识别 =================
const videoElement = document.getElementById('video-input');
const previewCanvas = document.getElementById('cam-preview');
const previewCtx = previewCanvas.getContext('2d');
previewCtx.translate(previewCanvas.width, 0);
previewCtx.scale(-1, 1);
function onResults(results) {
// 即使隐藏,Canvas 也必须绘制,否则逻辑可能会断
previewCtx.drawImage(results.image, 0, 0, previewCanvas.width, previewCanvas.height);
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
STATE.handDetected = true;
document.getElementById('status-dot').className = "h-2 w-2 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]";
document.getElementById('loading').style.display = 'none';
const landmarks = results.multiHandLandmarks[0];
drawConnectors(previewCtx, landmarks, HAND_CONNECTIONS, {color: '#40c9ff', lineWidth: 1});
const wrist = landmarks[0];
const tips = [4, 8, 12, 16, 20].map(i => landmarks[i]);
const dists = tips.map(tip => Math.hypot(tip.x - wrist.x, tip.y - wrist.y));
const avgDist = dists.reduce((a, b) => a + b) / 5;
let rawOpenness = (avgDist - 0.2) * 3.3;
rawOpenness = Math.max(0, Math.min(1, rawOpenness));
STATE.handOpenness += (rawOpenness - STATE.handOpenness) * 0.1;
const mid = landmarks[9];
const handX = (wrist.x + mid.x) / 2;
const handY = (wrist.y + mid.y) / 2;
STATE.handPosition.x += (handX - STATE.handPosition.x) * 0.1;
STATE.handPosition.y += (handY - STATE.handPosition.y) * 0.1;
} else {
STATE.handDetected = false;
document.getElementById('status-dot').className = "h-2 w-2 rounded-full bg-yellow-500";
STATE.handOpenness += (0.0 - STATE.handOpenness) * 0.05;
}
}
const hands = new Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`});
hands.setOptions({ maxNumHands: 1, modelComplexity: 1, minDetectionConfidence: 0.6, minTrackingConfidence: 0.6 });
hands.onResults(onResults);
const cameraUtils = new Camera(videoElement, {
onFrame: async () => await hands.send({image: videoElement}),
width: 320, height: 240
});
cameraUtils.start();
// ================= 动画循环 (ANIMATION LOOP) =================
function animate() {
requestAnimationFrame(animate);
const dt = 0.03;
STATE.time += dt;
shaderMaterial.uniforms.time.value = STATE.time;
shaderMaterial.uniforms.handOpenness.value = STATE.handOpenness;
const positionsArray = particles.geometry.attributes.position.array;
// 旋转逻辑
if (STATE.handDetected) {
// [可调参数] 手势旋转灵敏度 (已增大)
// handPosition.x 范围 0~1,中心为 0.5。
// 当手在画面正中心 (0.5) 时,targetRotY = -(0.5 - 0.5) * 8.0 = 0
// 此时粒子群旋转角度归零,正面对着屏幕。
const targetRotY = -(STATE.handPosition.x - 0.4) * 8.0;
const targetRotX = (STATE.handPosition.y - 0.6) * 6.0;
particles.rotation.y += (targetRotY - particles.rotation.y) * 0.1;
particles.rotation.x += (targetRotX - particles.rotation.x) * 0.1;
} else {
particles.rotation.y += 0.001;
particles.rotation.x += (0 - particles.rotation.x) * 0.05;
}
const openness = STATE.handOpenness;
const lerpFactor = 0.1;
for (let i = 0; i < CONFIG.particleCount; i++) {
const i3 = i * 3;
const tx = STATE.targetPositions[i3];
const ty = STATE.targetPositions[i3+1];
const tz = STATE.targetPositions[i3+2];
const rx = STATE.randomOffsets[i3] + Math.sin(STATE.time * 0.5 + i) * 2.0;
const ry = STATE.randomOffsets[i3+1] + Math.cos(STATE.time * 0.3 + i) * 2.0;
const rz = STATE.randomOffsets[i3+2];
const mixRatio = openness * openness;
const destX = tx * (1.0 - mixRatio) + rx * mixRatio;
const destY = ty * (1.0 - mixRatio) + ry * mixRatio;
const destZ = tz * (1.0 - mixRatio) + rz * mixRatio;
positionsArray[i3] += (destX - positionsArray[i3]) * lerpFactor;
positionsArray[i3+1] += (destY - positionsArray[i3+1]) * lerpFactor;
positionsArray[i3+2] += (destZ - positionsArray[i3+2]) * lerpFactor;
}
particles.geometry.attributes.position.needsUpdate = true;
renderer.render(scene, camera);
}
animate();
// ================= 交互事件 =================
window.switchShape = (shape) => {
STATE.currentShape = shape;
STATE.targetPositions = generateTargetPositions(shape);
document.querySelectorAll('.btn-shape').forEach(btn => {
btn.classList.remove('active');
if(btn.innerText.toLowerCase().includes(shape === 'text' ? 'sjtu' : shape)) {
btn.classList.add('active');
}
});
};
document.getElementById('colorPicker').addEventListener('input', (e) => {
const hex = e.target.value;
document.getElementById('colorCode').innerText = hex;
shaderMaterial.uniforms.color.value = new THREE.Color(hex);
});
// [新增] 摄像头开关监听
document.getElementById('camToggle').addEventListener('change', (e) => {
const preview = document.getElementById('cam-preview');
// 如果选中,显示 Block;否则 None
preview.style.display = e.target.checked ? 'block' : 'none';
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
const newPixel = Math.max(window.devicePixelRatio, 2.0);
renderer.setPixelRatio(newPixel);
shaderMaterial.uniforms.pixelRatio.value = newPixel;
document.getElementById('pixel-ratio').innerText = newPixel.toFixed(1) + 'x';
});
</script>
</body>
</html>
