Initial commit

This commit is contained in:
admin
2025-12-08 14:22:41 +08:00
commit c83d5ded36
3 changed files with 690 additions and 0 deletions

40
index.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>交互式3D粒子系统</title>
<link rel="stylesheet" href="style.css">
<!-- Three.js -->
<script src="https://cdn.bootcdn.net/ajax/libs/three.js/r128/three.min.js"></script>
<!-- Tween.js -->
<script src="https://cdn.bootcdn.net/ajax/libs/tween.js/18.6.4/tween.umd.js"></script>
<!-- MediaPipe Hands (使用 unpkg) -->
<script src="https://unpkg.com/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
<!-- lil-gui -->
<script src="https://unpkg.com/lil-gui@0.17.0/dist/lil-gui.umd.min.js"></script>
</head>
<body>
<div id="loading">加载中... (请允许摄像头权限)</div>
<!-- 摄像头预览 (隐藏,用于计算) -->
<!-- 注意:不要用 display: none否则某些浏览器不会播放视频 -->
<video id="input_video" style="opacity: 0; position: absolute; pointer-events: none; z-index: -1;" playsinline></video>
<!-- 3D 容器 -->
<div id="canvas-container"></div>
<!-- UI 面板 -->
<div id="ui-container">
<div class="control-group">
<button id="fullscreen-btn">全屏显示</button>
</div>
<!-- lil-gui 将会自动挂载,或者我们可以手动挂载到这里 -->
</div>
<script src="main.js"></script>
</body>
</html>

570
main.js Normal file
View 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();
}
}
}

80
style.css Normal file
View File

@@ -0,0 +1,80 @@
body {
margin: 0;
overflow: hidden;
background-color: #000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
#canvas-container {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
#ui-container {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
pointer-events: none; /* 让鼠标穿透到 canvas除非点到 UI 元素 */
}
#ui-container > * {
pointer-events: auto;
}
.control-group {
margin-bottom: 10px;
text-align: right;
}
#fullscreen-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
font-size: 14px;
backdrop-filter: blur(5px);
}
#fullscreen-btn:hover {
background: rgba(255, 255, 255, 0.4);
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
z-index: 20;
pointer-events: none;
text-shadow: 0 0 10px rgba(0, 255, 255, 0.7);
}
/* 调整 lil-gui 的样式使其更现代 */
.lil-gui {
--background-color: rgba(20, 20, 30, 0.8);
--text-color: #eee;
--title-background-color: #2a2a3a;
--widget-color: #444;
--hover-color: #555;
--focus-color: #666;
--number-color: #2cc9ff;
--string-color: #a2ff00;
backdrop-filter: blur(10px);
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
}
.lil-gui .title {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}