Initial commit
This commit is contained in:
570
main.js
Normal file
570
main.js
Normal file
@@ -0,0 +1,570 @@
|
||||
|
||||
// 全局变量
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user