index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>VERMIS // HAND</title>
<style>
:root{--ink:#141414;--mute:#9a9a9a;--line:#e7e7e7;--acc:#e0442e}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}
#gl{position:fixed;inset:0;display:block}
#hud{position:fixed;top:16px;left:16px;width:264px;background:rgba(255,255,255,.94);border:1px solid var(--line);padding:14px 14px 12px;font-size:11px;letter-spacing:.02em;user-select:none;max-height:calc(100vh - 32px);overflow:auto}
#hud.hidden{display:none}
.row{display:flex;justify-content:space-between;align-items:center;margin:5px 0}
h1{font-size:12px;font-weight:600;margin:0 0 10px;letter-spacing:.08em;display:flex;justify-content:space-between}
h1 span{color:var(--mute);font-weight:400}
#status{display:flex;align-items:center;gap:7px}
#dot{width:7px;height:7px;border-radius:50%;background:#ccc}
#gesture{font-size:20px;font-weight:600;letter-spacing:.06em;margin:8px 0 6px}
.bar{width:140px;height:4px;background:#f0f0f0;position:relative}
.bar i{position:absolute;left:0;top:0;bottom:0;background:var(--ink);width:0}
#edgeBar i{background:var(--acc)}
hr{border:0;border-top:1px solid var(--line);margin:10px 0}
input[type=range]{width:100px;accent-color:#141414}
.btns{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
#mode{color:var(--mute);margin:-2px 0 8px}
button{font:inherit;font-size:11px;background:#fff;border:1px solid #d6d6d6;padding:6px 9px;cursor:pointer;color:var(--ink)}
button:hover{border-color:var(--ink)}
.legend{color:var(--mute);line-height:1.6;margin-top:10px}
#cam{position:fixed;top:16px;right:16px;width:208px;height:156px;border:1px solid var(--line);background:#fafafa}
#cam video,#cam canvas{position:absolute;inset:0;width:100%;height:100%;transform:scaleX(-1);object-fit:cover}
#cam video{opacity:.55;filter:grayscale(1)}
#start{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.55)}
#start .card{background:#fff;border:1px solid var(--line);padding:26px 28px;width:340px;font-size:12px;line-height:1.6}
#start .card b{display:block;font-size:13px;letter-spacing:.08em;margin-bottom:8px}
#start button{margin-top:14px;padding:9px 14px;font-size:12px}
#msg{color:var(--mute);margin-top:10px;min-height:1em}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"}}
</script>
</head>
<body>
<canvas id="gl"></canvas>
<div id="hud">
<h1>VERMIS // HAND <span>v0.5</span></h1>
<div id="status"><div id="dot"></div><span id="statusTxt">bereit</span></div>
<div id="gesture">—</div>
<div id="mode">folgt</div>
<div class="row"><span>Hand offen</span><div class="bar" id="openBar"><i></i></div></div>
<div class="row"><span>Kante</span><div class="bar" id="edgeBar"><i></i></div></div>
<hr>
<div class="row"><span>Hand-Glättung</span><input id="sSmooth" type="range" min="0" max="1" step="0.01" value="0.75"></div>
<div class="row"><span>Rot.-Offset</span><input id="sOff" type="range" min="0" max="1" step="0.01" value="0.3"></div>
<div class="row"><span>Tiefe</span><input id="sDepth" type="range" min="1" max="5" step="0.05" value="3"></div>
<div class="row"><span>Randzone</span><input id="sEdge" type="range" min="0.01" max="0.2" step="0.005" value="0.05"></div>
<hr>
<div class="row"><span>Folgen</span><input id="sFollow" type="range" min="2" max="24" step="0.5" value="11"></div>
<div class="row"><span>Wendigkeit</span><input id="sTurn" type="range" min="2" max="30" step="0.5" value="16"></div>
<div class="row"><span>Biegsamkeit</span><input id="sBody" type="range" min="0" max="1" step="0.01" value="0.75"></div>
<div class="row"><span>Kringel-Tempo</span><input id="sCurl" type="range" min="0" max="3" step="0.05" value="1.2"></div>
<div class="row"><span>Panelgröße</span><input id="sPanel" type="range" min="0.5" max="2.5" step="0.05" value="1.3"></div>
<hr>
<div class="row"><span>Lautstärke</span><input id="sVol" type="range" min="0" max="1" step="0.01" value="0.6"></div>
<div class="btns"><button id="bCal">Kalibrieren (C)</button><button id="bMute">Ton an (M)</button><button id="bMut">Mutieren (N)</button><button id="bOrig">Original</button></div>
<div class="legend">Faust → kringelt sich um die Hand, Panels zu<br>Metal (Zeige+Kleiner) → zittert<br>Victory (halten) → steht senkrecht<br>Zeigefinger → Windböe<br>H → HUD aus</div>
</div>
<div id="cam"><video id="video" playsinline muted></video><canvas id="ov" width="208" height="156"></canvas></div>
<div id="start"><div class="card">
<b>VERMIS // HAND</b>
Hand mittig vor die Kamera halten, Handfläche zur Kamera, Finger nach oben. Beim ersten Erkennen wird diese Pose als Neutral-Rotation und Mitteltiefe gesetzt (später mit C neu kalibrieren).
<br><button id="bStart">Kamera & Ton starten</button>
<div id="msg">Lade Handtracking-Modell…</div>
</div></div>
<script type="module">
import * as THREE from 'three';
import { FilesetResolver, HandLandmarker } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/vision_bundle.mjs';
/* ---------- helpers ---------- */
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
const lerp=(a,b,t)=>a+(b-a)*t;
const damp=(k,dt)=>1-Math.exp(-k*dt);
function mulberry32(a){return()=>{a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const rnd=mulberry32(11);
class OneEuro{
constructor(minCut=1,beta=.3,dCut=1){this.minCut=minCut;this.beta=beta;this.dCut=dCut;this.x=null;this.dx=0}
a(c,dt){const tau=1/(2*Math.PI*c);return 1/(1+tau/dt)}
filter(v,dt){
if(this.x===null){this.x=v;this.dx=0;return v}
const dv=(v-this.x)/dt; this.dx+=this.a(this.dCut,dt)*(dv-this.dx);
const c=this.minCut+this.beta*Math.abs(this.dx); this.x+=this.a(c,dt)*(v-this.x); return this.x;
}
reset(){this.x=null}
}
/* ---------- config ---------- */
const HX=6, HY=3.2, HZ=6; // Tracking-Volumen (halbe Kantenlängen)
const N=12, L=0.55; // Glieder / Gliedlänge
const RINGS=4, RAD=7; // Low-Poly-Auflösung Körper
const totalLen=(N-1)*L;
const UP=new THREE.Vector3(0,1,0), ZAX=new THREE.Vector3(0,0,1), ORIGIN=new THREE.Vector3();
/* ---------- renderer / scene ---------- */
const renderer=new THREE.WebGLRenderer({canvas:document.getElementById('gl'),antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));
renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
const scene=new THREE.Scene();
scene.background=new THREE.Color(0xf4f4f3);
scene.fog=new THREE.Fog(0xf4f4f3,24,48);
const camera=new THREE.PerspectiveCamera(38,innerWidth/innerHeight,.1,100);
camera.position.set(0,2.2,17.5); camera.lookAt(0,-.3,0);
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight)});
scene.add(new THREE.HemisphereLight(0xffffff,0xb9b9b9,1.25));
const sun=new THREE.DirectionalLight(0xffffff,2.1);
sun.position.set(5,10,7); sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024);
Object.assign(sun.shadow.camera,{left:-12,right:12,top:12,bottom:-12,near:1,far:40});
sun.shadow.bias=-.0005; sun.shadow.normalBias=.02;
scene.add(sun);
const floor=new THREE.Mesh(new THREE.PlaneGeometry(80,80).rotateX(-Math.PI/2),new THREE.ShadowMaterial({opacity:.11}));
floor.position.y=-HY-1.3; floor.receiveShadow=true; scene.add(floor);
const boxMat=new THREE.LineBasicMaterial({color:0xd2d2d2,transparent:true,opacity:.8});
const box=new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(HX*2,HY*2,HZ*2)),boxMat);
scene.add(box);
const marker=new THREE.Mesh(new THREE.SphereGeometry(.06,8,6),new THREE.MeshBasicMaterial({color:0xbdbdbd}));
scene.add(marker);
/* ---------- materials ---------- */
const matBody=new THREE.MeshStandardMaterial({color:0x2f3237,roughness:.55,metalness:.25,flatShading:true});
const matPanel=new THREE.MeshStandardMaterial({color:0x585d65,roughness:.42,metalness:.35,flatShading:true});
const matDark=new THREE.MeshStandardMaterial({color:0x1c1e22,roughness:.5,metalness:.3,flatShading:true});
const matEye=new THREE.MeshStandardMaterial({color:0xff2a14,emissive:0xff2410,emissiveIntensity:1,roughness:.3,flatShading:true});
/* ---------- worm builder (mutierbar) ---------- */
const DEF={seed:11,rS:.36,rE:.09,pw:.8,rip:.22,rad:7,pMin:1,pMax:3,pW:2,pWr:1,pL:2,pTaper:.45,pOpen:.55,pOpenR:.85,
tH:6,tHn:11,tT:3,tTn:7,tThick:.05,hR:.25,hEl:1.3,hSub:1,bodyL:.19,panelL:.36,hue:.6,sat:.05,collars:1};
let WS=Object.assign({},DEF);
let panelK=1.3;
let bones=[], body=null, head=null, panels=[], tentacles=[], tMesh=null;
function setPanelScale(k){panelK=k;for(const p of panels)p.m.scale.set(1,k,k)}
const tGeo=new THREE.CylinderGeometry(.82,1,1,5,1,true); tGeo.translate(0,.5,0);
function applyColors(){
matBody.color.setHSL(WS.hue,WS.sat,WS.bodyL);
matPanel.color.setHSL(WS.hue,WS.sat,WS.panelL);
matDark.color.setHSL(WS.hue,WS.sat,WS.bodyL*.6);
}
function randomParams(){
const R=Math.random, q=(a,b)=>a+(b-a)*R();
const pMin=Math.floor(q(0,2.2)), pMax=pMin+1+Math.floor(q(0,2.5));
return {seed:Math.floor(R()*1e9),rS:q(.28,.46),rE:q(.05,.14),pw:q(.6,1.2),rip:q(.1,.4),rad:5+Math.floor(R()*4),
pMin,pMax,pW:q(1.4,2.8),pWr:q(.3,1),pL:q(1.3,2.6),pTaper:q(.2,.7),pOpen:q(.4,.8),pOpenR:q(.4,1.2),
tH:Math.floor(q(2,10)),tHn:Math.floor(q(6,16)),tT:Math.floor(q(0,6)),tTn:Math.floor(q(4,12)),tThick:q(.03,.07),
hR:q(.18,.3),hEl:q(.9,1.7),hSub:R()<.5?0:1,bodyL:q(.14,.3),panelL:q(.26,.5),hue:R(),sat:R()<.6?0:q(.04,.12),collars:R()<.7?1:0};
}
function mutate(){WS=randomParams();buildWorm()}
function buildWorm(){
if(body){scene.remove(body);body.traverse(o=>{if(o.geometry)o.geometry.dispose()});body.skeleton.dispose()}
if(tMesh){scene.remove(tMesh);tMesh.dispose()}
applyColors();
const r=mulberry32(WS.seed), RAD=WS.rad;
const baseR=z=>{const s=clamp(z/totalLen,0,1);return lerp(WS.rS,WS.rE,Math.pow(s,WS.pw))};
const radiusAt=z=>{const f=((z/L)%1+1)%1;return baseR(z)*((1-WS.rip)+WS.rip*Math.sin(Math.PI*f))};
// Körper
const pos=[],si=[],sw=[],idx=[];
const pushV=(x,y,z)=>{
pos.push(x,y,z);
const u=z/L; const b=clamp(Math.floor(u),0,N-2); let t=clamp(u-b,0,1); t=t*t*(3-2*t);
si.push(b,b+1,0,0); sw.push(1-t,t,0,0);
};
const rings=(N-1)*RINGS+1;
for(let k=0;k<rings;k++){
const z=k*L/RINGS, rr=radiusAt(z), off=(k%2)*Math.PI/RAD;
for(let j=0;j<RAD;j++){const a=j/RAD*Math.PI*2+off;pushV(Math.cos(a)*rr,Math.sin(a)*rr*.88,z)}
}
for(let k=0;k<rings-1;k++)for(let j=0;j<RAD;j++){
const a=k*RAD+j,b=k*RAD+(j+1)%RAD,c=(k+1)*RAD+j,d=(k+1)*RAD+(j+1)%RAD;
idx.push(a,b,c,b,d,c);
}
const f0=pos.length/3; pushV(0,0,-.02);
for(let j=0;j<RAD;j++)idx.push(f0,(j+1)%RAD,j);
const tip=pos.length/3; pushV(0,0,totalLen+.3);
const last=(rings-1)*RAD;
for(let j=0;j<RAD;j++)idx.push(last+j,last+(j+1)%RAD,tip);
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
g.setAttribute('skinIndex',new THREE.Uint16BufferAttribute(si,4));
g.setAttribute('skinWeight',new THREE.Float32BufferAttribute(sw,4));
g.setIndex(idx); g.computeVertexNormals();
bones=[];
for(let i=0;i<N;i++){const b=new THREE.Bone();b.position.set(0,0,i*L);bones.push(b)}
body=new THREE.SkinnedMesh(g,matBody);
bones.forEach(b=>body.add(b));
body.bind(new THREE.Skeleton(bones));
body.frustumCulled=false; body.castShadow=true;
scene.add(body);
// Kragen
if(WS.collars)for(let i=1;i<N;i++){
const c=new THREE.Mesh(new THREE.TorusGeometry(baseR(i*L)*.8,.035,3,8),matDark);
c.castShadow=true; bones[i].add(c);
}
// Kopf
head=new THREE.Group(); bones[0].add(head);
const hg=new THREE.IcosahedronGeometry(WS.hR,WS.hSub); hg.scale(.9,.8,WS.hEl);
const skull=new THREE.Mesh(hg,matBody); skull.position.z=-WS.hR*.55; skull.castShadow=true; head.add(skull);
// Panels
panels=[];
for(let i=1;i<N-2;i++){
const count=WS.pMin+Math.floor(r()*(WS.pMax-WS.pMin+1));
const slots=[0,1,2,3,4,5].sort(()=>r()-.5);
for(let c=0;c<count;c++){
const th=(slots[c]+.2+r()*.6)/6*Math.PI*2;
const pz=.04*L, plen=L*WS.pL*(.85+r()*.3);
const rH=radiusAt(i*L+pz), rM=baseR(i*L+plen)*.92;
const w=baseR(i*L)*(WS.pW+r()*WS.pWr);
const pg=new THREE.BoxGeometry(.03,w,plen); pg.translate(.015,0,plen/2);
const pp=pg.attributes.position;
for(let v=0;v<pp.count;v++){pp.setY(v,pp.getY(v)*(1-WS.pTaper*(pp.getZ(v)/plen)))}
pg.computeVertexNormals();
const piv=new THREE.Group(); piv.position.set(Math.cos(th)*rH,Math.sin(th)*rH*.88,pz); piv.rotation.z=th;
const hinge=new THREE.Group(); piv.add(hinge);
const m=new THREE.Mesh(pg,matPanel); m.castShadow=true; m.scale.set(1,panelK,panelK); hinge.add(m);
bones[i].add(piv);
const closedA=Math.atan2(rM-rH+.05,plen);
panels.push({hinge,m,bone:i,closedA,openA:closedA+WS.pOpen+r()*WS.pOpenR,delay:i*.035+r()*.06,s:panelTarget,v:0,phase:r()*6.28});
}
}
// Tentakel
tentacles=[];
const addT=(bone,local,n,seg,r0)=>tentacles.push({bone,local,n,seg,r0,pts:[],prev:[],init:false,aOld:new THREE.Vector3(),aNow:new THREE.Vector3()});
for(let k=0;k<WS.tH;k++){
const th=Math.PI+(k+.5)/WS.tH*Math.PI, rr=radiusAt(L+.25)*.9;
addT(1,new THREE.Vector3(Math.cos(th)*rr,Math.sin(th)*rr*.88,.25),WS.tHn+Math.floor(r()*3),.3,WS.tThick);
}
for(let k=0;k<WS.tT;k++){
const th=k/Math.max(1,WS.tT)*Math.PI*2+.4;
addT(N-1,new THREE.Vector3(Math.cos(th)*.06,Math.sin(th)*.06,.2),WS.tTn,.25,WS.tThick*.7);
}
const segTotal=tentacles.reduce((a,t)=>a+t.n,0);
tMesh=new THREE.InstancedMesh(tGeo,matDark,Math.max(1,segTotal)); tMesh.count=segTotal;
tMesh.frustumCulled=false; tMesh.castShadow=true; scene.add(tMesh);
}
/* ---------- state ---------- */
const headPos=new THREE.Vector3(), headVel=new THREE.Vector3(), velS=new THREE.Vector3();
const dirS=new THREE.Vector3(0,0,-1), dirT=new THREE.Vector3(0,0,-1);
const qDir=new THREE.Quaternion(), qBank=new THREE.Quaternion(), qOff=new THREE.Quaternion(), qOffT=new THREE.Quaternion(), qHead=new THREE.Quaternion();
const qI=new THREE.Quaternion(), qRaw=new THREE.Quaternion(), qHandS=new THREE.Quaternion(), qRel=new THREE.Quaternion(), qTmp=new THREE.Quaternion();
const mTmp=new THREE.Matrix4();
let lastYaw=0,yawRate=0,bank=0;
const wp=[],wprev=[];
for(let i=0;i<N;i++){wp.push(new THREE.Vector3(0,0,i*L));wprev.push(new THREE.Vector3(0,0,i*L))}
const handTarget=new THREE.Vector3(), lastHandTarget=new THREE.Vector3(), target=new THREE.Vector3(), wander=new THREE.Vector3();
let wanderAmp=2.6;
const fX=new OneEuro(1,.3), fY=new OneEuro(1,.3), fZ=new OneEuro(.6,.25), fS=new OneEuro(1,.5);
let tracking=false, everTracked=false, lastSeen=-10, lastDetT=0, started=false, camError=false;
let refScale=null, lastScale=1, depthRange=1+1/3, zN=0;
const neutral={}; let lastHand='';
let prox=0, openness=1, fistState=false;
let gesture='—', candG='', candN=0;
let panelTarget=1, panelOld=1, panelChangeT=-10;
let pinchS=0, pinchT=0, windBoost=1, windBoostT=1;
const pulses=[]; let lastPulseT=-10;
let offStrength=.3, handSm=.75, followK=11, turnK=16, bodyFlex=.75, curlSpeed=1.2, edgeZone=.05;
let mouseTarget=null, reacq=false;
let curlS=0,curlT=0,jitS=0,jitT=0,standS=0,standLatch=false,standRel=-10,eager=1;
const standPos=new THREE.Vector3(), upRef=new THREE.Vector3();
const qJit=new THREE.Quaternion(), eJit=new THREE.Euler();
const wf=[], coilP=[], dF=new THREE.Vector3(), dP=new THREE.Vector3(), dC=new THREE.Vector3(), sA=new THREE.Vector3();
for(let i=0;i<N;i++){wf.push(new THREE.Vector3(0,0,i*L));coilP.push(new THREE.Vector3())}
const mEl=document.getElementById('mode');
const handLP=new THREE.Vector3(); let handNew=true;
buildWorm();
/* ---------- UI ---------- */
const $=id=>document.getElementById(id);
$('sOff').oninput=e=>offStrength=+e.target.value;
$('sSmooth').oninput=e=>{handSm=+e.target.value;applySmooth()};
$('sDepth').oninput=e=>depthRange=1+1/(+e.target.value);
$('sVol').oninput=e=>{if(master)master.gain.value=+e.target.value};
function applySmooth(){const mc=lerp(2.5,.12,handSm),be=lerp(.3,.02,handSm);fX.minCut=mc;fY.minCut=mc;fZ.minCut=mc*.4;fX.beta=be;fY.beta=be;fZ.beta=be*.8}
applySmooth();
let soundOn=true;
function toggleMute(){soundOn=!soundOn;$('bMute').textContent=soundOn?'Ton an (M)':'Ton aus (M)'}
function calibrate(){if(!tracking)return;refScale=lastScale;neutral[lastHand]=qHandS.clone()}
$('bMute').onclick=toggleMute; $('bCal').onclick=calibrate;
$('bMut').onclick=mutate; $('bOrig').onclick=()=>{WS=Object.assign({},DEF);buildWorm()};
$('sFollow').oninput=e=>followK=+e.target.value;
$('sTurn').oninput=e=>turnK=+e.target.value;
$('sBody').oninput=e=>bodyFlex=+e.target.value;
$('sCurl').oninput=e=>curlSpeed=+e.target.value;
$('sPanel').oninput=e=>setPanelScale(+e.target.value);
$('sEdge').oninput=e=>edgeZone=+e.target.value;
addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(k==='c')calibrate(); if(k==='m')toggleMute(); if(k==='h')$('hud').classList.toggle('hidden'); if(k==='n')mutate();
});
addEventListener('mousemove',e=>{
if(!camError)return;
mouseTarget=new THREE.Vector3((e.clientX/innerWidth*2-1)*HX,-(e.clientY/innerHeight*2-1)*HY,0);
});
/* ---------- audio ---------- */
let actx=null,master,clickBuf,lostGain,nextClick=0;
function initAudio(){
actx=new (window.AudioContext||window.webkitAudioContext)();
master=actx.createGain(); master.gain.value=+$('sVol').value; master.connect(actx.destination);
const sr=actx.sampleRate,n=Math.floor(sr*.02);
clickBuf=actx.createBuffer(1,n,sr); const d=clickBuf.getChannelData(0);
for(let i=0;i<n;i++){const t=i/sr;d[i]=(Math.sin(2*Math.PI*1050*t)*.6+(Math.random()*2-1)*.35*Math.exp(-t*1400))*Math.exp(-t*300)*.7}
const osc=actx.createOscillator(); osc.type='sine'; osc.frequency.value=300;
const lp=actx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=900;
lostGain=actx.createGain(); lostGain.gain.value=0;
osc.connect(lp).connect(lostGain).connect(master); osc.start();
}
function click(when,g){
const s=actx.createBufferSource(); s.buffer=clickBuf;
const gg=actx.createGain(); gg.gain.value=g; s.connect(gg).connect(master); s.start(when);
}
function updateAudio(nowS){
if(!actx)return;
const now=actx.currentTime;
const lostOn=soundOn&&started&&everTracked&&!camError&&(nowS-lastSeen>.35);
lostGain.gain.setTargetAtTime(lostOn?.035:0,now,.12);
if(soundOn&&tracking&&prox>.01){
const interval=lerp(1.5,.3,Math.pow(prox,1.2));
if(nextClick<now)nextClick=now+.01;
if(nextClick-now>interval)nextClick=now+interval;
while(nextClick<now+.1){click(nextClick,.3+.4*prox);nextClick+=interval}
}else nextClick=0;
}
/* ---------- hand tracking ---------- */
const WASM='https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm';
const MODEL='https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task';
async function createHands(){
const fs=await FilesetResolver.forVisionTasks(WASM);
const opts=d=>({baseOptions:{modelAssetPath:MODEL,delegate:d},runningMode:'VIDEO',numHands:1,
minHandDetectionConfidence:.6,minHandPresenceConfidence:.55,minTrackingConfidence:.5});
try{return await HandLandmarker.createFromOptions(fs,opts('GPU'))}
catch(e){return await HandLandmarker.createFromOptions(fs,opts('CPU'))}
}
let hands=null;
const handsPromise=createHands().then(h=>{hands=h;$('msg').textContent='Modell geladen.';return h})
.catch(e=>{$('msg').textContent='Modell konnte nicht geladen werden: '+e.message});
const video=$('video');
async function initCam(){
const stream=await navigator.mediaDevices.getUserMedia({video:{width:{ideal:640},height:{ideal:480},facingMode:'user'},audio:false});
video.srcObject=stream; await video.play();
}
$('bStart').onclick=async()=>{
initAudio(); try{await actx.resume()}catch(e){}
$('msg').textContent='Starte Kamera…';
try{await initCam()}catch(e){camError=true;$('statusTxt').textContent='keine Kamera – Maus steuert'}
if(!camError){$('msg').textContent='Warte auf Modell…';await handsPromise}
$('start').style.display='none'; started=true;
};
const CONN=[[0,1],[1,2],[2,3],[3,4],[0,5],[5,6],[6,7],[7,8],[5,9],[9,10],[10,11],[11,12],[9,13],[13,14],[14,15],[15,16],[13,17],[17,18],[18,19],[19,20],[0,17]];
const HAND_S=6, handT=new Float32Array(63);
const handGroup=new THREE.Group(); scene.add(handGroup);
const handPosAttr=new THREE.BufferAttribute(new Float32Array(63),3);
const handGeo=new THREE.BufferGeometry(); handGeo.setAttribute('position',handPosAttr); handGeo.setIndex(CONN.flat());
const handLines=new THREE.LineSegments(handGeo,new THREE.LineBasicMaterial({color:0x141414,transparent:true,opacity:.55}));
const handDots=new THREE.Points(handGeo,new THREE.PointsMaterial({color:0x141414,size:.07,sizeAttenuation:true}));
handLines.frustumCulled=false; handDots.frustumCulled=false; handGroup.add(handLines,handDots);
const ov=$('ov'), oc=ov.getContext('2d');
const P=[...Array(5)].map(()=>new THREE.Vector3());
function handQuat(wl,out){
const g=(i,v)=>v.set(-wl[i].x,-wl[i].y,-wl[i].z);
const p0=g(0,P[0]),p5=g(5,P[1]),p9=g(9,P[2]),p17=g(17,P[3]);
const up=p9.sub(p0).normalize();
const side=p5.sub(p17).normalize();
const nrm=P[4].crossVectors(side,up).normalize();
side.crossVectors(up,nrm);
mTmp.makeBasis(side,up,nrm); out.setFromRotationMatrix(mTmp);
}
function classify(wl){
const d=(a,b)=>Math.hypot(wl[a].x-wl[b].x,wl[a].y-wl[b].y,wl[a].z-wl[b].z);
const curl=(tip,mcp)=>clamp((d(tip,0)/d(mcp,0)-1)/.85,0,1);
const ci=curl(8,5),cm=curl(12,9),cr=curl(16,13),cp=curl(20,17);
const open=(ci+cm+cr+cp)/4;
fistState=fistState?open<.38:open<.22;
let g='—';
if(fistState)g='FAUST';
else if(ci>.6&&cp>.55&&cm<.4&&cr<.4)g='METAL';
else if(ci>.6&&cm>.6&&cr<.4&&cp<.4)g='VICTORY';
else if(ci>.6&&cm<.4&&cr<.4&&cp<.4)g='ZEIGEN';
else if(open>.6)g='OFFEN';
return{g,open};
}
function onGesture(prev,g,t){
if(g==='FAUST'||prev==='FAUST'){
const nt=g==='FAUST'?0:1;
if(nt!==panelTarget){panelOld=panelTarget;panelTarget=nt;panelChangeT=t}
}
curlT=g==='FAUST'?1:0;
jitT=g==='METAL'?1:0;
windBoostT=g==='ZEIGEN'?3.5:(g==='METAL'?4.5:1);
if(g==='VICTORY'){
if(!standLatch)standPos.set(clamp(headPos.x,-HX*.6,HX*.6),2.2,clamp(headPos.z,-HZ*.5,HZ*.5));
standLatch=true;
}else if(prev==='VICTORY')standRel=t+.3;
}
let lastVT=-1;
function detect(t){
if(!hands||camError||video.readyState<2||video.currentTime===lastVT)return;
lastVT=video.currentTime;
const res=hands.detectForVideo(video,performance.now());
const lm=res.landmarks&&res.landmarks[0];
oc.clearRect(0,0,ov.width,ov.height);
const m=edgeZone; oc.strokeStyle='rgba(0,0,0,.12)'; oc.strokeRect(ov.width*m,ov.height*m,ov.width*(1-2*m),ov.height*(1-2*m));
if(!lm)return;
const wl=res.worldLandmarks[0];
for(let i=0;i<21;i++){handT[i*3]=-wl[i].x*HAND_S;handT[i*3+1]=-wl[i].y*HAND_S;handT[i*3+2]=-wl[i].z*HAND_S}
const hl=(res.handedness||res.handednesses);
const hd=(hl&&hl[0]&&hl[0][0]&&hl[0][0].categoryName)||'Right';
const dt=clamp(t-lastDetT,1/120,.2); lastDetT=t;
if(t-lastSeen>.5){fX.reset();fY.reset();fZ.reset();fS.reset();for(const k in neutral)delete neutral[k];reacq=true;eager=.1;handNew=true}
lastSeen=t; everTracked=true;
// Palm-Center + Bounding Box
let cx=0,cy=0; for(const i of [0,5,9,13,17]){cx+=lm[i].x;cy+=lm[i].y} cx/=5;cy/=5;
let minX=1,maxX=0,minY=1,maxY=0; for(const p of lm){minX=Math.min(minX,p.x);maxX=Math.max(maxX,p.x);minY=Math.min(minY,p.y);maxY=Math.max(maxY,p.y)}
// Tiefe: Bild-Länge / Welt-Länge (max über Palm-Segmente → robust gegen Verkürzung)
const asp=(video.videoWidth||4)/(video.videoHeight||3);
let sc=0;
for(const [a,b] of [[0,5],[0,9],[0,13],[0,17],[5,17],[5,9],[9,13]]){
const img=Math.hypot((lm[a].x-lm[b].x)*asp,lm[a].y-lm[b].y);
const w=Math.hypot(wl[a].x-wl[b].x,wl[a].y-wl[b].y,wl[a].z-wl[b].z)||1e-4;
sc=Math.max(sc,img/w);
}
const ls=fS.filter(Math.log(sc),dt); lastScale=Math.exp(ls);
if(refScale===null)refScale=lastScale;
zN=clamp(Math.log(lastScale/refScale)/Math.log(depthRange),-1.6,1.6);
const X=clamp((.5-cx)/.36,-1.2,1.2)*HX, Y=clamp((.5-cy)/.34,-1.2,1.2)*HY, Z=zN*HZ;
handTarget.set(fX.filter(X,dt),fY.filter(Y,dt),fZ.filter(Z,dt));
lastHandTarget.copy(handTarget);
// Rotation
handQuat(wl,qRaw);
if(hd!==lastHand||reacq){qHandS.copy(qRaw);lastHand=hd;reacq=false}
if(!neutral[hd])neutral[hd]=qRaw.clone();
if(qHandS.dot(qRaw)<0){qRaw.x*=-1;qRaw.y*=-1;qRaw.z*=-1;qRaw.w*=-1}
qHandS.slerp(qRaw,damp(lerp(14,3,handSm),dt));
// Gesten (3 Frames entprellt)
const c=classify(wl); openness=c.open;
if(c.g===candG)candN++;else{candG=c.g;candN=1}
if(candN>=3&&c.g!==gesture){onGesture(gesture,c.g,t);gesture=c.g}
// Kantennähe
let qx0=1,qx1=0,qy0=1,qy1=0; for(const i of [0,5,9,13,17]){qx0=Math.min(qx0,lm[i].x);qx1=Math.max(qx1,lm[i].x);qy0=Math.min(qy0,lm[i].y);qy1=Math.max(qy1,lm[i].y)}
const margin=Math.min(qx0,1-qx1,qy0,1-qy1);
const pXY=clamp(1-margin/edgeZone,0,1), pZ=Math.max(clamp((zN-1.15)/.35,0,1),clamp((-zN-.8)/.25,0,1));
prox=Math.max(pXY,pZ);
// Overlay
oc.strokeStyle='rgba(20,20,20,.7)'; oc.lineWidth=1.2; oc.beginPath();
for(const [a,b] of CONN){oc.moveTo(lm[a].x*ov.width,lm[a].y*ov.height);oc.lineTo(lm[b].x*ov.width,lm[b].y*ov.height)}
oc.stroke(); oc.fillStyle=prox>.01?'#e0442e':'#141414';
for(const p of lm){oc.fillRect(p.x*ov.width-1.5,p.y*ov.height-1.5,3,3)}
}
/* ---------- simulation ---------- */
const v1=new THREE.Vector3(),v2=new THREE.Vector3(),v3=new THREE.Vector3(),acc=new THREE.Vector3();
function windAt(p,t,out){
return out.set(
Math.sin(t*.7+p.y*.8)*.9+Math.sin(t*1.9+p.z*1.3)*.5+.4,
Math.sin(t*1.1+p.x*.9)*.35,
Math.cos(t*.9+p.x*.7)*.7
).multiplyScalar(windBoost);
}
function slerpV(out,a,b,s){
const th=Math.acos(clamp(a.dot(b),-1,1));
if(th<1e-3)return out.lerpVectors(a,b,s).normalize();
if(Math.PI-th<1e-3){
sA.set(a.y,-a.x,0); if(sA.lengthSq()<1e-4)sA.set(0,a.z,-a.y); sA.normalize();
return out.copy(a).multiplyScalar(Math.cos(s*Math.PI)).addScaledVector(sA,Math.sin(s*Math.PI));
}
const sn=Math.sin(th);
return out.copy(a).multiplyScalar(Math.sin((1-s)*th)/sn).addScaledVector(b,Math.sin(s*th)/sn);
}
function computeCoil(t){
// Spirale kreist um den Hand-Cursor; der Kopf läuft außen auf der Windung
const R0=1.25*(1+.04*Math.sin(t*1.3)), rmin=.38, PHI=2*totalLen/(R0+rmin), a=(R0-rmin)/PHI;
const phi0=-t*curlSpeed;
const cx=headPos.x+Math.sin(t*.9)*.25, cy=headPos.y+Math.cos(t*.7)*.2;
let phi=0;
for(let i=0;i<N;i++){
const rr=Math.max(rmin,R0-a*phi), ang=phi0+phi;
coilP[i].set(cx+rr*Math.cos(ang),cy+rr*Math.sin(ang),headPos.z-i*.1+rr*Math.cos(ang)*.3);
phi+=2*Math.asin(Math.min(.98,L/(2*Math.max(rr,.2))));
}
}
function updateChain(dt,t,speed){
// freie Kette (Follow-the-Leader, glatt)
wf[0].copy(headPos);
const straight=clamp(lerp(.5,.05,bodyFlex)*dt*60,0,.8), droop=.012*dt*60, sway=.012*dt*60;
for(let i=1;i<N;i++){
v2.subVectors(wf[i],wf[i-1]); const len=v2.length();
if(len<1e-5)v2.set(0,0,1); else v2.divideScalar(len);
if(i>=2){v3.subVectors(wf[i-1],wf[i-2]).normalize(); v2.addScaledVector(v3,straight)}
const f=i/N;
v2.y-=droop*f; v2.x+=Math.sin(t*.8-i*.55)*sway*f;
v2.normalize();
wf[i].copy(wf[i-1]).addScaledVector(v2,L);
}
if(!Number.isFinite(wf[N-1].x+wf[N-1].y+wf[N-1].z)){
for(let i=0;i<N;i++)wf[i].copy(headPos).add(v1.set(0,0,i*L));
}
// Anzeige-Kette: Posen einmischen (Stehen, Kringeln) + Zittern
wp[0].copy(headPos);
if(curlS>.001){computeCoil(t);wp[0].lerp(coilP[0],curlS)}
if(jitS>.001){wp[0].x+=Math.sin(t*71)*.07*jitS;wp[0].y+=Math.sin(t*83)*.07*jitS;wp[0].z+=Math.sin(t*59)*.07*jitS}
for(let i=1;i<N;i++){
dF.subVectors(wf[i],wf[i-1]); const l=dF.length(); if(l<1e-5)dF.set(0,0,1); else dF.divideScalar(l);
if(standS>.001){
dC.set(Math.sin(t*.7+i*.4)*.12,-1,Math.cos(t*.6+i*.33)*.09).normalize();
slerpV(dP,dF,dC,standS); dF.copy(dP);
}
if(curlS>.001){
dC.subVectors(coilP[i],coilP[i-1]).normalize();
slerpV(dP,dF,dC,curlS); dF.copy(dP);
}
if(jitS>.001){
dF.x+=Math.sin(t*67+i*1.9)*.25*jitS; dF.y+=Math.sin(t*79+i*2.7)*.25*jitS; dF.z+=Math.sin(t*61+i*3.1)*.25*jitS;
dF.normalize();
}
wp[i].copy(wp[i-1]).addScaledVector(dF,L);
}
if(!Number.isFinite(wp[N-1].x+wp[N-1].y+wp[N-1].z)){for(let i=0;i<N;i++)wp[i].copy(wf[i])}
}
const qd=new THREE.Quaternion(), qPrev=new THREE.Quaternion(), fPrev=new THREE.Vector3(), FWD=new THREE.Vector3(0,0,-1);
function updateBones(t){
bones[0].position.copy(wp[0]); bones[0].quaternion.copy(qHead);
qPrev.copy(qHead); fPrev.copy(FWD).applyQuaternion(qHead);
for(let i=1;i<N;i++){
v1.subVectors(wp[i-1],wp[i]).normalize();
qd.setFromUnitVectors(fPrev,v1);
bones[i].quaternion.multiplyQuaternions(qd,qPrev);
bones[i].position.copy(wp[i]);
qPrev.copy(bones[i].quaternion); fPrev.copy(v1);
}
for(let i=0;i<N;i++){const b=1+bulge(i);bones[i].scale.set(b,b,1)}
body.updateMatrixWorld(true);
}
function bulge(i){let s=0;for(const p of pulses)s+=Math.exp(-(i-p)*(i-p)/1.2);return s*.32}
const dummy=new THREE.Object3D();
const FIXED=1/60; let tAcc=0;
function updateTentacles(dt,t){
// feste Zeitschritte → träge, ruckelfreie Verlet-Simulation
tAcc=Math.min(tAcc+dt,FIXED*4);
const nSteps=Math.floor(tAcc/FIXED); tAcc-=nSteps*FIXED;
for(const T of tentacles){
T.aOld.copy(T.aNow);
T.aNow.copy(T.local).applyMatrix4(bones[T.bone].matrixWorld);
if(!T.init){
for(let j=0;j<=T.n;j++){const p=T.aNow.clone();p.y-=j*T.seg;T.pts.push(p);T.prev.push(p.clone())}
T.aOld.copy(T.aNow); T.init=true;
}
}
for(let s=1;s<=nSteps;s++){
const f=s/nSteps, tt=t-(nSteps-s)*FIXED;
for(const T of tentacles){
const pts=T.pts, prev=T.prev, n=T.n, mx=T.seg*.8;
pts[0].lerpVectors(T.aOld,T.aNow,f); prev[0].copy(pts[0]);
for(let j=1;j<=n;j++){
const p=pts[j], q=prev[j];
v2.subVectors(p,q).multiplyScalar(.975);
const sp=v2.length(); if(sp>mx)v2.multiplyScalar(mx/sp);
q.copy(p); p.add(v2);
windAt(p,tt+j*.15,acc).multiplyScalar(.9); acc.y-=2.6;
p.addScaledVector(acc,FIXED*FIXED);
}
for(let j=2;j<=n;j++){ // leichte Biegesteifigkeit
v3.subVectors(pts[j-1],pts[j-2]).normalize().multiplyScalar(T.seg).add(pts[j-1]);
pts[j].lerp(v3,.12);
}
for(let it=0;it<6;it++)for(let j=1;j<=n;j++){
v2.subVectors(pts[j],pts[j-1]); const len=v2.length()||1e-5, diff=(len-T.seg)/len;
if(j===1)pts[j].addScaledVector(v2,-diff);
else{pts[j].addScaledVector(v2,-.5*diff);pts[j-1].addScaledVector(v2,.5*diff)}
}
}
}
let idx=0;
for(const T of tentacles){
for(let j=0;j<T.n;j++){
const a=T.pts[j],b=T.pts[j+1];
v3.subVectors(b,a); const len=v3.length()||1e-5; v3.divideScalar(len);
const rr=T.r0*(1-j/T.n*.85);
dummy.position.copy(a); dummy.quaternion.setFromUnitVectors(UP,v3); dummy.scale.set(rr,len*1.08,rr);
dummy.updateMatrix(); tMesh.setMatrixAt(idx++,dummy.matrix);
}
}
tMesh.instanceMatrix.needsUpdate=true;
}
function updatePanels(dt,t){
for(const p of panels){
const tgt=(t-panelChangeT>=p.delay)?panelTarget:panelOld;
const a=95*(tgt-p.s)-11*p.v; p.v+=a*dt; p.s+=p.v*dt;
const flutter=Math.sin(t*9+p.phase)*.05*windBoost*Math.max(0,p.s);
p.hinge.rotation.y=p.closedA+(p.openA-p.closedA)*p.s+flutter+bulge(p.bone)*1.4+jitS*Math.sin(t*61+p.phase*9)*.45;
}
}
/* ---------- loop ---------- */
const clock=new THREE.Clock(); let hudT=0;
const cGrey=new THREE.Color(0xd2d2d2), cRed=new THREE.Color(0xe0442e);
const dotEl=$('dot'),stEl=$('statusTxt'),gEl=$('gesture'),oBar=$('openBar').firstElementChild,eBar=$('edgeBar').firstElementChild;
function frame(){
requestAnimationFrame(frame);
const dt=clamp(clock.getDelta(),1/240,1/30); const t=clock.elapsedTime;
detect(t);
tracking=!camError&&(t-lastSeen<.18);
if(!tracking){prox=0;if(t-lastSeen>.3&&gesture!=='—'){onGesture(gesture,'—',t);gesture='—'}}
curlS=lerp(curlS,curlT,damp(1.8,dt)); jitS=lerp(jitS,jitT,damp(9,dt));
if(standLatch&&gesture!=='VICTORY'&&t>standRel){standLatch=false;eager=Math.min(eager,.3)}
standS=lerp(standS,standLatch?1:0,damp(1.6,dt));
handLP.lerp(handTarget,damp(lerp(25,2.5,handSm),dt));
eager=Math.min(1,eager+dt*.5*(.3+eager));
// Zielposition
wanderAmp=lerp(wanderAmp,tracking?.15:(everTracked||mouseTarget?.45:2.6),damp(1.5,dt));
wander.set(Math.sin(t*.43)*wanderAmp*1.6,Math.sin(t*.61+1.3)*wanderAmp*.7,Math.sin(t*.29+2.1)*wanderAmp*.9);
if(tracking)target.copy(handLP);
else if(mouseTarget)target.copy(mouseTarget);
else if(everTracked)target.copy(handLP);
else target.set(0,0,0);
target.add(wander);
if(standLatch){target.copy(standPos);target.x+=Math.sin(t*.5)*.25;target.y+=Math.sin(t*.9)*.12;target.z+=Math.cos(t*.4)*.2}
marker.position.copy(target); marker.visible=tracking;
handGroup.visible=tracking; handGroup.position.copy(standLatch?handLP:target); if(standLatch)marker.position.copy(handLP);
if(tracking){const k=damp(30,dt),ar=handPosAttr.array;for(let q=0;q<63;q++)ar[q]=handNew?handT[q]:ar[q]+(handT[q]-ar[q])*k;handNew=false;handPosAttr.needsUpdate=true}
// kritisch gedämpfte Feder → weiches Beschleunigen/Abbremsen hinter dem Hand-Cursor
const om=followK*(.25+.75*eager);
acc.subVectors(target,headPos).multiplyScalar(om*om).addScaledVector(headVel,-2*om);
headVel.addScaledVector(acc,dt); headPos.addScaledVector(headVel,dt);
if(!Number.isFinite(headPos.x+headPos.y+headPos.z+headVel.x+headVel.y+headVel.z)){headPos.copy(target);headVel.set(0,0,0)}
// Richtung
velS.lerp(headVel,damp(Math.max(6,turnK),dt));
const speed=velS.length();
if(speed>.35){dirT.copy(velS).normalize();dirT.y=clamp(dirT.y,-.9,.9);dirT.normalize()}
const poseS=Math.max(curlS,standS);
if(poseS>.01){v1.subVectors(wp[0],wp[1]); if(v1.lengthSq()>1e-6){v1.normalize();dirT.lerp(v1,poseS).normalize()}}
dirS.lerp(dirT,damp(turnK,dt)).normalize();
const sUp=clamp((Math.abs(dirS.y)-.55)/.35,0,1);
upRef.set(0,1-sUp,sUp).normalize();
mTmp.lookAt(ORIGIN,dirS,upRef); qDir.setFromRotationMatrix(mTmp);
const yaw=Math.atan2(dirS.x,-dirS.z); let dy=yaw-lastYaw; dy=Math.atan2(Math.sin(dy),Math.cos(dy)); lastYaw=yaw;
yawRate=lerp(yawRate,dy/dt,damp(4,dt));
bank=lerp(bank,clamp(yawRate*.22,-.8,.8)*(1-sUp),damp(3,dt));
qBank.setFromAxisAngle(ZAX,bank);
// Hand-Rotation als Offset
if(tracking&&neutral[lastHand]){
qRel.copy(qHandS).multiply(qTmp.copy(neutral[lastHand]).invert());
if(qRel.w<0){qRel.x*=-1;qRel.y*=-1;qRel.z*=-1;qRel.w*=-1}
const ang=2*Math.acos(clamp(qRel.w,-1,1)), maxA=1.6;
if(ang>maxA){qTmp.copy(qRel);qRel.slerpQuaternions(qI,qTmp,maxA/ang)}
qOffT.slerpQuaternions(qI,qRel,offStrength);
}else qOffT.identity();
qOff.slerp(qOffT,damp(tracking?6:2,dt));
qHead.copy(qDir).multiply(qBank).multiply(qOff);
if(jitS>.001){qJit.setFromEuler(eJit.set(Math.sin(t*79)*.3*jitS,Math.sin(t*67)*.3*jitS,Math.sin(t*91)*.3*jitS));qHead.multiply(qJit)}
// Effekte
windBoost=lerp(windBoost,windBoostT,damp(3,dt));
if(standS>.8&&t-lastPulseT>2.6){pulses.push(-1);lastPulseT=t}
for(let i=pulses.length-1;i>=0;i--){pulses[i]+=dt*15;if(pulses[i]>N+3)pulses.splice(i,1)}
updateChain(dt,t,speed);
updateBones(t);
updatePanels(dt,t);
updateTentacles(dt,t);
boxMat.color.lerpColors(cGrey,cRed,prox); boxMat.opacity=.8;
updateAudio(t);
if(t-hudT>.1){
hudT=t;
const lost=started&&everTracked&&!tracking&&!camError;
dotEl.style.background=tracking?'#141414':(lost?'#e0442e':'#ccc');
if(!camError)stEl.textContent=tracking?'Tracking':(lost?'Tracking verloren':(started?'Hand zeigen…':'bereit'));
gEl.textContent=tracking?gesture:'—';
mEl.textContent=standLatch?'steht (Victory halten)':(curlS>.5?'kringelt':(jitS>.5?'zittert':'folgt'));
oBar.style.width=(tracking?openness*100:0)+'%';
eBar.style.width=(prox*100)+'%';
}
renderer.render(scene,camera);
}
frame();
</script>
</body>
</html>