571 lines
18 KiB
JavaScript
571 lines
18 KiB
JavaScript
|
||
// 全局变量
|
||
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();
|
||
}
|
||
}
|
||
}
|