Particle3d_v001 / index.html
JamesToth's picture
Update index.html
adf9abb verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hand Control Particle System</title>
<style>
body { margin: 0; overflow: hidden; background-color: #050505; font-family: 'Segoe UI', sans-serif; }
/* Video hidden, used for processing */
#input-video { display: none; }
/* UI Overlay */
#ui-container {
position: absolute;
top: 20px;
left: 20px;
width: 280px;
padding: 20px;
background: rgba(20, 20, 20, 0.6);
backdrop-filter: blur(10px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
z-index: 10;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
}
h1 { font-size: 1.2rem; margin: 0 0 15px 0; font-weight: 300; letter-spacing: 1px; }
.control-group { margin-bottom: 15px; }
label { display: block; font-size: 0.8rem; margin-bottom: 5px; color: #aaa; }
select, input[type="color"] {
width: 100%;
padding: 8px;
border-radius: 8px;
border: none;
background: rgba(255,255,255,0.1);
color: white;
outline: none;
cursor: pointer;
}
select option { background: #222; }
.status {
font-size: 0.75rem;
color: #00ff88;
margin-top: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.dot { width: 8px; height: 8px; background: #00ff88; border-radius: 50%; box-shadow: 0 0 5px #00ff88;}
.dot.inactive { background: #ff4444; box-shadow: 0 0 5px #ff4444; }
/* Loading Overlay */
#loading {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.5rem;
pointer-events: none;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<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>
</head>
<body>
<div id="loading">Initializing AI & Graphics...</div>
<div id="ui-container">
<h1>Particle Controller</h1>
<div class="control-group">
<label>Shape Template</label>
<select id="shape-select">
<option value="heart">Love Heart</option>
<option value="saturn">Saturn Ring</option>
<option value="galaxy">Spiral Galaxy</option>
<option value="fireworks">Fireworks</option>
<option value="sphere">Quantum Sphere</option>
</select>
</div>
<div class="control-group">
<label>Particle Color</label>
<input type="color" id="color-picker" value="#00ffff">
</div>
<div class="status">
<div id="status-dot" class="dot inactive"></div>
<span id="status-text">Waiting for camera...</span>
</div>
<p style="font-size: 0.7rem; color: #666; margin-top: 15px;">
Instruction: Show both hands. Move hands apart to expand. Clench fists to vibrate particles.
</p>
</div>
<video id="input-video"></video>
<script>
// --- 1. CONFIGURATION ---
const PARTICLE_COUNT = 15000;
const PARTICLE_SIZE = 0.04;
// State
const state = {
shape: 'heart',
color: new THREE.Color(0x00ffff),
handDistance: 1, // Multiplier for expansion
handTension: 0, // Multiplier for jitter
targetPositions: new Float32Array(PARTICLE_COUNT * 3),
};
// --- 2. THREE.JS SETUP ---
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.05);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 8;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
// Particles
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(PARTICLE_COUNT * 3);
// Initialize random positions
for(let i=0; i<PARTICLE_COUNT*3; i++) {
positions[i] = (Math.random() - 0.5) * 20;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: PARTICLE_SIZE,
color: state.color,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
// --- 3. SHAPE GENERATORS ---
// Helper to map sphere coordinates
function randomSpherePoint() {
const u = Math.random();
const v = Math.random();
const theta = 2 * Math.PI * u;
const phi = Math.acos(2 * v - 1);
let r = 3;
const x = r * Math.sin(phi) * Math.cos(theta);
const y = r * Math.sin(phi) * Math.sin(theta);
const z = r * Math.cos(phi);
return {x, y, z};
}
const generators = {
heart: (i) => {
const t = Math.random() * Math.PI * 2;
const u = Math.random() * Math.PI * 2; // density distribution
// Heart formula
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);
let z = (Math.random()-0.5) * 4; // Thickness
// Scale down
return { x: x * 0.2, y: y * 0.2, z: z };
},
sphere: (i) => {
return randomSpherePoint();
},
saturn: (i) => {
// 70% Ring, 30% Planet
if (Math.random() > 0.3) {
// Ring
const angle = Math.random() * Math.PI * 2;
const r = 4 + Math.random() * 2;
return {
x: Math.cos(angle) * r,
y: (Math.random() - 0.5) * 0.2,
z: Math.sin(angle) * r
};
} else {
// Planet
const p = randomSpherePoint();
return { x: p.x * 0.6, y: p.y * 0.6, z: p.z * 0.6 };
}
},
galaxy: (i) => {
const branches = 3;
const spin = i / PARTICLE_COUNT * branches;
const radius = (i / PARTICLE_COUNT) * 6;
const angle = spin * Math.PI * 2;
const randomOffset = (Math.random() - 0.5);
return {
x: Math.cos(angle) * radius + randomOffset,
y: (Math.random() - 0.5) * (1 - radius/7), // Thicker at center
z: Math.sin(angle) * radius + randomOffset
};
},
fireworks: (i) => {
const p = randomSpherePoint();
const burst = Math.random() * 6;
return { x: p.x * burst, y: p.y * burst, z: p.z * burst };
}
};
function updateTargetShape() {
const generator = generators[state.shape];
for (let i = 0; i < PARTICLE_COUNT; i++) {
const pos = generator(i);
state.targetPositions[i * 3] = pos.x;
state.targetPositions[i * 3 + 1] = pos.y;
state.targetPositions[i * 3 + 2] = pos.z;
}
}
// Initialize shape
updateTargetShape();
// --- 4. MEDIAPIPE HAND TRACKING ---
const videoElement = document.getElementById('input-video');
function onResults(results) {
document.getElementById('loading').style.display = 'none';
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
statusDot.classList.remove('inactive');
statusText.innerText = "Tracking Active";
// 1. Detect Expansion (Distance between two wrists if 2 hands present)
if (results.multiHandLandmarks.length === 2) {
const hand1 = results.multiHandLandmarks[0][0]; // Wrist
const hand2 = results.multiHandLandmarks[1][0]; // Wrist
// Calculate simple Euclidean distance in screen space
const dx = hand1.x - hand2.x;
const dy = hand1.y - hand2.y;
const dist = Math.sqrt(dx*dx + dy*dy);
// Map distance: 0.2 is close, 0.8 is far. Map to scale 0.5 to 2.0
state.handDistance = THREE.MathUtils.mapLinear(dist, 0.1, 0.8, 0.5, 2.5);
} else {
// Default if 1 hand
state.handDistance = 1;
}
// 2. Detect Tension (Closed Hand)
// Measure distance between Wrist (0) and Middle Finger Tip (12)
let totalOpenness = 0;
results.multiHandLandmarks.forEach(landmarks => {
const wrist = landmarks[0];
const tip = landmarks[12];
const d = Math.sqrt(Math.pow(wrist.x - tip.x, 2) + Math.pow(wrist.y - tip.y, 2));
totalOpenness += d;
});
// Normalize openness approximately (0.1 is fist, 0.3+ is open)
const avgOpenness = totalOpenness / results.multiHandLandmarks.length;
// If openness is low (fist), tension is high
if (avgOpenness < 0.15) {
state.handTension = THREE.MathUtils.lerp(state.handTension, 1.0, 0.1);
} else {
state.handTension = THREE.MathUtils.lerp(state.handTension, 0.0, 0.1);
}
} else {
statusDot.classList.add('inactive');
statusText.innerText = "No Hands Detected";
state.handDistance = THREE.MathUtils.lerp(state.handDistance, 1, 0.05);
state.handTension = THREE.MathUtils.lerp(state.handTension, 0, 0.05);
}
}
const hands = new Hands({locateFile: (file) => {
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
}});
hands.setOptions({
maxNumHands: 2,
modelComplexity: 1,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5
});
hands.onResults(onResults);
// Initialize Camera
const cameraUtils = new Camera(videoElement, {
onFrame: async () => {
await hands.send({image: videoElement});
},
width: 640,
height: 480
});
cameraUtils.start();
// --- 5. UI INTERACTION ---
document.getElementById('shape-select').addEventListener('change', (e) => {
state.shape = e.target.value;
updateTargetShape();
});
document.getElementById('color-picker').addEventListener('input', (e) => {
material.color.set(e.target.value);
});
// --- 6. ANIMATION LOOP ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
const positions = particles.geometry.attributes.position.array;
// Interaction smoothing
const targetScale = state.handDistance;
const jitterIntensity = state.handTension * 0.2; // How much they shake when fist is closed
for (let i = 0; i < PARTICLE_COUNT; i++) {
const ix = i * 3;
const iy = i * 3 + 1;
const iz = i * 3 + 2;
// Get target base position
let tx = state.targetPositions[ix];
let ty = state.targetPositions[iy];
let tz = state.targetPositions[iz];
// Apply Scale (Hand Distance)
tx *= targetScale;
ty *= targetScale;
tz *= targetScale;
// Apply Jitter (Hand Tension)
if (state.handTension > 0.1) {
tx += (Math.random() - 0.5) * jitterIntensity;
ty += (Math.random() - 0.5) * jitterIntensity;
tz += (Math.random() - 0.5) * jitterIntensity;
}
// Simple Lerp for smooth transition
positions[ix] += (tx - positions[ix]) * 0.05;
positions[iy] += (ty - positions[iy]) * 0.05;
positions[iz] += (tz - positions[iz]) * 0.05;
}
// Slight rotation for the whole system
particles.rotation.y = time * 0.1;
// Dynamic Wave Effect if idle
if (state.handTension < 0.1) {
// particles.rotation.z = Math.sin(time * 0.5) * 0.1;
}
particles.geometry.attributes.position.needsUpdate = true;
renderer.render(scene, camera);
}
animate();
// Resize Handler
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>