Files
particle-3d-system/main.js
2025-12-08 14:22:41 +08:00

571 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 全局变量
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 = `
<div style="background:rgba(255,0,0,0.3); padding:20px; border-radius:10px;">
<h3>启动失败</h3>
<p>无法访问摄像头或加载 AI 模型。</p>
<p>错误信息: ${err.message || err.name}</p>
<p>请尝试:</p>
<ul style="text-align:left; display:inline-block;">
<li>检查浏览器地址栏是否允许摄像头权限</li>
<li>确保设备摄像头未被其他程序占用</li>
<li>如果是本地文件直接打开,请改用本地服务器 (localhost)</li>
<li>尝试更换浏览器 (推荐 Chrome/Edge)</li>
</ul>
</div>
`;
// 显示重试按钮
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();
}
}
}