参考:https://www.yjln.com/643.html

1. 基础追踪模式 (基础操控)
当单手处于摄像头视野内且被成功识别时,系统进入追踪模式。
A.旋转控制 (掌心水平移动/垂直移动)
a1.动作: 将手掌面向摄像头,然后在空中左右或上下平移。
a2.模型: 土星模型会随着掌心的移动而进行相应的 Y 轴(左右)或 X 轴(上下)旋转。手随心动,可以360度欣赏粒子星系的细节。
B.缩放控制 (拇指与食指捏合距离)
b1.动作: 做出"捏合"的手势,改变拇指指尖与食指指尖之间的距离。
b2.模型: 系统会实时计算这两个指尖的距离。捏合距离越小,土星模型缩小(远离镜头);距离越大,模型放大(靠近镜头)。
2. 状态控制手势 (高级指令)
为了增加交互的流畅性和趣味性,设计了特定的手势来切换系统的运行状态。
C.一键归位 (比耶手势 ✌️)
c1.动作: 对着摄像头比出标准的"V字"或"比耶"手势(食指和中指伸直,其余手指弯曲)。
c2.模型: 一旦识别到此手势,系统会立即从当前的追踪模式切换至"IDLE(待机)"状态。土星模型将通过平滑的动画过渡,自动回到屏幕中心,并恢复初始的、缓慢的自动游荡旋转。无论之前模型处于什么角度或大小,都能优雅归位。
D.断点锁定 (手部移出画面)
d1.动作: 直接将手从摄像头的视野中移开。
d2.模型: 当系统在设定的缓冲时间内(约0.5秒)未检测到手势时,为了防止由于识别丢失导致的画面突变,系统将自动进入"FROZEN(锁定)"状态。土星模型将完全停留在手离开时的那一刻的角度、大小和位置。可以随时再次将手伸入画面来接管控制。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saturn Resonance - 极致防抖稳定版 (手势悬停+比耶归位)</title>
<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/hands/hands.js" crossorigin="anonymous"></script>
<style>
:root { --primary-gold: #c5a059; --bg-dark: #050505; }
body { margin: 0; overflow: hidden; background-color: var(--bg-dark); font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; color: white; user-select: none; }
#canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; background: radial-gradient(circle at center, #0a0a0f 0%, #020202 100%); }
#ui-layer { position: absolute; top: 40px; left: 40px; z-index: 10; pointer-events: none; }
.glass-panel {
background: rgba(5, 5, 5, 0.4); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
padding: 30px; border-radius: 8px; border: 1px solid rgba(197, 160, 89, 0.15);
border-left: 3px solid var(--primary-gold); box-shadow: 0 15px 35px rgba(0, 0, 0, 0.5);
}
h1 { font-weight: 300; font-size: 2rem; margin: 0 0 15px 0; color: #f0e6d2; letter-spacing: 6px; text-transform: uppercase; }
.status-text { font-size: 0.85rem; color: #888; line-height: 1.8; font-family: monospace; }
.highlight { font-weight: bold; text-shadow: 0 0 8px rgba(197,160,89,0.4); }
.active-pulse { animation: pulse 1.5s infinite; color: #00ff88 !important; text-shadow: 0 0 10px rgba(0,255,136,0.6); }
.frozen-state { color: #ffaa00 !important; text-shadow: 0 0 10px rgba(255,170,0,0.6); }
.idle-state { color: var(--primary-gold) !important; }
@keyframes pulse { 0% { opacity: 0.7; } 50% { opacity: 1; } 100% { opacity: 0.7; } }
#controls { position: absolute; bottom: 40px; right: 40px; z-index: 10; pointer-events: auto; }
button, #author-btn {
background: rgba(0, 0, 0, 0.4); border: 1px solid rgba(197, 160, 89, 0.3); color: var(--primary-gold);
padding: 12px 24px; font-size: 0.8rem; border-radius: 4px; cursor: pointer; transition: all 0.2s ease;
text-transform: uppercase; letter-spacing: 2px; backdrop-filter: blur(8px); text-decoration: none; display: inline-block;
}
#author-btn { position: absolute; top: 40px; right: 40px; border-radius: 20px; }
button:hover, #author-btn:hover { background: rgba(197, 160, 89, 0.25); border-color: var(--primary-gold); box-shadow: 0 0 20px rgba(197, 160, 89, 0.3); transform: scale(1.05); }
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; color: #888; font-size: 0.9rem; letter-spacing: 4px; font-family: monospace; }
.input_video { display: none; }
</style>
</head>
<body>
<video class="input_video"></video>
<a id="author-btn" href="https://www.yjln.com" target="_blank">By Mr.lun</a>
<div id="loading">NEURAL LINK INITIATING...</div>
<div id="ui-layer">
<div class="glass-panel">
<h1>Saturn</h1>
<div class="status-text">
TRACKING STATE: <span id="status-indicator" class="highlight idle-state">STANDBY / IDLE</span><br><br>
> ORBITAL MECHANICS: ONLINE<br>
> MOTION LOCK: <span style="color:#ffaa00;">ON (LIFT HAND TO FREEZE)</span><br>
> RESET TRIGGER: <span style="color:#00ff88;">V-SIGN (✌️)</span>
</div>
</div>
</div>
<div id="controls"><button onclick="toggleFullScreen()">FULLSCREEN INIT</button></div>
<div id="canvas-container"></div>
<script type="x-shader/x-vertex" id="vertexshader">
attribute vec4 aParams; attribute vec3 customColor; attribute float aRandomId;
varying vec3 vColor; varying float vDist; varying float vOpacity; varying float vScaleFactor; varying float vIsRing;
uniform float uTime; uniform float uScale; uniform float uRotationX; uniform float uRotationY;
mat2 rotate2d(float _angle){ return mat2(cos(_angle),-sin(_angle), sin(_angle),cos(_angle)); }
float hash(float n) { return fract(sin(n) * 43758.5453123); }
void main() {
float normScaleLOD = clamp((uScale - 0.15) / 2.35, 0.0, 1.0);
float visibilityThreshold = 0.9 + pow(normScaleLOD, 1.2) * 0.1;
if (aRandomId > visibilityThreshold) { gl_Position = vec4(2.0, 2.0, 2.0, 0.0); return; }
vec3 pos = position; float isRing = aParams.w; float speed = aParams.z;
if (isRing > 0.5) { pos.xz = rotate2d(uTime * speed * 0.2) * pos.xz; } else { pos.xz = rotate2d(uTime * 0.03) * pos.xz; }
float cx = cos(uRotationX), sx = sin(uRotationX);
float ry = pos.y * cx - pos.z * sx; float rz = pos.y * sx + pos.z * cx; pos.y = ry; pos.z = rz;
float cy = cos(uRotationY), sy = sin(uRotationY);
float rx = pos.x * cy + pos.z * sy; rz = -pos.x * sy + pos.z * cy; pos.x = rx; pos.z = rz;
vec4 mvPosition = modelViewMatrix * vec4(pos * uScale, 1.0);
float dist = -mvPosition.z;
if (dist < 25.0 && dist > 0.1) {
float chaos = pow(1.0 - (dist / 25.0), 3.0) * 3.0; float ht = uTime * 40.0;
vec3 noiseVec = vec3(sin(ht + pos.x * 10.0) * hash(pos.y), cos(ht + pos.y * 10.0) * hash(pos.x), sin(ht * 0.5) * hash(pos.z));
mvPosition.xyz += noiseVec * chaos;
}
gl_Position = projectionMatrix * mvPosition;
float pointSize = aParams.x * (190.0 / dist); if (isRing < 0.5 && dist < 50.0) pointSize *= 0.8;
gl_PointSize = clamp(pointSize, 0.0, 150.0);
vColor = customColor; vOpacity = aParams.y; vScaleFactor = uScale; vIsRing = isRing; vDist = dist;
}
</script>
<script type="x-shader/x-fragment" id="fragmentshader">
varying vec3 vColor; varying float vDist; varying float vOpacity; varying float vScaleFactor; varying float vIsRing;
void main() {
vec2 cxy = 2.0 * gl_PointCoord - 1.0; float r2 = dot(cxy, cxy); if (r2 > 1.0) discard;
float glow = 1.0 - r2; float t = clamp((vScaleFactor - 0.15) / 2.35, 0.0, 1.0);
vec3 baseColor = mix(vec3(0.35, 0.22, 0.05), vColor, smoothstep(0.1, 0.9, t)); vec3 finalColor = baseColor * (0.2 + t);
if (vDist < 40.0) {
float closeMix = 1.0 - (vDist / 40.0);
if (vIsRing < 0.5) finalColor = mix(finalColor, vColor * vColor * 1.5, closeMix * 0.8); else finalColor += vec3(0.15, 0.12, 0.1) * closeMix;
}
float alpha = glow * vOpacity * (0.25 + 0.45 * t); if (vDist < 10.0) alpha *= vDist * 0.1;
gl_FragColor = vec4(finalColor, alpha);
}
</script>
<script type="x-shader/x-vertex" id="starVS">
attribute float size; attribute vec3 customColor; varying vec3 vColor;
void main() { vColor = customColor; vec4 mvPos = modelViewMatrix * vec4(position, 1.0); gl_PointSize = clamp(size * (1000.0 / -mvPos.z), 1.0, 8.0); gl_Position = projectionMatrix * mvPos; }
</script>
<script type="x-shader/x-fragment" id="starFS">
varying vec3 vColor; uniform float uTime; float hash(vec2 st) { return fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.545); }
void main() { vec2 cxy = 2.0 * gl_PointCoord - 1.0; float r2 = dot(cxy, cxy); if(r2 > 1.0) discard; gl_FragColor = vec4(vColor * (0.7 + 0.3 * sin(uTime * 2.0 + hash(gl_FragCoord.xy) * 10.0)), (1.0 - r2) * 0.8); }
</script>
<script>
const CONFIG = {
particles: 1000000, planetRadius: 18,
lerpSpeed: 0.2, cameraZ: 100, pixelRatioLimit: 1.5
};
let scene, camera, renderer, particles, stars;
let uniforms, starUniforms;
let targetScale = 1.0, targetRotX = 0.4, targetRotY = 0.0;
let currentScale = 1.0, currentRotX = 0.4, currentRotY = 0.0;
// --- 核心状态机 ---
// 'IDLE': 初始/比耶后的自动缓游荡归位状态
// 'TRACKING': 手势控制状态
// 'FROZEN': 手离开画面,保持当前形态
let systemState = 'IDLE';
let lostHandFrames = 0;
const LOST_TOLERANCE = 15;
let smoothedPinchDist = 0.1;
let smoothedPalmX = 0.5;
let smoothedPalmY = 0.5;
const videoElement = document.querySelector('.input_video');
const statusElement = document.getElementById('status-indicator');
const loadingElement = document.getElementById('loading');
function initThree() {
scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020202, 0.00015);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 10000); camera.position.z = CONFIG.cameraZ;
initSaturn(); initStarfield();
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, CONFIG.pixelRatioLimit));
document.getElementById('canvas-container').appendChild(renderer.domElement);
window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
animate();
}
function initSaturn() {
const geometry = new THREE.BufferGeometry();
const pos = new Float32Array(CONFIG.particles * 3), cols = new Float32Array(CONFIG.particles * 3), params = new Float32Array(CONFIG.particles * 4), randomIds = new Float32Array(CONFIG.particles);
const R = CONFIG.planetRadius; const bodyCols = ['#E3DAC5', '#C9A070', '#E3DAC5', '#B08D55'].map(c => new THREE.Color(c));
for(let i = 0; i < CONFIG.particles; i++) {
let x, y, z, r, g, b, size, opacity, speed, isRingVal; randomIds[i] = Math.random();
if (i < CONFIG.particles * 0.25) {
isRingVal = 0.0; speed = 0.0;
const phi = Math.acos(2 * Math.random() - 1), theta = 2 * Math.PI * Math.random();
x = R * Math.sin(phi) * Math.cos(theta); y = R * Math.cos(phi) * 0.9; z = R * Math.sin(phi) * Math.sin(theta);
let lat = (y / R + 1.0) * 0.5; let cIdx = Math.max(0, Math.floor(lat * 4 + Math.cos(lat * 40.0)*0.8) % 4);
r = bodyCols[cIdx].r; g = bodyCols[cIdx].g; b = bodyCols[cIdx].b; size = 1.0 + Math.random() * 0.8; opacity = 0.8;
} else {
isRingVal = 1.0; let zR = Math.random(), ringR;
if (zR < 0.15) { ringR = R * (1.235 + Math.random()*0.29); r=0.16; g=0.14; b=0.12; size=0.5; opacity=0.3; }
else if (zR < 0.65) { ringR = R * (1.525 + Math.random()*0.425); r=0.8; g=0.75; b=0.62; size=0.8+Math.random()*0.6; opacity=0.85; }
else if (zR < 0.69) { ringR = R * (1.95 + Math.random()*0.075); r=0.02; g=0.02; b=0.02; size=0.3; opacity=0.1; }
else if (zR < 0.99) { ringR = R * (2.025 + Math.random()*0.245); r=0.59; g=0.56; b=0.52; size=0.7; opacity=0.6; }
else { ringR = R * (2.32 + Math.random()*0.02); r=0.68; g=0.68; b=0.62; size=1.0; opacity=0.7; }
const theta = Math.random() * Math.PI * 2;
x = ringR * Math.cos(theta); z = ringR * Math.sin(theta); y = (Math.random() - 0.5) * (ringR > R*2.3 ? 0.4 : 0.15);
speed = 8.0 / Math.sqrt(ringR);
}
pos[i*3]=x; pos[i*3+1]=y; pos[i*3+2]=z; cols[i*3]=r; cols[i*3+1]=g; cols[i*3+2]=b;
let pIdx = i * 4; params[pIdx] = size; params[pIdx+1] = opacity; params[pIdx+2] = speed; params[pIdx+3] = isRingVal;
}
geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geometry.setAttribute('customColor', new THREE.BufferAttribute(cols, 3));
geometry.setAttribute('aParams', new THREE.BufferAttribute(params, 4)); geometry.setAttribute('aRandomId', new THREE.BufferAttribute(randomIds, 1));
uniforms = { uTime: { value: 0 }, uScale: { value: 1.0 }, uRotationX: { value: 0.4 }, uRotationY: { value: 0.0 } };
particles = new THREE.Points(geometry, new THREE.ShaderMaterial({ depthWrite: false, blending: THREE.AdditiveBlending, transparent: true, uniforms: uniforms, vertexShader: document.getElementById('vertexshader').textContent, fragmentShader: document.getElementById('fragmentshader').textContent }));
particles.rotation.z = 26.73 * (Math.PI / 180); scene.add(particles);
}
function initStarfield() {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(30000 * 3), cols = new Float32Array(30000 * 3), sizes = new Float32Array(30000);
for(let i=0; i<30000; i++) {
const r = 400 + Math.random() * 3000, t = Math.random()*Math.PI*2, p = Math.acos(2*Math.random()-1);
pos[i*3]=r*Math.sin(p)*Math.cos(t); pos[i*3+1]=r*Math.cos(p); pos[i*3+2]=r*Math.sin(p)*Math.sin(t);
cols[i*3]=0.8+Math.random()*0.2; cols[i*3+1]=0.8+Math.random()*0.2; cols[i*3+2]=0.9+Math.random()*0.1; sizes[i]=1.0+Math.random()*2.0;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('customColor', new THREE.BufferAttribute(cols, 3)); geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
starUniforms = { uTime: { value: 0 } };
stars = new THREE.Points(geo, new THREE.ShaderMaterial({ uniforms: starUniforms, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: document.getElementById('starVS').textContent, fragmentShader: document.getElementById('starFS').textContent }));
scene.add(stars);
}
const clock = new THREE.Clock(); let autoIdleTime = 0;
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
uniforms.uTime.value = time; if(starUniforms) starUniforms.uTime.value = time;
if(stars) stars.rotation.y = time * 0.002;
// 依据不同状态执行不同逻辑
if (systemState === 'IDLE') {
autoIdleTime += 0.01;
targetScale = 1.0 + Math.sin(autoIdleTime) * 0.1;
targetRotX = 0.4 + Math.sin(autoIdleTime * 0.3) * 0.2;
targetRotY = Math.sin(autoIdleTime * 0.2) * 0.4;
statusElement.innerText = "STANDBY / IDLE (AUTO ORBIT)";
statusElement.className = "highlight idle-state";
} else if (systemState === 'FROZEN') {
// FROZEN 状态下不修改 target,维持原状
statusElement.innerText = "SIGNAL LOST / POSITION LOCKED";
statusElement.className = "highlight frozen-state";
} else if (systemState === 'TRACKING') {
statusElement.innerText = "LINK ESTABLISHED / FULL CONTROL";
statusElement.className = "highlight active-pulse";
}
// 平滑过渡
currentScale += (targetScale - currentScale) * CONFIG.lerpSpeed;
currentRotX += (targetRotX - currentRotX) * CONFIG.lerpSpeed;
currentRotY += (targetRotY - currentRotY) * CONFIG.lerpSpeed;
uniforms.uScale.value = currentScale; uniforms.uRotationX.value = currentRotX; uniforms.uRotationY.value = currentRotY;
renderer.render(scene, camera);
}
const hands = new Hands({locateFile: (f) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${f}`});
hands.setOptions({ maxNumHands: 1, modelComplexity: 1, minDetectionConfidence: 0.65, minTrackingConfidence: 0.65 });
hands.onResults((results) => {
loadingElement.style.display = 'none';
if (results.multiHandLandmarks?.length > 0) {
lostHandFrames = 0;
const hand = results.multiHandLandmarks[0];
// 1. 识别是否为"比耶 (V字)"手势
// 逻辑:食指和中指伸直(指尖在关节上方),无名指和小指弯曲(指尖在关节下方)
const indexUp = hand[8].y < hand[6].y;
const middleUp = hand[12].y < hand[10].y;
const ringDown = hand[16].y > hand[14].y;
const pinkyDown = hand[20].y > hand[18].y;
const isVSign = indexUp && middleUp && ringDown && pinkyDown;
if (isVSign) {
// 如果识别到比耶,立刻进入 IDLE 归位模式
systemState = 'IDLE';
// 让平滑滤波器的数据也同步往默认状态靠拢,防止放下手切回 TRACKING 时镜头突变抽搐
smoothedPinchDist += (0.1 - smoothedPinchDist) * 0.1;
smoothedPalmX += (0.5 - smoothedPalmX) * 0.1;
smoothedPalmY += (0.5 - smoothedPalmY) * 0.1;
} else {
// 正常追踪手势
systemState = 'TRACKING';
const palm = hand[9];
const rawPinchDist = Math.hypot(hand[4].x - hand[8].x, hand[4].y - hand[8].y);
const rawPalmX = palm.x;
const rawPalmY = palm.y;
const filterFactor = 0.15;
smoothedPinchDist += (rawPinchDist - smoothedPinchDist) * filterFactor;
smoothedPalmX += (rawPalmX - smoothedPalmX) * filterFactor;
smoothedPalmY += (rawPalmY - smoothedPalmY) * filterFactor;
targetScale = 0.15 + Math.max(0, Math.min(1, (smoothedPinchDist - 0.02) / 0.18)) * 2.6;
targetRotY = -(smoothedPalmX - 0.5) * Math.PI * 2.5;
targetRotX = (smoothedPalmY - 0.5) * Math.PI * 1.5;
}
} else {
lostHandFrames++;
if (lostHandFrames > LOST_TOLERANCE) {
// 如果手势丢失,且之前是在 TRACKING 模式,则冻结在当前位置
if (systemState === 'TRACKING') {
systemState = 'FROZEN';
}
// 如果之前是因为比耶进入了 IDLE 模式,手离开后也会继续保持漂亮的缓游荡状态,不会突变。
}
}
});
new Camera(videoElement, { onFrame: async () => { await hands.send({image: videoElement}); }, width: 640, height: 480 }).start();
function toggleFullScreen() { if (!document.fullscreenElement) document.documentElement.requestFullscreen(); else if (document.exitFullscreen) document.exitFullscreen(); }
initThree();
</script>
</body>
</html>