BODY_HORROR_PLAYGROUND_v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/e57b2d8b037dbe7d/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SKIN ORB</title>
<style>
:root{--line:#e8e8e8;--mute:#8a8a8a}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:#111;font:11px/1.4 ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;letter-spacing:.02em}
#app{display:flex;height:100vh}
#stage{flex:1;position:relative;min-width:0;background:#000;overflow:hidden}
#stage canvas{position:absolute;left:0;top:0;display:block;cursor:grab;touch-action:none}
#hint{position:absolute;left:0;right:0;bottom:14px;text-align:center;color:#5c5c5c;font-size:10px;letter-spacing:.06em;pointer-events:none}
#panel{width:290px;border-left:1px solid var(--line);overflow-y:auto;padding:20px 18px 40px;background:#fff}
h1{font-size:12px;font-weight:600;letter-spacing:.16em;margin:0 0 3px}
.sub{color:var(--mute);margin-bottom:6px}
h2{font-size:10px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin:16px 0 6px;padding-top:12px;border-top:1px solid var(--line)}
.row{display:grid;grid-template-columns:92px 1fr 38px;align-items:center;gap:8px;margin:4px 0}
.row label{color:#333}
.row .v{text-align:right;color:var(--mute);font-variant-numeric:tabular-nums}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:16px;background:transparent;margin:0}
input[type=range]::-webkit-slider-runnable-track{height:1px;background:#cfcfcf}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:#111;margin-top:-4px}
input[type=range]::-moz-range-track{height:1px;background:#cfcfcf}
input[type=range]::-moz-range-thumb{width:9px;height:9px;border:0;border-radius:50%;background:#111}
input[type=color]{width:100%;height:18px;border:1px solid var(--line);padding:0;background:#fff}
input[type=checkbox]{justify-self:start;margin:0;accent-color:#111}
select{width:100%;font:inherit;border:1px solid var(--line);background:#fff;padding:2px}
button{font:inherit;border:1px solid #111;background:#fff;color:#111;padding:5px 10px;cursor:pointer;margin:14px 6px 0 0}
button:hover{background:#111;color:#fff}
@media (max-width:760px){#app{flex-direction:column;height:auto}#stage{flex:none;height:72vh}#panel{width:100%;border-left:0;border-top:1px solid var(--line)}}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="app">
  <div id="stage"><div id="hint">Linke Maus: Haut ziehen → Glühen · Rechte Maus: Kamera · Rad: Zoom</div></div>
  <aside id="panel">
    <h1>SKIN ORB</h1>
    <div class="sub">Schwerelose Hautkugel · Subsurface-Glühen</div>
    <div class="sub" id="fps">–</div>
    <div id="controls"></div>
    <div><button id="reset">Zurücksetzen</button><button id="reroll">Neu würfeln</button></div>
  </aside>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

// ------------------------------------------------------------------ Parameter
const P = {
  animate:true, field:'perlin', speed:0.1, scale:1.4, slack:0.03, amp:0.08,
  breath:0.6, breathRate:0.06, deflate:0.7, inflate:1.08, pressure:1.0, drift:0.15, bend:0.9, round:0.05, selfCol:true,
  amorph:0.1, shapeFreq:1.3, morph:0.04, shapeK:40, slide:0.06,
  sym:'mirror', symLock:0.5, symGrab:true, bulges:3, walls:2, ears:1, noses:1, featSize:1.0, flex:0.08, sway:0.6, shapeSmooth:5,
  foldAmp:0.16, foldFreq:6, foldSpeed:0.05, foldWarp:0.8, foldPush:1.2, overlap:0.12,
  edit:false, brush:'draw', brushR:0.28, brushStr:0.6,
  plastic:2.5, plasticDecay:12, stretch:0.5,
  spin:0.02, float:1.0,
  grabR:0.3, lift:0.2,
  glow:'#ff6a2a', glowDeep:'#b0140a', glowInt:0.7, charge:0.8, glowMax:3, decay:4, spread:0.3,
  pulseRate:0.5, pulseDepth:0.5, pulseWave:1.0, motionGlow:0.05, baseGlow:0.02, thin:0.8,
  bloom:0.35, bloomRadius:0.4, bloomThr:1.0, exposure:0.8,
  skin:'#c98d72', deep:'#9c2a1c', scatter:0.85, trans:0.45, rough:0.5, spec:0.6, oil:0.4,
  pore:0.3, poreScale:170, fine:0.8, fineShade:0.5, moles:0.5, freckles:0.3, blotch:0.55, fineFreq:240, fineIrr:0.85, compGain:3.0, cavity:0.55, nSmooth:2,
  hairCount:14000, hairLen:0.034, hairTilt:0.9, hairColor:'#2a1a10', hairAlpha:0.4, cluster:0.7, clusterSize:2.6,
  keyAz:55, keyEl:28, keyInt:1.0, keyCol:'#ffe6d2',
  rimAz:-155, rimEl:18, rimInt:1.2, rimCol:'#c9dcff',
  ambient:0.02, shadows:true, shadowSoft:5
};

// ------------------------------------------------------------------ Simplex 3D
const grad3 = new Float32Array([1,1,0,-1,1,0,1,-1,0,-1,-1,0,1,0,1,-1,0,1,1,0,-1,-1,0,-1,0,1,1,0,-1,1,0,1,-1,0,-1,-1]);
const perm = new Uint8Array(512);
(function(){ const p=new Uint8Array(256); for(let i=0;i<256;i++)p[i]=i; let s=1337;
  for(let i=255;i>0;i--){ s=(s*16807)%2147483647; const j=s%(i+1); const t=p[i]; p[i]=p[j]; p[j]=t; }
  for(let i=0;i<512;i++) perm[i]=p[i&255]; })();
function noise3(x,y,z){
  const F3=1/3, G3=1/6;
  const s=(x+y+z)*F3; const i=Math.floor(x+s), j=Math.floor(y+s), k=Math.floor(z+s);
  const t=(i+j+k)*G3; const x0=x-(i-t), y0=y-(j-t), z0=z-(k-t);
  let i1,j1,k1,i2,j2,k2;
  if(x0>=y0){ if(y0>=z0){i1=1;j1=0;k1=0;i2=1;j2=1;k2=0;} else if(x0>=z0){i1=1;j1=0;k1=0;i2=1;j2=0;k2=1;} else {i1=0;j1=0;k1=1;i2=1;j2=0;k2=1;} }
  else { if(y0<z0){i1=0;j1=0;k1=1;i2=0;j2=1;k2=1;} else if(x0<z0){i1=0;j1=1;k1=0;i2=0;j2=1;k2=1;} else {i1=0;j1=1;k1=0;i2=1;j2=1;k2=0;} }
  const x1=x0-i1+G3, y1=y0-j1+G3, z1=z0-k1+G3;
  const x2=x0-i2+2*G3, y2=y0-j2+2*G3, z2=z0-k2+2*G3;
  const x3=x0-1+3*G3, y3=y0-1+3*G3, z3=z0-1+3*G3;
  const ii=i&255, jj=j&255, kk=k&255;
  let n=0, tt, g;
  tt=0.6-x0*x0-y0*y0-z0*z0; if(tt>0){ g=(perm[ii+perm[jj+perm[kk]]]%12)*3; tt*=tt; n+=tt*tt*(grad3[g]*x0+grad3[g+1]*y0+grad3[g+2]*z0); }
  tt=0.6-x1*x1-y1*y1-z1*z1; if(tt>0){ g=(perm[ii+i1+perm[jj+j1+perm[kk+k1]]]%12)*3; tt*=tt; n+=tt*tt*(grad3[g]*x1+grad3[g+1]*y1+grad3[g+2]*z1); }
  tt=0.6-x2*x2-y2*y2-z2*z2; if(tt>0){ g=(perm[ii+i2+perm[jj+j2+perm[kk+k2]]]%12)*3; tt*=tt; n+=tt*tt*(grad3[g]*x2+grad3[g+1]*y2+grad3[g+2]*z2); }
  tt=0.6-x3*x3-y3*y3-z3*z3; if(tt>0){ g=(perm[ii+1+perm[jj+1+perm[kk+1]]]%12)*3; tt*=tt; n+=tt*tt*(grad3[g]*x3+grad3[g+1]*y3+grad3[g+2]*z3); }
  return 32*n;
}
function smooth(a,b,x){ const t=Math.min(1,Math.max(0,(x-a)/(b-a))); return t*t*(3-2*t); }

// ------------------------------------------------------------------ Kugel-Mesh (geschlossene Membran)
// DET 26 ≈ 7 300 Partikel (vorher 40 ≈ 16 800)
const R=0.75, DET=26;
let src=new THREE.IcosahedronGeometry(R,DET);
src.deleteAttribute('normal'); src.deleteAttribute('uv');
src=mergeVertices(src,1e-5);
const restP=Float32Array.from(src.attributes.position.array);
const CNT=restP.length/3;
const TRI=Uint32Array.from(src.index.array);
const TN=TRI.length/3;
src.dispose();

function volumeOf(A){
  let v=0;
  for(let t=0;t<TN;t++){
    const a=TRI[t*3]*3, b=TRI[t*3+1]*3, c=TRI[t*3+2]*3;
    v+=A[a]*(A[b+1]*A[c+2]-A[b+2]*A[c+1]) + A[a+1]*(A[b+2]*A[c]-A[b]*A[c+2]) + A[a+2]*(A[b]*A[c+1]-A[b+1]*A[c]);
  }
  return v/6;
}
if(volumeOf(restP)<0){ for(let t=0;t<TN;t++){ const s=TRI[t*3+1]; TRI[t*3+1]=TRI[t*3+2]; TRI[t*3+2]=s; } }
const V0=volumeOf(restP);

// Kanten (Struktur) + Biegung (gegenüberliegende Ecken zweier Dreiecke)
const eMap=new Map(), eA=[], eB=[], eOpp=[], bA=[], bB=[];
for(let t=0;t<TN;t++) for(let e=0;e<3;e++){
  const a=TRI[t*3+e], b=TRI[t*3+(e+1)%3], c=TRI[t*3+(e+2)%3];
  const lo=Math.min(a,b), hi=Math.max(a,b), key=lo*CNT+hi;
  const id=eMap.get(key);
  if(id===undefined){ eMap.set(key,eA.length); eA.push(lo); eB.push(hi); eOpp.push(c); }
  else { bA.push(eOpp[id]); bB.push(c); }
}
const NE=eA.length, CC=NE+bA.length;
const CA=new Int32Array(CC), CB=new Int32Array(CC), CL=new Float32Array(CC), CT=new Uint8Array(CC);
let EL=0;
for(let c=0;c<CC;c++){
  const a=c<NE?eA[c]:bA[c-NE], b=c<NE?eB[c]:bB[c-NE];
  CA[c]=a; CB[c]=b; CT[c]=c<NE?0:2;
  CL[c]=Math.hypot(restP[b*3]-restP[a*3],restP[b*3+1]-restP[a*3+1],restP[b*3+2]-restP[a*3+2]);
  if(c<NE) EL+=CL[c];
}
EL/=NE;
// Nachbarschaft (CSR)
const deg=new Int32Array(CNT);
for(let e=0;e<NE;e++){ deg[eA[e]]++; deg[eB[e]]++; }
const adjS=new Int32Array(CNT+1);
for(let k=0;k<CNT;k++) adjS[k+1]=adjS[k]+deg[k];
const adj=new Int32Array(NE*2), adjC=adjS.slice(0,CNT);
for(let e=0;e<NE;e++){ adj[adjC[eA[e]]++]=eB[e]; adj[adjC[eB[e]]++]=eA[e]; }

// Zielform: Richtung U je Partikel, Zielradius rT (in R), plastischer Versatz, Ziel-Kantenlängen TL
const U=new Float32Array(CNT*3); for(let i=0;i<CNT*3;i++) U[i]=restP[i]/R;
const TL=Float32Array.from(CL);
const rT=new Float32Array(CNT).fill(1), plast=new Float32Array(CNT), plTmp=new Float32Array(CNT);
const sculpt=new Float32Array(CNT), rTmp=new Float32Array(CNT), foldB=new Float32Array(CNT);   // Sculpt-Grundform (in R), Faltenband
// Spiegelpartner (x → -x); die Icosphere ist an der YZ-Ebene symmetrisch
const mirIdx=new Int32Array(CNT).fill(-1);
{
  const key=(x,y,z)=>Math.round(x*1e4)+','+Math.round(y*1e4)+','+Math.round(z*1e4);
  const m=new Map();
  for(let k=0;k<CNT;k++) m.set(key(restP[k*3],restP[k*3+1],restP[k*3+2]),k);
  for(let k=0;k<CNT;k++){
    const o=k*3, q=m.get(key(-restP[o],restP[o+1],restP[o+2]));
    if(q!==undefined){ mirIdx[k]=q; continue; }
    let best=1e9, bi=-1;
    for(let j=0;j<CNT;j++){ const p=j*3, dx=restP[p]+restP[o], dy=restP[p+1]-restP[o+1], dz=restP[p+2]-restP[o+2], d=dx*dx+dy*dy+dz*dz; if(d<best){best=d; bi=j;} }
    mirIdx[k]=bi;
  }
}

// ------------------------------------------------------------------ Simulationszustand
const pos=new Float32Array(CNT*3), prv=new Float32Array(CNT*3), nrm=new Float32Array(CNT*3), rnrm=new Float32Array(CNT*3), rnTmp=new Float32Array(CNT*3);
const inv=new Float32Array(CNT).fill(1);
const shapeN=new Float32Array(CNT).fill(1), vSpd=new Float32Array(CNT);
const grow=new Float32Array(CNT), drX=new Float32Array(CNT), drY=new Float32Array(CNT), drZ=new Float32Array(CNT);
const curv=new Float32Array(CNT), curvT=new Float32Array(CNT), comp4=new Float32Array(CNT*4);
// zeitlich geglättete Varianten fürs Rendering (gegen Flackern)
const curvA=new Float32Array(CNT), compA=new Float32Array(CNT*4);
const E=new Float32Array(CNT), Etmp=new Float32Array(CNT), grabW=new Float32Array(CNT);
let seedOff=0, targetVol=V0, SC=1;

function resetSheet(){
  for(let k=0;k<CNT;k++){
    const o=k*3;
    for(let a=0;a<3;a++) pos[o+a]=restP[o+a]*0.97+(Math.random()-0.5)*0.003;
  }
  prv.set(pos); inv.fill(1); grabW.fill(0); E.fill(0); plast.fill(0);
}

// Voronoi-Saaten auf der Kugel
const VMAX=100; const vSeeds=new Float32Array(VMAX*3), curS=new Float32Array(VMAX*3), pulseV=new Float32Array(VMAX);
let vK=0, vScale=-1, vSpacing=1;
function makeSeeds(){
  vK=Math.min(VMAX,Math.round(6+14*P.scale*P.scale));
  let s=Math.floor(seedOff*1000)+7; const rnd=()=>{ s=(s*16807)%2147483647; return s/2147483647; };
  for(let q=0;q<vK;q++){
    const z=rnd()*2-1, ph=rnd()*Math.PI*2, r=Math.sqrt(1-z*z);
    vSeeds[q*3]=r*Math.cos(ph)*R; vSeeds[q*3+1]=z*R; vSeeds[q*3+2]=r*Math.sin(ph)*R;
  }
  vSpacing=R*Math.sqrt(4*Math.PI/vK); vScale=P.scale;
}

// ------------------------------------------------------------------ Symmetrie (vertikale Achse Y)
const SYM={none:[1,false],mirror:[1,true],d2:[2,true],d3:[3,true],d4:[4,true],d5:[5,true],d6:[6,true]};
const FQ=new Float32Array(3); let FA=0, FM=false;
// faltet eine Richtung in den Fundamentalbereich → alle Felder werden automatisch symmetrisch
// FA/FM: Rücktransformation (symTr) aus dem Fundamentalbereich an die Originalstelle
function fold(x,y,z){
  const [n,m]=SYM[P.sym]||SYM.none;
  FA=0; FM=false;
  if(n===1&&!m){ FQ[0]=x; FQ[1]=y; FQ[2]=z; return FQ; }
  const w=Math.PI*2/n, r=Math.sqrt(x*x+z*z);
  let ph=Math.atan2(x,z); if(ph<0) ph+=Math.PI*2;
  let k=Math.floor(ph/w); if(k>=n) k=n-1;
  ph-=k*w;
  if(m&&ph>w/2){ ph=w-ph; FM=true; FA=(k+1)*w; } else FA=k*w;
  FQ[0]=r*Math.sin(ph); FQ[1]=y; FQ[2]=r*Math.cos(ph); return FQ;
}
function symImages(){
  const [n,m]=SYM[P.sym]||SYM.none, out=[];
  for(let i=0;i<n;i++){ out.push([i*Math.PI*2/n,false]); if(m) out.push([i*Math.PI*2/n,true]); }
  return out;
}
function symTr(x,y,z,a,m,o){ if(m) x=-x; const c=Math.cos(a), s=Math.sin(a); o[0]=x*c+z*s; o[1]=y; o[2]=-x*s+z*c; return o; }

// ------------------------------------------------------------------ Auswüchse: Wölbungen, Wände, Ohr- und Nasenknorpel
let FEAT=[], featKey='';
const featSig=()=>[P.sym,Math.round(P.bulges),Math.round(P.walls),Math.round(P.ears),Math.round(P.noses),seedOff].join('|');
function frameOf(cx,cy,cz){
  let ex=cz, ez=-cx, l=Math.hypot(ex,ez); if(l<1e-4){ ex=1; ez=0; l=1; } ex/=l; ez/=l;
  return [ex,0,ez, cy*ez, cz*ex-cx*ez, -cy*ex];   // e1 horizontal, e2 = c × e1 (nach oben)
}
function makeFeatures(){
  const [n,m]=SYM[P.sym]||SYM.none, w=Math.PI*2/n, span=m?w/2:w;
  let s=Math.floor(seedOff*7919)+101; const rnd=()=>{ s=(s*16807)%2147483647; return s/2147483647; };
  const dir=(ph,y)=>{ const r=Math.sqrt(1-y*y); return [r*Math.sin(ph),y,r*Math.cos(ph)]; };
  FEAT=[];
  // Wölbungen: breite, weiche Kuppeln
  for(let i=0;i<Math.round(P.bulges);i++){
    FEAT.push({t:0, c:dir(span*(0.1+0.8*rnd()), rnd()*1.6-0.8), w:0.3+0.25*rnd(), H:0.12+0.14*rnd(), ph:rnd()*6.28, rate:0.5+0.6*rnd()});
  }
  // Wände: knorpelige Lamellen mit gerolltem Rand; bei Spiegelung liegt die erste als Kamm auf der Symmetrieebene
  for(let i=0;i<Math.round(P.walls);i++){
    const onPlane=m&&i===0;
    FEAT.push({t:3, c:dir(onPlane?0:span*(0.2+0.6*rnd()), rnd()*1.0-0.5), al:onPlane?Math.PI/2:rnd()*Math.PI,
      Lw:0.55+0.5*rnd(), H:0.12+0.09*rnd(), wd:0.085+0.04*rnd(), lip:rnd()<0.5?1:-1, ph:rnd()*6.28, rate:0.4+0.5*rnd()});
  }
  for(let i=0;i<Math.round(P.ears);i++){
    FEAT.push({t:1, c:dir(span*(0.35+0.45*rnd()), rnd()*0.7-0.25), rho:0.22+0.08*rnd(), b0:-0.7+rnd()*0.4, span:3.9+rnd()*1.1,
      H:0.15+0.08*rnd(), wd:0.1+0.03*rnd(), ph:rnd()*6.28, rate:0.4+0.4*rnd()});
  }
  for(let i=0;i<Math.round(P.noses);i++){
    const ph0=m?(i%2?w/2:0):span*rnd();
    FEAT.push({t:2, c:dir(ph0, rnd()*0.4-0.15), Lb:0.3+0.12*rnd(), Hb:0.1+0.05*rnd(), Ht:0.16+0.08*rnd(), ph:rnd()*6.28, rate:0.3+0.3*rnd()});
  }
  for(const f of FEAT) f.F=frameOf(f.c[0],f.c[1],f.c[2]);
  featKey=featSig();
}
function prepFeatures(t){
  const sw=P.sway;
  for(const f of FEAT){
    f.A=0.65+0.35*Math.sin(t*f.rate+f.ph);             // wachsen / zurückziehen
    const F=f.F, sx=sw*0.05*Math.sin(t*f.rate*0.7+f.ph*1.3), sy=sw*0.05*Math.sin(t*f.rate*0.53+f.ph*0.7);
    let cx=f.c[0]+F[0]*sx+F[3]*sy, cy=f.c[1]+F[1]*sx+F[4]*sy, cz=f.c[2]+F[2]*sx+F[5]*sy;
    const cl=Math.hypot(cx,cy,cz); f.cx=cx/cl; f.cy=cy/cl; f.cz=cz/cl;   // leichtes Pendeln der Achse
    if(f.t===3){
      // Wandrichtung t im Tangentialraum, Großkreis-Normale nW = c × t
      const ca=Math.cos(f.al), sa=Math.sin(f.al);
      let tx=F[0]*ca+F[3]*sa, ty=F[1]*ca+F[4]*sa, tz=F[2]*ca+F[5]*sa;
      const dd=tx*f.cx+ty*f.cy+tz*f.cz; tx-=f.cx*dd; ty-=f.cy*dd; tz-=f.cz*dd;
      const tl=Math.hypot(tx,ty,tz)||1; f.tx=tx/tl; f.ty=ty/tl; f.tz=tz/tl;
      f.nx=f.cy*f.tz-f.cz*f.ty; f.ny=f.cz*f.tx-f.cx*f.tz; f.nz=f.cx*f.ty-f.cy*f.tx;
    }
  }
}
const gs=(a,b)=>Math.exp(-(a*a)/(b*b));
function featureH(qx,qy,qz){
  let h=0; const sz=P.featSize;
  for(const f of FEAT){
    const A=f.A, F=f.F;
    const d=qx*f.cx+qy*f.cy+qz*f.cz; if(d<0.3) continue;
    const u=qx*F[0]+qy*F[1]+qz*F[2], v=qx*F[3]+qy*F[4]+qz*F[5];
    if(f.t===0){
      // Wölbung: (1-t²)² – weiche Kuppel ohne Spitze
      const th=Math.acos(Math.min(1,d)), tt=th/(f.w*sz);
      if(tt<1){ const p=1-tt*tt; h+=f.H*sz*A*p*p; }
    } else if(f.t===3){
      // Wand: Grat entlang eines Großkreisbogens + gerollter Rand zur Seite
      const Lw=f.Lw*sz, sAl=Math.atan2(qx*f.tx+qy*f.ty+qz*f.tz, d);
      if(Math.abs(sAl)<Lw*0.5){
        const sd=Math.asin(Math.max(-1,Math.min(1,qx*f.nx+qy*f.ny+qz*f.nz)))*f.lip, wd=f.wd*sz;
        const env=Math.pow(Math.cos(Math.PI*sAl/Lw),0.7);
        h+=f.H*sz*A*env*(gs(sd,wd)+0.35*gs(sd-1.9*wd,0.75*wd));
      }
    } else if(f.t===1){
      // Ohr: C-förmiger Helix-Rand, Antihelix, flache Muschel
      const th=Math.acos(Math.min(1,d)), rho=f.rho*sz;
      if(th<rho*1.6){
        let be=Math.atan2(v,u)-f.b0; be=((be%6.2832)+6.2832)%6.2832;
        const tp=be<f.span?Math.pow(Math.sin(Math.PI*be/f.span),0.6):0;
        const wd=f.wd*sz;
        h+=f.H*sz*A*tp*(gs(th-rho,wd)*(0.75+0.25*Math.sin(be*1.3)) + 0.45*gs(th-rho*0.6,wd*0.8)) - f.H*sz*A*0.08*Math.max(0,1-th/rho);
      }
    } else {
      // Nasenknorpel: Rücken, Spitze, Flügel, Nasenlöcher
      const L=f.Lb*sz, Hb=f.Hb*sz*A, Ht=f.Ht*sz*A, wB=0.065*sz, au=Math.abs(u);
      const dv=v<0?v:(v>L?v-L:0);
      h+=Hb*(1-0.5*Math.min(1,Math.max(0,v/L)))*gs(u,wB)*gs(dv,wB);
      h+=Ht*gs(u,0.08*sz)*gs(v+0.01*sz,0.08*sz);
      h+=0.55*Ht*gs(au-0.09*sz,0.06*sz)*gs(v+0.05*sz,0.06*sz);
      h-=0.3*Ht*gs(au-0.05*sz,0.03*sz)*gs(v+0.09*sz,0.03*sz);
    }
  }
  return h;
}

function updateGrowth(t){
  if(featSig()!==featKey) makeFeatures();
  const f=P.scale*1.2, tt=t*P.speed+seedOff, isV=P.field==='voronoi';
  if(isV){
    if(vScale!==P.scale) makeSeeds();
    const m=0.4*R/P.scale;
    for(let q=0;q<vK;q++){
      curS[q*3]  =vSeeds[q*3]  +m*noise3(q*7.31,tt,1.7);
      curS[q*3+1]=vSeeds[q*3+1]+m*noise3(q*3.17,4.2,tt);
      curS[q*3+2]=vSeeds[q*3+2]+m*noise3(tt,q*5.53,9.1);
      pulseV[q]=0.65+0.35*noise3(q*1.91,tt*2.0,8.8);
    }
  }
  const df=0.6*f, dr=P.drift, sf=P.shapeFreq, tm=t*P.morph+seedOff*0.37, am=P.amorph;
  const fa=P.edit?0:P.foldAmp, ff=P.foldFreq, fph=t*P.foldSpeed*6.2832, fw=P.foldWarp;
  prepFeatures(t*P.flex*6.2832);
  for(let k=0;k<CNT;k++){
    const o=k*3, Fq=fold(U[o],U[o+1],U[o+2]), x=Fq[0], y=Fq[1], z=Fq[2];
    let g;
    if(!isV){
      g=0.7*noise3(x*f+tt,y*f-tt*0.6,z*f)+0.3*noise3(x*f*2.13+5.2,y*f*2.13,z*f*2.13-3.1+tt*1.7);
    } else {
      const px=x*R, py=y*R, pz=z*R;
      let d1=1e9,d2=1e9,n1=0;
      for(let q=0;q<vK;q++){
        const dx=px-curS[q*3], dy=py-curS[q*3+1], dz=pz-curS[q*3+2], d=dx*dx+dy*dy+dz*dz;
        if(d<d1){ d2=d1; d1=d; n1=q; } else if(d<d2) d2=d;
      }
      const e=(Math.sqrt(d2)-Math.sqrt(d1))*2/vSpacing;
      g=(Math.min(e,1)*2-1)*pulseV[n1];
    }
    grow[k]=Math.max(-0.25,P.slack+P.amp*g);
    let dx0=dr*noise3(x*df+11.1+tt*0.3,y*df,z*df), dz0=dr*noise3(x*df-3.3,y*df+tt*0.35,z*df+5.5);
    drY[k]=dr*noise3(x*df,y*df-7.7,z*df+tt*0.4);
    if(FM) dx0=-dx0;   // Driftvektor zurück an die Originalstelle spiegeln/drehen
    if(FA!==0){ const c=Math.cos(FA), sn=Math.sin(FA); drX[k]=dx0*c+dz0*sn; drZ[k]=-dx0*sn+dz0*c; } else { drX[k]=dx0; drZ[k]=dz0; }
    // amorphe Grundform + knorpelige Auswüchse (Formgedächtnis)
    rT[k]=1+am*(0.75*noise3(x*sf+tm,y*sf,z*sf-tm*0.7)+0.25*noise3(x*sf*2.1+3.0,y*sf*2.1-tm,z*sf*2.1))+featureH(x,y,z)+sculpt[k];
    // Faltenwurf: wandernde, verzogene Faltenbänder entlang der Hochachse (symmetrisch)
    if(fa>0){
      const ph=y*ff+fw*noise3(x*1.6+5.1,y*1.6,z*1.6+tt*0.4)-fph;
      const b=smooth(0.2,0.95,Math.sin(ph));
      foldB[k]=b; grow[k]+=fa*b; drY[k]+=P.foldPush*b;    // Überschuss + Schub → Falten legen sich übereinander
    } else foldB[k]=0;
  }
  // Zielform glätten: keine Spitzen, alles bleibt gewölbt
  for(let pass=0;pass<P.shapeSmooth;pass++){
    rTmp.set(rT);
    for(let k=0;k<CNT;k++){
      let a=0; const s0=adjS[k], s1=adjS[k+1];
      for(let m=s0;m<s1;m++) a+=rTmp[adj[m]];
      rT[k]=rTmp[k]*0.5+0.5*a/(s1-s0);
    }
  }
}

// Ruhelängen folgen der Zielform → die Haut ist in ihrer Form entspannt, nicht gespannt wie Stoff
function computeTL(){
  for(let c=0;c<CC;c++){
    const ia=CA[c], ib=CB[c], a=ia*3, b=ib*3, ra=R*rT[ia]+plast[ia], rb=R*rT[ib]+plast[ib];
    const dx=U[b]*rb-U[a]*ra, dy=U[b+1]*rb-U[a+1]*ra, dz=U[b+2]*rb-U[a+2]*ra;
    TL[c]=Math.sqrt(dx*dx+dy*dy+dz*dz);
  }
}

// Flächengewichtete Normalen + Volumen
function computeNormals(){
  nrm.fill(0); let v=0;
  for(let t=0;t<TN;t++){
    const a=TRI[t*3]*3, b=TRI[t*3+1]*3, c=TRI[t*3+2]*3;
    const ax=pos[a],ay=pos[a+1],az=pos[a+2], bx=pos[b],by=pos[b+1],bz=pos[b+2], cx=pos[c],cy=pos[c+1],cz=pos[c+2];
    const e1x=bx-ax,e1y=by-ay,e1z=bz-az, e2x=cx-ax,e2y=cy-ay,e2z=cz-az;
    const nx=e1y*e2z-e1z*e2y, ny=e1z*e2x-e1x*e2z, nz=e1x*e2y-e1y*e2x;
    nrm[a]+=nx; nrm[a+1]+=ny; nrm[a+2]+=nz;
    nrm[b]+=nx; nrm[b+1]+=ny; nrm[b+2]+=nz;
    nrm[c]+=nx; nrm[c+1]+=ny; nrm[c+2]+=nz;
    v+=ax*(by*cz-bz*cy)+ay*(bz*cx-bx*cz)+az*(bx*cy-by*cx);
  }
  for(let k=0;k<CNT;k++){
    const o=k*3, l=Math.hypot(nrm[o],nrm[o+1],nrm[o+2])||1;
    nrm[o]/=l; nrm[o+1]/=l; nrm[o+2]/=l;
  }
  return v/6;
}

function solve(withBend){
  const kb=P.bend, kst=P.stretch, sc=SC, end=withBend?CC:NE;   // Strukturkanten zuerst, Biegung nur jede 2. Iteration
  for(let c=0;c<end;c++){
    const ia=CA[c], ib=CB[c], wa=inv[ia], wb=inv[ib], w=wa+wb;
    if(w===0) continue;
    const a=ia*3, b=ib*3;
    const dx=pos[b]-pos[a], dy=pos[b+1]-pos[a+1], dz=pos[b+2]-pos[a+2];
    const d2=dx*dx+dy*dy+dz*dz; if(d2<1e-12) continue;
    const d=Math.sqrt(d2);
    const L=TL[c]*sc*(1+0.5*(grow[ia]+grow[ib]));
    let kk=CT[c]===0?1:kb; if(d>L) kk*=kst;   // Dehnung weicher als Stauchung
    const s=kk*(d-L)/(d*w);
    pos[a]+=dx*s*wa; pos[a+1]+=dy*s*wa; pos[a+2]+=dz*s*wa;
    pos[b]-=dx*s*wb; pos[b+1]-=dy*s*wb; pos[b+2]-=dz*s*wb;
  }
}

function smoothPass(kf){
  for(let k=0;k<CNT;k++){
    const w=inv[k]; if(!w) continue;
    let ax=0,ay=0,az=0; const s0=adjS[k], s1=adjS[k+1], n=s1-s0;
    for(let m=s0;m<s1;m++){ const q=adj[m]*3; ax+=pos[q]; ay+=pos[q+1]; az+=pos[q+2]; }
    const o=k*3, s=kf*w;
    pos[o]+=(ax/n-pos[o])*s; pos[o+1]+=(ay/n-pos[o+1])*s; pos[o+2]+=(az/n-pos[o+2])*s;
  }
}

function step(dt){
  const dt2=dt*dt, damp=0.985;
  const vol=computeNormals();
  const ratio=targetVol/Math.max(vol,1e-4);
  const acc=P.pressure*Math.min(3,Math.max(-1.5,3*(ratio-1)));
  const sc=Math.cbrt(targetVol/V0), kS=P.shapeK*(P.edit?3:1); SC=sc;
  for(let k=0;k<CNT;k++){
    if(inv[k]===0) continue;
    const o=k*3, x=pos[o], y=pos[o+1], z=pos[o+2];
    const nx=nrm[o], ny=nrm[o+1], nz=nrm[o+2];
    // Drift nur tangential
    const dn=drX[k]*nx+drY[k]*ny+drZ[k]*nz;
    const fx=drX[k]-nx*dn, fy=drY[k]-ny*dn, fz=drZ[k]-nz*dn;
    // Formgedächtnis: Feder zur amorphen Grundform
    const rt=R*sc*rT[k]+plast[k];
    const gx=(U[o]*rt-x)*kS, gy=(U[o+1]*rt-y)*kS, gz=(U[o+2]*rt-z)*kS;
    const vx=(x-prv[o])*damp, vy=(y-prv[o+1])*damp, vz=(z-prv[o+2])*damp;
    prv[o]=x; prv[o+1]=y; prv[o+2]=z;
    pos[o]  =x+vx+(nx*acc+fx+gx)*dt2;
    pos[o+1]=y+vy+(ny*acc+fy+gy)*dt2;
    pos[o+2]=z+vz+(nz*acc+fz+gz)*dt2;
  }
  applyGrab();
  for(let it=0;it<6;it++) solve((it&1)===0);
  if(P.round>0) smoothPass(P.round);
  applyGrab();
  radialClamp();
}

// Jeder Hautpunkt bleibt auf seinem eigenen Strahl vom Zentrum:
// die Form kann sich stark ausbeulen, aber nie in sich selbst stülpen
function radialClamp(){
  const minR=R*0.45, maxR=R*2.8, tm=P.slide;
  for(let k=0;k<CNT;k++){
    const o=k*3, ux=restP[o]/R, uy=restP[o+1]/R, uz=restP[o+2]/R;
    const x=pos[o], y=pos[o+1], z=pos[o+2];
    let d=x*ux+y*uy+z*uz;
    let tx=x-ux*d, ty=y-uy*d, tz=z-uz*d;
    if(d<minR) d=minR; else if(d>maxR) d=maxR;
    const tl=Math.hypot(tx,ty,tz), lim=(tm+P.overlap*foldB[k])*d/R;   // in Faltenbändern darf die Haut überlappen
    if(tl>lim){ const f=lim/tl; tx*=f; ty*=f; tz*=f; }
    pos[o]=ux*d+tx; pos[o+1]=uy*d+ty; pos[o+2]=uz*d+tz;
  }
}

// Spiegelkopplung: hält die Simulation exakt symmetrisch zur YZ-Ebene
function enforceMirror(s){
  for(let k=0;k<CNT;k++){
    const m=mirIdx[k]; if(m<k) continue;
    const a=k*3;
    if(m===k){ pos[a]-=pos[a]*s; prv[a]-=prv[a]*s; continue; }
    const b=m*3;
    for(const A of [pos,prv]){
      const mx=(A[a]-A[b])*0.5, my=(A[a+1]+A[b+1])*0.5, mz=(A[a+2]+A[b+2])*0.5;
      A[a]+=(mx-A[a])*s; A[a+1]+=(my-A[a+1])*s; A[a+2]+=(mz-A[a+2])*s;
      A[b]+=(-mx-A[b])*s; A[b+1]+=(my-A[b+1])*s; A[b+2]+=(mz-A[b+2])*s;
    }
  }
}

// Plastizität: was herausgezogen wird, bleibt als Auswuchs stehen und bildet sich langsam zurück
function updatePlastic(dt){
  const dec=Math.exp(-dt/Math.max(0.5,P.plasticDecay)), rate=1-Math.exp(-dt*P.plastic);
  for(let k=0;k<CNT;k++){
    let p=plast[k]*dec; const gw=grabW[k];
    if(gw>0){
      const o=k*3, d=pos[o]*U[o]+pos[o+1]*U[o+1]+pos[o+2]*U[o+2];
      p+=((d-R*SC*rT[k])-p)*rate*gw;
    }
    plast[k]=p;
  }
  plTmp.set(plast);
  for(let k=0;k<CNT;k++){
    let a=0; const s0=adjS[k], s1=adjS[k+1];
    for(let m=s0;m<s1;m++) a+=plTmp[adj[m]];
    plast[k]=plTmp[k]*0.7+0.3*a/(s1-s0);
  }
}

// schwerelos: Schwerpunkt sanft im Zentrum halten
function recenter(){
  let cx=0,cy=0,cz=0;
  for(let k=0;k<CNT;k++){ const o=k*3; cx+=pos[o]; cy+=pos[o+1]; cz+=pos[o+2]; }
  const s=0.2/CNT; cx*=s; cy*=s; cz*=s;
  for(let k=0;k<CNT;k++){ const o=k*3; pos[o]-=cx; pos[o+1]-=cy; pos[o+2]-=cz; prv[o]-=cx; prv[o+1]-=cy; prv[o+2]-=cz; }
}

// Selbstkollision (Spatial Hash)
// größerer Kollisionsradius = dickere Haut, Falten liegen nicht flach aufeinander
const CR=EL*1.05, CR2=CR*CR, CS=CR, HS=1<<16, HM=HS-1, SKIP2=(EL*3.2)*(EL*3.2);
const hStart=new Int32Array(HS+1), hCur=new Int32Array(HS+1), hOf=new Int32Array(CNT), hSorted=new Int32Array(CNT), cI=new Int32Array(CNT*3);
const hashC=(x,y,z)=>((Math.imul(x,73856093)^Math.imul(y,19349663)^Math.imul(z,83492791))>>>0)&HM;
function selfCollide(){
  hStart.fill(0);
  for(let k=0;k<CNT;k++){
    const o=k*3;
    const ix=Math.floor((pos[o]+4)/CS), iy=Math.floor((pos[o+1]+4)/CS), iz=Math.floor((pos[o+2]+4)/CS);
    cI[o]=ix; cI[o+1]=iy; cI[o+2]=iz;
    const h=hashC(ix,iy,iz); hOf[k]=h; hStart[h+1]++;
  }
  for(let c=1;c<=HS;c++) hStart[c]+=hStart[c-1];
  hCur.set(hStart);
  for(let k=0;k<CNT;k++) hSorted[hCur[hOf[k]]++]=k;
  for(let k=0;k<CNT;k++){
    const o=k*3, ix=cI[o], iy=cI[o+1], iz=cI[o+2];
    for(let dz=-1;dz<=1;dz++) for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
      const h=hashC(ix+dx,iy+dy,iz+dz);
      for(let m=hStart[h], e=hStart[h+1]; m<e; m++){
        const q=hSorted[m]; if(q<=k) continue;
        const p=q*3;
        const rx=restP[p]-restP[o], ry=restP[p+1]-restP[o+1], rz=restP[p+2]-restP[o+2];
        if(rx*rx+ry*ry+rz*rz<SKIP2) continue;
        const ex=pos[p]-pos[o], ey=pos[p+1]-pos[o+1], ez=pos[p+2]-pos[o+2];
        const d2=ex*ex+ey*ey+ez*ez; if(d2>=CR2||d2<1e-12) continue;
        const wa=inv[k], wb=inv[q], w=wa+wb; if(w===0) continue;
        const d=Math.sqrt(d2), s=(CR-d)/(d*w);
        pos[o]-=ex*s*wa; pos[o+1]-=ey*s*wa; pos[o+2]-=ez*s*wa;
        pos[p]+=ex*s*wb; pos[p+1]+=ey*s*wb; pos[p+2]+=ez*s*wb;
      }
    }
  }
}

// Greifen (lokale Koordinaten des Körpers)
const grab={on:false, idx:[], w:[], off:[], im:[], imgs:[[0,false]], target:new THREE.Vector3(), lift:0};
function startGrab(p){
  grab.idx.length=0; grab.w.length=0; grab.off.length=0; grab.im.length=0;
  grab.imgs=(P.sym!=='none'&&P.symGrab)?symImages():[[0,false]];
  const imP=grab.imgs.map(([a,m])=>symTr(p.x,p.y,p.z,a,m,[0,0,0]));
  const Rg=P.grabR;
  for(let k=0;k<CNT;k++){
    const o=k*3; let best=1e9, bi=0;
    for(let i=0;i<imP.length;i++){
      const q=imP[i], dx=pos[o]-q[0], dy=pos[o+1]-q[1], dz=pos[o+2]-q[2], d=dx*dx+dy*dy+dz*dz;
      if(d<best){ best=d; bi=i; }
    }
    const d=Math.sqrt(best);
    if(d<Rg){
      const q=imP[bi], t=1-d/Rg, w=t*t*(3-2*t);
      grab.idx.push(k); grab.w.push(w); grab.im.push(bi); grab.off.push(pos[o]-q[0],pos[o+1]-q[1],pos[o+2]-q[2]);
      inv[k]=1-0.9*w; grabW[k]=w;
    }
  }
  grab.on=grab.idx.length>0; grab.target.copy(p); grab.lift=0;
}
function applyGrab(){
  if(!grab.on) return;
  const T=grab.target, L=grab.lift;
  const imT=grab.imgs.map(([a,m])=>{ const v=symTr(T.x,T.y,T.z,a,m,[0,0,0]), l=Math.hypot(v[0],v[1],v[2])||1; return [v[0],v[1],v[2],v[0]/l,v[1]/l,v[2]/l]; });
  for(let i=0;i<grab.idx.length;i++){
    const o=grab.idx[i]*3, w=grab.w[i], s=w*0.5, q=imT[grab.im[i]];
    const tx=q[0]+grab.off[i*3]+q[3]*L*w, ty=q[1]+grab.off[i*3+1]+q[4]*L*w, tz=q[2]+grab.off[i*3+2]+q[5]*L*w;
    pos[o]+=(tx-pos[o])*s; pos[o+1]+=(ty-pos[o+1])*s; pos[o+2]+=(tz-pos[o+2])*s;
  }
}
function endGrab(){ for(const k of grab.idx){ inv[k]=1; grabW[k]=0; } grab.on=false; }

// Nachbearbeitung für das Rendering: Normalen, Krümmung, Stauchung
function postProcess(){
  computeNormals();
  rnrm.set(nrm);
  for(let pass=0;pass<P.nSmooth;pass++){
    rnTmp.set(rnrm);
    for(let k=0;k<CNT;k++){
      const o=k*3; let nx=rnTmp[o]*2, ny=rnTmp[o+1]*2, nz=rnTmp[o+2]*2;
      for(let m=adjS[k];m<adjS[k+1];m++){ const q=adj[m]*3; nx+=rnTmp[q]; ny+=rnTmp[q+1]; nz+=rnTmp[q+2]; }
      const l=Math.hypot(nx,ny,nz)||1; rnrm[o]=nx/l; rnrm[o+1]=ny/l; rnrm[o+2]=nz/l;
    }
  }
  const iE2=1/(EL*EL);
  for(let k=0;k<CNT;k++){
    const o=k*3, s0=adjS[k], s1=adjS[k+1], n=s1-s0;
    let ax=0,ay=0,az=0;
    for(let m=s0;m<s1;m++){ const q=adj[m]*3; ax+=pos[q]; ay+=pos[q+1]; az+=pos[q+2]; }
    curv[k]=((ax/n-pos[o])*nrm[o]+(ay/n-pos[o+1])*nrm[o+1]+(az/n-pos[o+2])*nrm[o+2])*iE2;
  }
  for(let pass=0;pass<2;pass++){
    curvT.set(curv);
    for(let k=0;k<CNT;k++){
      let a=0; const s0=adjS[k], s1=adjS[k+1];
      for(let m=s0;m<s1;m++) a+=curvT[adj[m]];
      curv[k]=0.5*curvT[k]+0.5*a/(s1-s0);
    }
  }
  comp4.fill(0);
  for(let e=0;e<NE;e++){
    const a=eA[e], b=eB[e], oa=a*3, ob=b*3;
    const ex=pos[ob]-pos[oa], ey=pos[ob+1]-pos[oa+1], ez=pos[ob+2]-pos[oa+2], d=Math.sqrt(ex*ex+ey*ey+ez*ez);
    const L=TL[e]*SC*(1+0.5*(grow[a]+grow[b])), r=d/L;
    const c=Math.min(1,Math.max(0,1-r)), s=Math.max(0,r-1);
    const il=1/CL[e];
    const rx=(restP[ob]-restP[oa])*il, ry=(restP[ob+1]-restP[oa+1])*il, rz=(restP[ob+2]-restP[oa+2])*il;
    const cx=c*rx*rx, cy=c*ry*ry, cz=c*rz*rz;
    comp4[a*4]+=cx; comp4[a*4+1]+=cy; comp4[a*4+2]+=cz; comp4[a*4+3]+=s;
    comp4[b*4]+=cx; comp4[b*4+1]+=cy; comp4[b*4+2]+=cz; comp4[b*4+3]+=s;
  }
  for(let k=0;k<CNT;k++){ const i=1/deg[k]; comp4[k*4]*=i; comp4[k*4+1]*=i; comp4[k*4+2]*=i; comp4[k*4+3]*=i; }
  for(let k=0;k<CNT;k++) curvA[k]+=(curv[k]-curvA[k])*0.2;
  for(let i=0;i<CNT*4;i++) compA[i]+=(comp4[i]-compA[i])*0.15;
}

// ------------------------------------------------------------------ Lichtschicht unter der Haut
let pulsePhase=0, maxE=0, pulseF=0;
function updateGlow(dt){
  const dec=Math.exp(-dt/Math.max(0.05,P.decay));
  const ch=P.charge*dt, mg=P.motionGlow*dt, cap=P.glowMax;
  for(let k=0;k<CNT;k++){
    let e=E[k]; const gw=grabW[k];
    if(gw>0){ e*=dec+(1-dec)*gw; e+=ch*gw; } else e*=dec;
    if(mg>0){
      const o=k*3, sp=Math.hypot(pos[o]-prv[o],pos[o+1]-prv[o+1],pos[o+2]-prv[o+2])*120;
      vSpd[k]+=(sp-vSpd[k])*0.08;   // geglättete Geschwindigkeit
      if(vSpd[k]>0.25) e+=mg*(vSpd[k]-0.25);
    }
    E[k]=e>cap?cap:e;
  }
  const sp=P.spread;
  if(sp>0) for(let pass=0;pass<2;pass++){
    Etmp.set(E);
    for(let k=0;k<CNT;k++){
      let a=0; const s0=adjS[k], s1=adjS[k+1];
      for(let m=s0;m<s1;m++) a+=Etmp[adj[m]];
      E[k]=Etmp[k]+sp*(a/(s1-s0)-Etmp[k]);
    }
  }
  maxE=0; for(let k=0;k<CNT;k++) if(E[k]>maxE) maxE=E[k];
  // je mehr Energie, desto schneller das Pulsieren – beim Abklingen wird es langsamer
  const fT=P.pulseRate*(0.5+0.5*Math.min(1,maxE/1.5));
  pulseF+=(fT-pulseF)*Math.min(1,dt*0.8);   // Frequenz gleitet weich
  pulsePhase+=dt*Math.PI*2*pulseF;
}

// ------------------------------------------------------------------ Render-Geometrie
const geo=new THREE.BufferGeometry();
const posAttr=new THREE.BufferAttribute(pos,3).setUsage(THREE.DynamicDrawUsage);
const nrmAttr=new THREE.BufferAttribute(rnrm,3).setUsage(THREE.DynamicDrawUsage);
const curvAttr=new THREE.BufferAttribute(curvA,1).setUsage(THREE.DynamicDrawUsage);
const compAttr=new THREE.BufferAttribute(compA,4).setUsage(THREE.DynamicDrawUsage);
const glowAttr=new THREE.BufferAttribute(E,1).setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position',posAttr); geo.setAttribute('normal',nrmAttr);
geo.setAttribute('aCurv',curvAttr); geo.setAttribute('aComp',compAttr); geo.setAttribute('aGlow',glowAttr);
geo.setAttribute('aRest',new THREE.BufferAttribute(restP,3));
geo.setIndex(new THREE.BufferAttribute(TRI,1));
geo.boundingSphere=new THREE.Sphere(new THREE.Vector3(),2.2);

// ------------------------------------------------------------------ Haare
const HMAX=30000;
const hT=new Int32Array(HMAX), hU=new Float32Array(HMAX), hV=new Float32Array(HMAX), hCos=new Float32Array(HMAX), hSin=new Float32Array(HMAX), hL=new Float32Array(HMAX), hTilt=new Float32Array(HMAX);
let hPlaced=0;
function placeHair(){
  const cl=P.cluster, cs=P.clusterSize, so=seedOff;
  let h=0, tries=0;
  while(h<HMAX && tries<HMAX*40){
    tries++;
    const t=(Math.random()*TN)|0; let u=Math.random(), v=Math.random(); if(u+v>1){ u=1-u; v=1-v; }
    const a=TRI[t*3]*3, b=TRI[t*3+1]*3, c=TRI[t*3+2]*3, w0=1-u-v;
    const x=restP[a]*w0+restP[b]*u+restP[c]*v, y=restP[a+1]*w0+restP[b+1]*u+restP[c+1]*v, z=restP[a+2]*w0+restP[b+2]*u+restP[c+2]*v;
    let d=0.5+0.5*(0.7*noise3(x*cs+so,y*cs,z*cs+21.3)+0.3*noise3(x*cs*2.7+4.1,y*cs*2.7,z*cs*2.7+so+40.1));
    d=Math.min(1,Math.max(0,d));
    const dm=smooth(0.32,0.78,d);
    if(Math.random()>(1-cl)+cl*dm*dm) continue;
    const ang=noise3(x*1.3+so,y*1.3,z*1.3+5.5)*3.0+(Math.random()-0.5)*0.9;
    hT[h]=t; hU[h]=u; hV[h]=v; hCos[h]=Math.cos(ang); hSin[h]=Math.sin(ang);
    hL[h]=(0.45+0.75*Math.random())*(0.55+0.7*dm); hTilt[h]=(Math.random()-0.5)*0.5;
    h++;
  }
  hPlaced=h;
}
const hp=new Float32Array(HMAX*6), hn=new Float32Array(HMAX*6), ht=new Float32Array(HMAX*2), hg=new Float32Array(HMAX*2);
for(let h=0;h<HMAX;h++){ ht[h*2]=0; ht[h*2+1]=1; }
const hairGeo=new THREE.BufferGeometry();
const hpAttr=new THREE.BufferAttribute(hp,3).setUsage(THREE.DynamicDrawUsage);
const hnAttr=new THREE.BufferAttribute(hn,3).setUsage(THREE.DynamicDrawUsage);
const hgAttr=new THREE.BufferAttribute(hg,1).setUsage(THREE.DynamicDrawUsage);
hairGeo.setAttribute('position',hpAttr); hairGeo.setAttribute('aN',hnAttr); hairGeo.setAttribute('aG',hgAttr);
hairGeo.setAttribute('aT',new THREE.BufferAttribute(ht,1));
hairGeo.boundingSphere=new THREE.Sphere(new THREE.Vector3(),2.2);

const REF=new THREE.Vector3(0.27,0.92,0.28).normalize();
function updateHair(){
  const cnt=Math.min(hPlaced,P.hairCount|0);
  hairLines.visible=cnt>0&&P.hairAlpha>0;
  if(!hairLines.visible) return;
  const L0=P.hairLen;
  for(let h=0;h<cnt;h++){
    const t=hT[h], a=TRI[t*3], b=TRI[t*3+1], c=TRI[t*3+2], u=hU[h], v=hV[h], w0=1-u-v;
    const A=a*3, B=b*3, C=c*3;
    let bx=pos[A]*w0+pos[B]*u+pos[C]*v, by=pos[A+1]*w0+pos[B+1]*u+pos[C+1]*v, bz=pos[A+2]*w0+pos[B+2]*u+pos[C+2]*v;
    let nx=rnrm[A]*w0+rnrm[B]*u+rnrm[C]*v, ny=rnrm[A+1]*w0+rnrm[B+1]*u+rnrm[C+1]*v, nz=rnrm[A+2]*w0+rnrm[B+2]*u+rnrm[C+2]*v;
    let l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
    let tx=ny*REF.z-nz*REF.y, ty=nz*REF.x-nx*REF.z, tz=nx*REF.y-ny*REF.x;
    l=Math.hypot(tx,ty,tz); if(l<1e-3){ tx=0; ty=nz; tz=-ny; l=Math.hypot(ty,tz)||1; }
    tx/=l; ty/=l; tz/=l;
    const qx=ny*tz-nz*ty, qy=nz*tx-nx*tz, qz=nx*ty-ny*tx;
    const tl=Math.min(1.5,Math.max(0,P.hairTilt+hTilt[h])), ct=Math.cos(tl), st=Math.sin(tl);
    const ca=hCos[h], sa=hSin[h];
    const dx=nx*ct+(tx*ca+qx*sa)*st, dy=ny*ct+(ty*ca+qy*sa)*st, dz=nz*ct+(tz*ca+qz*sa)*st;
    const len=L0*hL[h];
    bx+=nx*0.0006; by+=ny*0.0006; bz+=nz*0.0006;
    const o=h*6;
    hp[o]=bx; hp[o+1]=by; hp[o+2]=bz; hp[o+3]=bx+dx*len; hp[o+4]=by+dy*len; hp[o+5]=bz+dz*len;
    hn[o]=nx; hn[o+1]=ny; hn[o+2]=nz; hn[o+3]=nx; hn[o+4]=ny; hn[o+5]=nz;
    const g=E[a]*w0+E[b]*u+E[c]*v; hg[h*2]=g; hg[h*2+1]=g;
  }
  hairGeo.setDrawRange(0,cnt*2);
  hpAttr.needsUpdate=true; hnAttr.needsUpdate=true; hgAttr.needsUpdate=true;
}

// ------------------------------------------------------------------ Shader
const skinVert = /* glsl */`
#include <common>
#include <shadowmap_pars_vertex>
attribute vec3 aRest;
attribute float aCurv;
attribute vec4 aComp;
attribute float aGlow;
varying vec3 vWorldPos;
varying vec3 vNormalW;
varying vec3 vRest;
varying float vCurv;
varying vec4 vComp;
varying float vGlow;
void main(){
  vComp = aComp; vGlow = aGlow; vRest = aRest; vCurv = aCurv;
  vec4 worldPosition = modelMatrix * vec4(position, 1.0);
  vec3 transformedNormal = normalMatrix * normal;
  #include <shadowmap_vertex>
  vWorldPos = worldPosition.xyz;
  vNormalW = normalize(mat3(modelMatrix) * normal);
  gl_Position = projectionMatrix * viewMatrix * worldPosition;
}`;

const skinFrag = /* glsl */`
#include <common>
#include <packing>
#include <shadowmap_pars_fragment>
uniform vec3 uL1Dir, uL1Col, uL2Dir, uL2Col, uCamPos, uSkin, uDeep, uGlow, uGlowDeep;
uniform float uFineShade, uMoles, uFreckles, uBlotch;
uniform float uScatter, uTrans, uRough, uSpec, uOil, uPore, uPoreScale, uFine, uFineFreq, uFineIrr, uCompGain, uCavity, uCurvScale, uAmbient, uShadowOn;
uniform float uGlowInt, uPhase, uPulseDepth, uPulseWave, uBaseGlow, uTime, uThin;
varying vec3 vWorldPos;
varying vec3 vNormalW;
varying vec3 vRest;
varying float vCurv;
varying vec4 vComp;
varying float vGlow;

vec3 skHash3(vec3 p){
  p = vec3(dot(p, vec3(127.1,311.7,74.7)), dot(p, vec3(269.5,183.3,246.1)), dot(p, vec3(113.5,271.9,124.6)));
  return fract(sin(p)*43758.5453);
}
vec3 skMod289(vec3 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec4 skMod289(vec4 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec4 skPermute(vec4 x){ return skMod289(((x*34.0)+1.0)*x); }
vec4 skTIS(vec4 r){ return 1.79284291400159 - 0.85373472095314*r; }
float skSnoise(vec3 v){
  const vec2 C = vec2(1.0/6.0, 1.0/3.0);
  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
  vec3 i = floor(v + dot(v, C.yyy));
  vec3 x0 = v - i + dot(i, C.xxx);
  vec3 g = step(x0.yzx, x0.xyz);
  vec3 l = 1.0 - g;
  vec3 i1 = min(g.xyz, l.zxy);
  vec3 i2 = max(g.xyz, l.zxy);
  vec3 x1 = x0 - i1 + C.xxx;
  vec3 x2 = x0 - i2 + C.yyy;
  vec3 x3 = x0 - D.yyy;
  i = skMod289(i);
  vec4 p = skPermute(skPermute(skPermute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
  float n_ = 0.142857142857;
  vec3 ns = n_*D.wyz - D.xzx;
  vec4 j = p - 49.0*floor(p*ns.z*ns.z);
  vec4 x_ = floor(j*ns.z);
  vec4 y_ = floor(j - 7.0*x_);
  vec4 x = x_*ns.x + ns.yyyy;
  vec4 y = y_*ns.x + ns.yyyy;
  vec4 h = 1.0 - abs(x) - abs(y);
  vec4 b0 = vec4(x.xy, y.xy);
  vec4 b1 = vec4(x.zw, y.zw);
  vec4 s0 = floor(b0)*2.0 + 1.0;
  vec4 s1 = floor(b1)*2.0 + 1.0;
  vec4 sh = -step(h, vec4(0.0));
  vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
  vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
  vec3 p0 = vec3(a0.xy, h.x);
  vec3 p1 = vec3(a0.zw, h.y);
  vec3 p2 = vec3(a1.xy, h.z);
  vec3 p3 = vec3(a1.zw, h.w);
  vec4 norm = skTIS(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
  m = m*m;
  return 42.0*dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
float skCell(vec3 p){
  vec3 i = floor(p), f = fract(p); float d = 8.0;
  for(int z=-1; z<=1; z++) for(int y=-1; y<=1; y++) for(int x=-1; x<=1; x++){
    vec3 g = vec3(float(x), float(y), float(z));
    vec3 r = g + 0.1 + 0.8*skHash3(i+g) - f; d = min(d, dot(r,r));
  }
  return sqrt(d);
}
float skSpots(vec3 p, float prob, float rmin, float rmax, float seed, out float bump){
  vec3 i = floor(p), f = fract(p); float m = 0.0; bump = 0.0;
  for(int z=-1; z<=1; z++) for(int y=-1; y<=1; y++) for(int x=-1; x<=1; x++){
    vec3 g = vec3(float(x), float(y), float(z));
    vec3 c = i + g;
    vec3 h1 = skHash3(c + seed), h2 = skHash3(c + seed + 37.7);
    if(h1.x > prob) continue;
    float rad = mix(rmin, rmax, h1.y*h1.y);
    vec3 dv = g + 0.2 + 0.6*h2 - f;
    float d = length(dv)/rad;
    float s = 1.0 - smoothstep(0.55, 1.0, d);
    m = max(m, s*mix(0.55, 1.0, h2.y));
    bump = max(bump, clamp(1.0 - d*d, 0.0, 1.0));
  }
  return m;
}
float skinHeight(vec3 p, vec3 nr, vec3 comp, float valley, out float poreMask, out float groove){
  // Poren
  float ps = uPoreScale;
  vec3 pp = p*ps;
  float aa = 1.0 - smoothstep(0.25, 0.9, length(fwidth(pp)));
  float pore = 1.0 - smoothstep(0.05, 0.24, skCell(pp));
  poreMask = pore*aa;
  float h = -pore*uPore*aa*0.6/ps;
  h += skSnoise(pp*0.35)*0.18*uPore*aa/ps;

  // feine Falten: drei Rillen-Familien (Achsen im Ruheraum), gewichtet nach Stauchrichtung
  float fq = uFineFreq, irr = uFineIrr;
  vec3 w = p + vec3(skSnoise(p*9.0), skSnoise(p*9.0 + 19.1), skSnoise(p*9.0 - 11.7))*(5.0/fq)*(1.0 + 2.0*irr)
             + vec3(skSnoise(p*31.0 + 4.0), skSnoise(p*31.0 - 8.0), skSnoise(p*31.0 + 15.0))*(1.5/fq)*irr;
  float ph = skSnoise(p*5.0 + 3.7)*2.4 + (skSnoise(p*13.0)*3.0 + skSnoise(p*29.0 + 5.0)*1.2)*irr;
  vec3 t = w*fq*vec3(1.0, 0.93, 1.07);
  vec3 aaT = 1.0 - smoothstep(0.6, 1.6, fwidth(t));
  vec3 g = pow(0.5 + 0.5*sin(t + ph*vec3(1.0, 1.3, 0.8) + vec3(0.0, 1.7, 3.1)), vec3(3.0));
  vec3 sg = w*fq*0.12;
  vec3 seg = mix(vec3(1.0), smoothstep(-0.35, 0.45, vec3(skSnoise(sg + 2.0), skSnoise(sg + 9.0), skSnoise(sg - 6.0))), irr);
  vec3 tang = 1.0 - nr*nr;
  float patchA = smoothstep(-0.4, 0.7, skSnoise(p*2.2 + 11.0));
  float base = (0.2 + 0.45*patchA + valley*0.6)*0.7;
  vec3 amp = base*tang + clamp(comp*uCompGain, 0.0, 1.0);
  float gsum = dot(g*amp*seg*aaT, vec3(1.0));
  // zweite, feinere Lage diagonal
  vec3 dg = vec3(0.57735);
  float f2 = fq*1.9, td = dot(w, dg)*f2;
  float aaD = 1.0 - smoothstep(0.6, 1.6, fwidth(td));
  float gd = pow(0.5 + 0.5*sin(td + ph*1.7), 3.0)*smoothstep(-0.2, 0.6, skSnoise(w*f2*0.08 + 5.0));
  float nd = dot(nr, dg);
  gsum += gd*(amp.x + amp.y + amp.z)*0.2*irr*aaD*(fq/f2)*(1.0 - nd*nd);
  groove = gsum*uFine;
  h -= gsum*uFine*0.9/fq;
  return h;
}

vec3 skPerturb(vec3 N, vec3 p, float h){
  vec3 dpdx = dFdx(p), dpdy = dFdy(p);
  float dhdx = dFdx(h), dhdy = dFdy(h);
  vec3 r1 = cross(dpdy, N), r2 = cross(N, dpdx);
  float det = dot(dpdx, r1);
  vec3 grad = sign(det)*(dhdx*r1 + dhdy*r2);
  return normalize(abs(det)*N - grad);
}
float skD(float nh, float rough){ float a = rough*rough; float a2 = a*a; float d = nh*nh*(a2-1.0)+1.0; return a2/(PI*d*d); }
vec3 skACES(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0); }

vec3 lightTerm(vec3 L, vec3 Lc, float shadow, vec3 N, vec3 Nb, vec3 No, vec3 V, vec3 alb, float occ, float poreMask, float ridge){
  float nlB = dot(Nb, L), nlG = dot(N, L);
  vec3 nl = vec3(mix(nlB, nlG, uScatter*0.85), mix(nlB, nlG, uScatter*0.4), nlB);
  vec3 wr = uScatter*vec3(0.6, 0.25, 0.12);
  vec3 diff = clamp((nl + wr)/(1.0 + wr), 0.0, 1.0);
  float shF = mix(0.12, 1.0, shadow);
  vec3 sh = mix(vec3(shF), vec3(pow(shF, 0.75), pow(shF, 0.9), shF), uScatter*0.6);
  // Transluzenz: dünne Grate leuchten von hinten durch
  float thin = clamp(0.25 + ridge*1.2, 0.0, 1.0);
  vec3 Lt = normalize(L + N*0.4);
  float back = pow(clamp(dot(V, -Lt), 0.0, 1.0), 4.0);
  float through = clamp(-nlG, 0.0, 1.0)*thin;
  vec3 trans = uDeep*(back*thin + through*0.8)*uTrans*mix(0.4, 1.0, shadow);
  // Spekular: Haut + Ölfilm
  vec3 Hh = normalize(L + V);
  float nh = max(dot(Nb, Hh), 0.0), nv = max(dot(Nb, V), 1e-3), nls = max(nlB, 0.0), vh = max(dot(V, Hh), 0.0);
  float F = 0.028 + 0.972*pow(1.0 - vh, 5.0);
  float k = uRough*uRough*0.5;
  float vis = 0.25/((nls*(1.0-k)+k)*(nv*(1.0-k)+k));
  float specSkin = F*vis*nls*skD(nh, uRough)*(1.0 - poreMask*0.6);
  float roughO = max(uRough*0.26, 0.05);
  float nhO = max(dot(No, Hh), 0.0), nvO = max(dot(No, V), 1e-3), nlO = max(dot(No, L), 0.0);
  float kO = roughO*roughO*0.5;
  float visO = 0.25/((nlO*(1.0-kO)+kO)*(nvO*(1.0-kO)+kO));
  float specOil = F*visO*nlO*skD(nhO, roughO);
  float spec = (specSkin*(1.0 - 0.35*uOil) + specOil*uOil*1.8)*occ;
  // Softbox-Reflexion
  vec3 Rr = reflect(-V, No);
  float FvO = 0.028 + 0.972*pow(1.0 - nvO, 5.0);
  float softbox = smoothstep(0.86, 0.97, dot(Rr, L));
  vec3 env = Lc*softbox*0.35*mix(0.25, 1.0, shadow)*FvO*(0.25 + uOil)*uSpec*occ;
  return Lc*(alb*diff*sh*occ + vec3(spec*uSpec*shadow)) + trans*Lc + env;
}

void main(){
  vec3 N = normalize(vNormalW);
  if(!gl_FrontFacing) N = -N;
  vec3 V = normalize(uCamPos - vWorldPos);
  float cav = clamp(vCurv*uCurvScale, -1.0, 1.0);
  float valley = max(cav, 0.0), ridge = max(-cav, 0.0);
  vec3 p = vRest, nr = normalize(vRest);
  float poreMask, groove;
  float h = skinHeight(p, nr, vComp.xyz, valley, poreMask, groove);
  float moleBump, frBump;
  float mole = skSpots(p*7.0 + skSnoise(p*40.0)*0.03, 0.13, 0.07, 0.19, 3.1, moleBump)*min(1.0, uMoles*1.6);
  float frMask = smoothstep(0.1, 0.75, skSnoise(p*1.4 + 30.0));
  float freck = skSpots(p*24.0, 0.3, 0.14, 0.32, 17.0, frBump)*frMask*uFreckles;
  h += moleBump*step(0.001, mole)*0.0035*uMoles;
  vec3 Nb = skPerturb(N, vWorldPos, h);

  float shadow = 1.0;
  #ifdef USE_SHADOWMAP
  #if NUM_DIR_LIGHT_SHADOWS > 0
    DirectionalLightShadow ds = directionalLightShadows[0];
    shadow = getShadow(directionalShadowMap[0], ds.shadowMapSize, ds.shadowBias, ds.shadowRadius, vDirectionalShadowCoord[0]);
  #endif
  #endif
  shadow = mix(1.0, shadow, uShadowOn);

  vec3 alb = mix(uSkin, uDeep, valley*0.35*uScatter);
  alb *= 1.0 - poreMask*0.12;
  float red = smoothstep(0.05, 0.85, skSnoise(p*2.3 + 7.0)*0.7 + skSnoise(p*6.1 - 3.0)*0.3);
  float tone = skSnoise(p*1.1 + 50.0)*0.6 + skSnoise(p*4.3 + 12.0)*0.3 + skSnoise(p*17.0)*0.1;
  alb = mix(alb, alb*vec3(1.06, 0.84, 0.82), red*0.55*uBlotch);
  alb *= 1.0 + tone*0.09*uBlotch;
  alb = mix(alb, alb*vec3(1.0, 0.97, 0.88), smoothstep(0.2, 0.9, skSnoise(p*0.8 - 20.0))*0.4*uBlotch);
  alb = mix(alb, alb*vec3(0.78, 0.6, 0.48), clamp(freck, 0.0, 1.0)*0.6);
  vec3 moleCol = mix(vec3(0.36, 0.21, 0.14), vec3(0.17, 0.10, 0.07), moleBump);
  alb = mix(alb, moleCol, clamp(mole, 0.0, 1.0)*0.9);
  float occ = clamp(1.0 - smoothstep(0.0, 1.0, valley)*uCavity, 0.2, 1.0);
  occ *= 1.0 - clamp(groove, 0.0, 1.0)*0.6*uFineShade;

  vec3 No = normalize(mix(Nb, N, 0.6));
  vec3 col = lightTerm(normalize(uL1Dir), uL1Col, shadow, N, Nb, No, V, alb, occ, poreMask, ridge)
           + lightTerm(normalize(uL2Dir), uL2Col, 1.0,    N, Nb, No, V, alb, occ, poreMask, ridge);
  col += alb*mix(vec3(0.45, 0.45, 0.5), vec3(1.0), N.y*0.5 + 0.5)*uAmbient*occ;

  // Lichtschicht unter der Haut: pulsierend, Wellen laufen vom Zentrum nach außen
  // reiner Sinus, nur sanfter räumlicher Versatz
  float pw = 0.5 + 0.5*sin(uPhase + uPulseWave*skSnoise(p*1.2 + 7.0));
  float G = vGlow*(1.0 - uPulseDepth + uPulseDepth*pw);
  G += uBaseGlow*smoothstep(-0.3, 0.9, skSnoise(p*2.0 + vec3(0.0, uTime*0.08, uTime*0.05)))*(0.7 + 0.3*pw);
  float nvG = max(dot(N, V), 0.0);
  float thinG = clamp(0.6 + vComp.w*uThin*8.0 + ridge*0.4*uThin - valley*0.5, 0.15, 2.5);
  float visG = mix(0.35, 1.0, nvG)*thinG*(1.0 - clamp(groove, 0.0, 1.0)*0.35)*(1.0 - poreMask*0.15)*(1.0 - clamp(mole, 0.0, 1.0)*0.7);
  vec3 gc = mix(uGlowDeep, uGlow, smoothstep(0.2, 1.8, G));
  gc += vec3(1.0, 0.85, 0.6)*smoothstep(1.6, 4.0, G)*0.6;
  vec3 emit = gc*G*visG*uGlowInt;
  emit *= mix(vec3(1.0), normalize(alb + 1e-3)*1.7, 0.35);
  col += emit;

  // linear HDR – Tonemapping + sRGB im OutputPass
  gl_FragColor = vec4(col, 1.0);
}`;

const hairVert = /* glsl */`
attribute float aT;
attribute vec3 aN;
attribute float aG;
varying float vT;
varying vec3 vN;
varying float vG;
void main(){
  vT = aT; vG = aG; vN = normalize(mat3(modelMatrix)*aN);
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}`;
const hairFrag = /* glsl */`
uniform vec3 uColor, uL1Dir, uL1Col, uL2Dir, uL2Col, uGlow;
uniform float uAlpha, uAmb, uGlowInt;
varying float vT;
varying vec3 vN;
varying float vG;
void main(){
  vec3 n = normalize(vN);
  float l1 = clamp((dot(n, normalize(uL1Dir)) + 0.4)/1.4, 0.0, 1.0);
  float l2 = clamp((dot(n, normalize(uL2Dir)) + 0.6)/1.6, 0.0, 1.0);
  vec3 c = uColor*(uAmb*2.0 + 0.3*(l1*uL1Col + l2*uL2Col))*mix(0.8, 1.15, vT);
  c += uGlow*vG*uGlowInt*0.25*(1.0 - vT*0.6);
  gl_FragColor = vec4(c, uAlpha*(1.0 - 0.8*vT));
}`;

// ------------------------------------------------------------------ Szene
const stage=document.getElementById('stage');
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(1); // Haut-Shader ist fragment-lastig
renderer.setClearColor(0x000000,1);
renderer.outputColorSpace=THREE.SRGBColorSpace;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=P.exposure;
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.VSMShadowMap;
stage.prepend(renderer.domElement);
const cv=renderer.domElement;

const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(32,1,0.05,50);
camera.position.set(0,0.35,3.7);

// Zwei Directional Lights – Low-Key: warmes Führungslicht + kühles Gegenlicht
const key=new THREE.DirectionalLight(0xffffff,1);
key.castShadow=true;
key.shadow.mapSize.set(1024,1024);
{ const sc=key.shadow.camera; sc.left=-1.3; sc.right=1.3; sc.top=1.3; sc.bottom=-1.3; sc.near=2; sc.far=10; sc.updateProjectionMatrix(); }
key.shadow.bias=-0.0002; key.shadow.normalBias=0.004;
key.shadow.radius=P.shadowSoft; key.shadow.blurSamples=16;
const rim=new THREE.DirectionalLight(0xffffff,1);
scene.add(key, key.target, rim, rim.target);

const skinUniforms=THREE.UniformsUtils.merge([THREE.UniformsLib.lights,{
  uL1Dir:{value:new THREE.Vector3(0,1,0)}, uL1Col:{value:new THREE.Color()}, uL2Dir:{value:new THREE.Vector3(0,1,0)}, uL2Col:{value:new THREE.Color()},
  uCamPos:{value:new THREE.Vector3()}, uSkin:{value:new THREE.Color()}, uDeep:{value:new THREE.Color()},
  uGlow:{value:new THREE.Color()}, uGlowDeep:{value:new THREE.Color()},
  uScatter:{value:0}, uTrans:{value:0}, uRough:{value:0.5}, uSpec:{value:1}, uOil:{value:0},
  uPore:{value:0}, uPoreScale:{value:100}, uFine:{value:0}, uFineFreq:{value:280}, uFineIrr:{value:0.75}, uCompGain:{value:4}, uCavity:{value:0}, uCurvScale:{value:0.12},
  uAmbient:{value:0.03}, uShadowOn:{value:1}, uFineShade:{value:0.5}, uMoles:{value:0.5}, uFreckles:{value:0.5}, uBlotch:{value:0.5},
  uGlowInt:{value:1}, uPhase:{value:0}, uPulseDepth:{value:0.6}, uPulseWave:{value:2}, uBaseGlow:{value:0}, uTime:{value:0}, uThin:{value:1}
}]);
const skinMat=new THREE.ShaderMaterial({uniforms:skinUniforms, vertexShader:skinVert, fragmentShader:skinFrag, lights:true, side:THREE.DoubleSide});

const body=new THREE.Group();
scene.add(body);
const skin=new THREE.Mesh(geo,skinMat);
skin.castShadow=true; skin.receiveShadow=true; skin.frustumCulled=false;
body.add(skin);

const hairMat=new THREE.ShaderMaterial({
  uniforms:{uColor:{value:new THREE.Color()}, uL1Dir:{value:new THREE.Vector3()}, uL1Col:{value:new THREE.Color()}, uL2Dir:{value:new THREE.Vector3()}, uL2Col:{value:new THREE.Color()},
    uGlow:{value:new THREE.Color()}, uAlpha:{value:0.5}, uAmb:{value:0.03}, uGlowInt:{value:1}},
  vertexShader:hairVert, fragmentShader:hairFrag, transparent:true, depthWrite:false
});
const hairLines=new THREE.LineSegments(hairGeo,hairMat);
hairLines.frustumCulled=false;
body.add(hairLines);

// Bloom, damit das Glimmen in den schwarzen Raum strahlt
const composer=new EffectComposer(renderer);
composer.addPass(new RenderPass(scene,camera));
const bloom=new UnrealBloomPass(new THREE.Vector2(256,256),P.bloom,P.bloomRadius,P.bloomThr);
composer.addPass(bloom);
composer.addPass(new OutputPass());

// ------------------------------------------------------------------ Sculpt-Modus (Grundform)
const SCP={down:false, inv:false, hit:false, dir:new THREE.Vector3()};
const ring=new THREE.LineLoop(
  new THREE.BufferGeometry().setFromPoints(Array.from({length:64},(_,i)=>new THREE.Vector3(Math.cos(i/64*Math.PI*2),Math.sin(i/64*Math.PI*2),0))),
  new THREE.LineBasicMaterial({color:0xffffff, transparent:true, opacity:0.55, depthTest:false}));
ring.visible=false; ring.renderOrder=10; scene.add(ring);
const ringN=new THREE.Vector3();
function sculptRay(e){
  setNDC(e); ray.setFromCamera(ndc,camera);
  const h=ray.intersectObject(skin,false)[0];
  if(!h){ SCP.hit=false; ring.visible=false; return false; }
  SCP.hit=true; body.updateMatrixWorld();
  SCP.dir.copy(body.worldToLocal(h.point.clone())).normalize();
  ringN.copy(h.face.normal).transformDirection(skin.matrixWorld);
  ring.position.copy(h.point).addScaledVector(ringN,0.004);
  ring.lookAt(h.point.clone().add(ringN));
  ring.scale.setScalar(P.brushR);
  ring.visible=true;
  return true;
}
// Pinsel wirkt auf die Ruherichtungen U → bei Symmetrie exakt gespiegelt
function applySculpt(dt){
  const imgs=P.sym!=='none'?symImages():[[0,false]];
  const dirs=imgs.map(([a,m])=>symTr(SCP.dir.x,SCP.dir.y,SCP.dir.z,a,m,[0,0,0]));
  const ang=P.brushR/R, ca=Math.cos(ang), mode=P.brush;
  let sgn=mode==='push'?-1:1; if(SCP.inv) sgn=-sgn;
  const st=P.brushStr*dt;
  if(mode==='smooth') rTmp.set(sculpt);
  for(let k=0;k<CNT;k++){
    const o=k*3; let best=-2;
    for(const q of dirs){ const d=U[o]*q[0]+U[o+1]*q[1]+U[o+2]*q[2]; if(d>best) best=d; }
    if(best<ca) continue;
    const t=1-Math.acos(Math.min(1,best))/ang, f=t*t*(3-2*t);
    if(mode==='smooth'){
      let a=0; const s0=adjS[k], s1=adjS[k+1];
      for(let m=s0;m<s1;m++) a+=rTmp[adj[m]];
      sculpt[k]+=(a/(s1-s0)-sculpt[k])*Math.min(1,f*st*12);
    } else if(mode==='flat'){
      sculpt[k]+=(0-sculpt[k])*Math.min(1,f*st*3);
    } else {
      sculpt[k]=Math.min(1.6,Math.max(-0.45,sculpt[k]+sgn*st*f*0.5));
    }
  }
}
const LS_KEY='skinorb_form_'+CNT;
function saveSculpt(){ try{ localStorage.setItem(LS_KEY,JSON.stringify(Array.from(sculpt,v=>Math.round(v*1e4)/1e4))); }catch(e){} }
function loadSculpt(){ try{ const t=localStorage.getItem(LS_KEY); if(t){ const a=JSON.parse(t); if(a.length===CNT) sculpt.set(a); } }catch(e){} }
function exportSculpt(){
  const blob=new Blob([JSON.stringify({type:'skin-orb-form', cnt:CNT, sculpt:Array.from(sculpt,v=>Math.round(v*1e4)/1e4)})],{type:'application/json'});
  const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='skin_orb_form.json'; a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function importSculpt(file){
  file.text().then(t=>{ const j=JSON.parse(t); if(j.cnt!==CNT||!Array.isArray(j.sculpt)) throw new Error('Mesh-Auflösung passt nicht');
    sculpt.set(j.sculpt); saveSculpt(); updateGrowth(simTime); computeTL(); }).catch(err=>alert('Form konnte nicht geladen werden: '+err.message));
}
loadSculpt();

// Maus-Greifen (vor OrbitControls registriert)
const ray=new THREE.Raycaster(), ndc=new THREE.Vector2(), dragPlane=new THREE.Plane(), tmpV=new THREE.Vector3(), camDir=new THREE.Vector3();
let controls;
function setNDC(e){ const r=cv.getBoundingClientRect(); ndc.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1); }
cv.addEventListener('contextmenu',e=>e.preventDefault());
cv.addEventListener('pointerdown',e=>{
  if(e.button!==0) return;
  if(P.edit){
    if(!sculptRay(e)) return;
    controls.enabled=false; cv.setPointerCapture(e.pointerId);
    SCP.down=true; SCP.inv=e.shiftKey||e.altKey; return;
  }
  setNDC(e); ray.setFromCamera(ndc,camera);
  const hit=ray.intersectObject(skin,false)[0];
  if(!hit) return;
  controls.enabled=false;
  cv.setPointerCapture(e.pointerId);
  camera.getWorldDirection(camDir);
  dragPlane.setFromNormalAndCoplanarPoint(camDir.negate(),hit.point);
  body.updateMatrixWorld();
  startGrab(body.worldToLocal(hit.point.clone()));
  cv.style.cursor='grabbing';
});
cv.addEventListener('pointermove',e=>{
  if(P.edit){ sculptRay(e); if(SCP.down) SCP.inv=e.shiftKey||e.altKey; return; }
  if(!grab.on) return;
  setNDC(e); ray.setFromCamera(ndc,camera);
  if(ray.ray.intersectPlane(dragPlane,tmpV)) grab.target.copy(body.worldToLocal(tmpV));
});
const release=()=>{ if(grab.on) endGrab(); if(SCP.down){ SCP.down=false; saveSculpt(); } controls.enabled=true; cv.style.cursor=P.edit?'crosshair':'grab'; };
cv.addEventListener('pointerleave',()=>{ if(!SCP.down) ring.visible=false; });
cv.addEventListener('pointerup',release);
cv.addEventListener('pointercancel',release);

controls=new OrbitControls(camera,cv);
controls.target.set(0,0,0);
controls.enablePan=false;
controls.enableDamping=true; controls.dampingFactor=0.08;
controls.minDistance=1.8; controls.maxDistance=7;
controls.mouseButtons={LEFT:THREE.MOUSE.ROTATE, MIDDLE:THREE.MOUSE.DOLLY, RIGHT:THREE.MOUSE.ROTATE};
controls.update();

function resize(){
  const r=stage.getBoundingClientRect();
  const w=Math.max(200,Math.floor(r.width)), h=Math.max(200,Math.floor(r.height));
  renderer.setSize(w,h);
  composer.setPixelRatio(renderer.getPixelRatio());
  composer.setSize(w,h);
  camera.aspect=w/h; camera.updateProjectionMatrix();
}
window.addEventListener('resize',resize);
resize();

// ------------------------------------------------------------------ UI
const UI=[
  ['h','Sculpt · Grundform'],
  ['edit','Edit-Modus (E)','check'],
  ['brush','Pinsel','select',[['draw','Aufbauen'],['push','Eindrücken'],['smooth','Glätten'],['flat','Zurücksetzen']]],
  ['brushR','Pinselgröße','range',0.05,0.8,0.005],
  ['brushStr','Stärke','range',0.05,2,0.01],
  ['h','Bewegung'],
  ['animate','Animation','check'],
  ['field','Feld','select',[['perlin','Perlin 3D'],['voronoi','Voronoi']]],
  ['speed','Tempo','range',0,0.6,0.005],
  ['scale','Feldgröße','range',0.4,3,0.01],
  ['slack','Überschuss','range',0,0.4,0.005],
  ['amp','Wachstum','range',0,0.4,0.005],
  ['pressure','Innendruck','range',0,5,0.05],
  ['breath','Atmen','range',0,1,0.01],
  ['breathRate','Atemtempo','range',0,0.4,0.005],
  ['deflate','Deflate','range',0.2,1,0.01],
  ['inflate','Inflate','range',0.6,1.6,0.01],
  ['drift','Drift','range',0,3,0.05],
  ['bend','Biegesteifigk.','range',0.02,1,0.01],
  ['round','Rundung','range',0,0.2,0.005],
  ['selfCol','Selbstkollision','check'],
  ['h','Form'],
  ['amorph','Amorphie','range',0,0.5,0.005],
  ['shapeFreq','Formfrequenz','range',0.4,4,0.05],
  ['morph','Formwandel','range',0,0.4,0.005],
  ['shapeK','Formgedächtnis','range',0,120,1],
  ['slide','Gleiten','range',0.005,0.2,0.005],
  ['h','Symmetrie · Auswüchse'],
  ['sym','Symmetrie','select',[['none','Aus'],['mirror','Spiegelachse'],['d2','2-zählig'],['d3','3-zählig'],['d4','4-zählig'],['d5','5-zählig'],['d6','6-zählig']]],
  ['symLock','Kopplung','range',0,1,0.01],
  ['symGrab','Symm. greifen','check'],
  ['bulges','Wölbungen','range',0,8,1],
  ['walls','Wände','range',0,6,1],
  ['ears','Ohren','range',0,3,1],
  ['noses','Nasen','range',0,2,1],
  ['featSize','Größe','range',0.3,2,0.01],
  ['flex','Regung','range',0,0.4,0.005],
  ['sway','Pendeln','range',0,2,0.01],
  ['plastic','Plastizität','range',0,8,0.05],
  ['plasticDecay','Rückbildung (s)','range',1,60,0.5],
  ['stretch','Zugsteifigk.','range',0.05,1,0.01],
  ['shapeSmooth','Formglättung','range',0,8,1],
  ['h','Faltenwurf'],
  ['foldAmp','Faltenwurf','range',0,0.5,0.005],
  ['foldFreq','Faltenbänder','range',1,16,0.1],
  ['foldSpeed','Wandern','range',-0.3,0.3,0.005],
  ['foldWarp','Verzug','range',0,3,0.05],
  ['foldPush','Schub','range',0,4,0.05],
  ['overlap','Überlappung','range',0,0.4,0.005],
  ['spin','Rotation','range',0,0.4,0.005],
  ['float','Schweben','range',0,3,0.05],
  ['h','Greifen'],
  ['grabR','Radius','range',0.03,0.4,0.005],
  ['lift','Herausziehen','range',0,0.5,0.005],
  ['h','Glühen unter der Haut'],
  ['glow','Lichtfarbe','color'],
  ['glowDeep','Tiefenfarbe','color'],
  ['glowInt','Intensität','range',0,5,0.05],
  ['charge','Aufladen','range',0,4,0.05],
  ['glowMax','Maximum','range',0.5,8,0.1],
  ['decay','Nachglühen (s)','range',0.3,15,0.1],
  ['spread','Ausbreitung','range',0,0.5,0.01],
  ['pulseRate','Pulsfrequenz','range',0,3,0.01],
  ['pulseDepth','Pulstiefe','range',0,1,0.01],
  ['pulseWave','Pulsversatz','range',0,4,0.05],
  ['motionGlow','Bewegungslicht','range',0,2,0.01],
  ['baseGlow','Grundglimmen','range',0,0.5,0.005],
  ['thin','Hautdünne','range',0,3,0.05],
  ['bloom','Bloom','range',0,2,0.01],
  ['bloomRadius','Bloom-Radius','range',0,1,0.01],
  ['bloomThr','Bloom-Schwelle','range',0,3,0.01],
  ['h','Hautmaterial'],
  ['skin','Hautfarbe','color'],
  ['deep','Subsurface','color'],
  ['scatter','Streuung','range',0,1,0.01],
  ['trans','Transluzenz','range',0,2,0.01],
  ['rough','Rauheit','range',0.15,0.9,0.01],
  ['spec','Glanz','range',0,2,0.01],
  ['oil','Ölfilm','range',0,1,0.01],
  ['pore','Poren','range',0,1.5,0.01],
  ['poreScale','Porendichte','range',40,400,1],
  ['fine','Feine Falten','range',0,2,0.01],
  ['fineFreq','Faltendichte','range',60,700,1],
  ['fineIrr','Unregelmäßigk.','range',0,1,0.01],
  ['compGain','Stauchfalten','range',0,12,0.1],
  ['fineShade','Rillenschatten','range',0,1,0.01],
  ['cavity','Faltenschatten','range',0,1,0.01],
  ['moles','Schönheitsfl.','range',0,1,0.01],
  ['freckles','Sommerspr.','range',0,1,0.01],
  ['blotch','Hautton-Unruhe','range',0,1,0.01],
  ['nSmooth','Mesh-Glättung','range',0,4,1],
  ['h','Haare'],
  ['hairCount','Anzahl','range',0,30000,500],
  ['cluster','Unregelmäßigk.','range',0,1,0.01],
  ['clusterSize','Büschelgröße','range',0.8,8,0.05],
  ['hairLen','Länge','range',0.004,0.06,0.001],
  ['hairTilt','Neigung','range',0,1.45,0.01],
  ['hairColor','Farbe','color'],
  ['hairAlpha','Deckkraft','range',0,1,0.01],
  ['h','Licht · Führung'],
  ['keyAz','Azimut','range',-180,180,1],
  ['keyEl','Höhe','range',-60,90,1],
  ['keyInt','Intensität','range',0,6,0.05],
  ['keyCol','Farbe','color'],
  ['h','Licht · Gegenlicht'],
  ['rimAz','Azimut','range',-180,180,1],
  ['rimEl','Höhe','range',-60,90,1],
  ['rimInt','Intensität','range',0,6,0.05],
  ['rimCol','Farbe','color'],
  ['h','Raum'],
  ['exposure','Belichtung','range',0.1,2,0.01],
  ['ambient','Umgebung','range',0,0.4,0.005],
  ['shadows','Schatten','check'],
  ['shadowSoft','Schattenweichh.','range',1,16,0.5],
];
const ON_CHANGE={cluster:placeHair, clusterSize:placeHair, sym:()=>endGrab(), edit:()=>setEdit(P.edit)};
const INP={};
const ctl=document.getElementById('controls');
for(const d of UI){
  if(d[0]==='h'){ const h=document.createElement('h2'); h.textContent=d[1]; ctl.appendChild(h); continue; }
  const [k,label,type]=d;
  const row=document.createElement('div'); row.className='row';
  const lab=document.createElement('label'); lab.textContent=label; row.appendChild(lab);
  const val=document.createElement('span'); val.className='v';
  let inp;
  if(type==='range'){
    inp=document.createElement('input'); inp.type='range'; inp.min=d[3]; inp.max=d[4]; inp.step=d[5]; inp.value=P[k];
    const st=+d[5];
    const fmt=()=>{ val.textContent = st>=1 ? String(Math.round(P[k])) : (+P[k]).toFixed(st<0.01?3:2); };
    fmt();
    inp.addEventListener('input',()=>{ P[k]=parseFloat(inp.value); fmt(); });
    if(ON_CHANGE[k]) inp.addEventListener('change',ON_CHANGE[k]);
  } else if(type==='check'){
    inp=document.createElement('input'); inp.type='checkbox'; inp.checked=P[k];
    inp.addEventListener('change',()=>{ P[k]=inp.checked; if(ON_CHANGE[k]) ON_CHANGE[k](); });
  } else if(type==='color'){
    inp=document.createElement('input'); inp.type='color'; inp.value=P[k];
    inp.addEventListener('input',()=>{ P[k]=inp.value; });
  } else {
    inp=document.createElement('select');
    for(const [v,t] of d[3]){ const o=document.createElement('option'); o.value=v; o.textContent=t; inp.appendChild(o); }
    inp.value=P[k];
    inp.addEventListener('change',()=>{ P[k]=inp.value; if(ON_CHANGE[k]) ON_CHANGE[k](); });
  }
  INP[k]=inp;
  row.appendChild(inp); row.appendChild(val); ctl.appendChild(row);
}
document.getElementById('reset').onclick=()=>{ endGrab(); resetSheet(); };
// Sculpt-Buttons direkt unter die Pinsel-Regler
{
  const bar=document.createElement('div');
  const mk=(t,fn)=>{ const b=document.createElement('button'); b.textContent=t; b.onclick=fn; bar.appendChild(b); return b; };
  const fileIn=document.createElement('input'); fileIn.type='file'; fileIn.accept='.json,application/json'; fileIn.style.display='none';
  fileIn.onchange=()=>{ if(fileIn.files[0]) importSculpt(fileIn.files[0]); fileIn.value=''; };
  mk('Form löschen',()=>{ sculpt.fill(0); saveSculpt(); updateGrowth(simTime); computeTL(); });
  mk('Plastik → Form',()=>{ for(let k=0;k<CNT;k++){ sculpt[k]+=plast[k]/(R*SC); plast[k]=0; } saveSculpt(); updateGrowth(simTime); computeTL(); });
  mk('Exportieren',exportSculpt);
  mk('Laden',()=>fileIn.click());
  bar.appendChild(fileIn);
  INP.brushStr.parentElement.after(bar);
}
const hintEl=document.getElementById('hint');
function setEdit(on){
  P.edit=on; if(INP.edit) INP.edit.checked=on;
  endGrab(); SCP.down=false; ring.visible=false;
  cv.style.cursor=on?'crosshair':'grab';
  hintEl.textContent=on?'EDIT · Linke Maus: modellieren · Shift: umkehren · Rechte Maus: Kamera · E: beenden'
                       :'Linke Maus: Haut ziehen → Glühen · Rechte Maus: Kamera · Rad: Zoom · E: Edit-Modus';
}
window.addEventListener('keydown',e=>{
  if(e.target.tagName==='INPUT'&&e.target.type!=='range'&&e.target.type!=='checkbox') return;
  if(e.key==='e'||e.key==='E') setEdit(!P.edit);
});
setEdit(false);
document.getElementById('reroll').onclick=()=>{ seedOff=Math.random()*100; vScale=-1; endGrab(); resetSheet(); updateGrowth(0); computeTL(); placeHair(); };

// ------------------------------------------------------------------ Loop
const l1=new THREE.Vector3(), l2=new THREE.Vector3();
function dirFrom(az,el,out){
  const a=THREE.MathUtils.degToRad(az), e=THREE.MathUtils.degToRad(el);
  return out.set(Math.cos(e)*Math.sin(a), Math.sin(e), Math.cos(e)*Math.cos(a)).normalize();
}
function updateUniforms(time){
  dirFrom(P.keyAz,P.keyEl,l1); dirFrom(P.rimAz,P.rimEl,l2);
  key.position.copy(l1).multiplyScalar(6); rim.position.copy(l2).multiplyScalar(6);
  const u=skinUniforms;
  u.uL1Dir.value.copy(l1); u.uL1Col.value.set(P.keyCol).multiplyScalar(P.keyInt);
  u.uL2Dir.value.copy(l2); u.uL2Col.value.set(P.rimCol).multiplyScalar(P.rimInt);
  u.uCamPos.value.copy(camera.position);
  u.uSkin.value.set(P.skin); u.uDeep.value.set(P.deep);
  u.uGlow.value.set(P.glow); u.uGlowDeep.value.set(P.glowDeep);
  u.uScatter.value=P.scatter; u.uTrans.value=P.trans; u.uRough.value=P.rough; u.uSpec.value=P.spec; u.uOil.value=P.oil;
  u.uPore.value=P.pore; u.uPoreScale.value=P.poreScale; u.uCavity.value=P.cavity;
  u.uFine.value=P.fine; u.uFineFreq.value=P.fineFreq; u.uFineIrr.value=P.fineIrr; u.uCompGain.value=P.compGain;
  u.uAmbient.value=P.ambient; u.uFineShade.value=P.fineShade; u.uMoles.value=P.moles; u.uFreckles.value=P.freckles; u.uBlotch.value=P.blotch;
  u.uShadowOn.value=P.shadows?1:0;
  u.uGlowInt.value=P.glowInt; u.uPhase.value=pulsePhase; u.uPulseDepth.value=P.pulseDepth; u.uPulseWave.value=P.pulseWave;
  u.uBaseGlow.value=P.baseGlow; u.uTime.value=time; u.uThin.value=P.thin;
  key.shadow.radius=P.shadowSoft;
  const hu=hairMat.uniforms;
  hu.uColor.value.set(P.hairColor);
  hu.uL1Dir.value.copy(l1); hu.uL1Col.value.copy(u.uL1Col.value);
  hu.uL2Dir.value.copy(l2); hu.uL2Col.value.copy(u.uL2Col.value);
  hu.uGlow.value.set(P.glow); hu.uGlowInt.value=P.glowInt; hu.uAlpha.value=P.hairAlpha; hu.uAmb.value=P.ambient;
  bloom.strength=P.bloom; bloom.radius=P.bloomRadius; bloom.threshold=P.bloomThr;
  renderer.toneMappingExposure=P.exposure;
}

let breathPh=0;
function updateBreath(){
  let b=1-P.breath*(0.5-0.5*Math.cos(breathPh));
  b+=0.08*P.breath*noise3(breathPh*0.7,4.1,seedOff);
  b=Math.min(1,Math.max(0,b));
  if(P.edit) b=1;   // im Edit-Modus: ruhig und voll aufgeblasen
  targetVol=V0*(P.deflate+(P.inflate-P.deflate)*b);
}

resetSheet();
updateGrowth(0);
computeTL();
placeHair();
updateBreath();
for(let i=0;i<150;i++) step(1/60);

let last=performance.now(), simTime=0, frameNo=0, fpsN=0, fpsT=performance.now();
const fpsEl=document.getElementById('fps');
function frame(now){
  const dt=Math.min((now-last)/1000,0.05); last=now;
  if(P.animate){ simTime+=dt; breathPh+=dt*Math.PI*2*P.breathRate; }
  if(grab.on) grab.lift+=(P.lift-grab.lift)*0.08;
  if(frameNo%3===0 || SCP.down){ updateGrowth(simTime); computeTL(); }
  if(SCP.down && SCP.hit) applySculpt(dt);
  updateBreath();
  step(1/60);
  if(P.selfCol && (frameNo&1)===0) selfCollide();
  if(P.sym!=='none' && P.symLock>0) enforceMirror(P.symLock);
  updatePlastic(dt);
  postProcess();
  updateGlow(dt);
  posAttr.needsUpdate=true; nrmAttr.needsUpdate=true; curvAttr.needsUpdate=true; compAttr.needsUpdate=true; glowAttr.needsUpdate=true;
  updateHair();
  // schwerelos treiben
  const f=P.edit?0:P.float;
  body.position.set(Math.sin(simTime*0.31)*0.02*f, Math.sin(simTime*0.5)*0.035*f, 0);
  body.rotation.x=Math.sin(simTime*0.23)*0.03*f;
  if(!P.edit) body.rotation.y+=dt*P.spin;
  updateUniforms(simTime);
  controls.update();
  composer.render();
  frameNo++;
  fpsN++; if(now-fpsT>500){ fpsEl.textContent=Math.round(fpsN*1000/(now-fpsT))+' fps · '+CNT+' Partikel'; fpsN=0; fpsT=now; }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}