SKIN_LANDSCAPES_v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/9f72cc1d4fc5e5ec/" 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 FOLD</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;display:flex;align-items:center;justify-content:center;min-width:0;padding:24px 24px 40px}
#wrap{position:relative}
#wrap canvas{display:block;cursor:grab;touch-action:none}
#hint{position:absolute;left:0;right:0;bottom:-22px;text-align:center;color:var(--mute);font-size:10px;letter-spacing:.06em}
#panel{width:290px;border-left:1px solid var(--line);overflow-y:auto;padding:20px 18px 40px}
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}#panel{width:100%;border-left:0;border-top:1px solid var(--line)}#stage{padding:16px 16px 36px}}
</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="wrap"><div id="hint">Linke Maus: Haut greifen · Rechte Maus: Kamera · Rad: Zoom</div></div></div>
  <aside id="panel">
    <h1>SKIN FOLD</h1>
    <div class="sub">Inflated Cloth · Subsurface · Vellushaar</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';

// ------------------------------------------------------------------ Parameter
const P = {
  animate:true, field:'perlin', speed:0.12, scale:1.5, slack:0.14, amp:0.18,
  pressure:1.6, gravity:2.4, drift:0.6, bend:0.35, round:0.03, selfCol:true,
  grabR:0.14, lift:0.18,
  skin:'#e9b8a0', deep:'#b3261e', scatter:0.75, trans:0.7, rough:0.42, spec:1.0, oil:0.75,
  pore:0.35, poreScale:150, fine:0.7, fineFreq:280, fineIrr:0.75, compGain:3.0, cavity:0.5, nSmooth:1,
  hairCount:16000, hairLen:0.016, hairTilt:1.0, hairColor:'#d8b896', hairAlpha:0.45, cluster:0.75, clusterSize:2.6,
  lightAz:40, lightEl:30, lightInt:1.55, ambient:0.22, 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); }

// ------------------------------------------------------------------ Cloth-Simulation
const N=92, PN=N+1, CNT=PN*PN, S=2.8, HALF=S/2, H=S/N, ITER=6, SUB=2;
const pos=new Float32Array(CNT*3), prv=new Float32Array(CNT*3), nrm=new Float32Array(CNT*3);
const inv=new Float32Array(CNT), inv0=new Float32Array(CNT);
const grow=new Float32Array(CNT), drX=new Float32Array(CNT), drZ=new Float32Array(CNT);
const restX=new Float32Array(CNT), restZ=new Float32Array(CNT), fadeA=new Float32Array(CNT);
for(let j=0;j<PN;j++) for(let i=0;i<PN;i++){
  const k=j*PN+i; restX[k]=-HALF+i*H; restZ[k]=-HALF+j*H;
  const e=Math.min(i/N,1-i/N,j/N,1-j/N);
  fadeA[k]=smooth(0.012,0.08,e);
  inv0[k]=(i===0||j===0||i===N||j===N)?0:1;
}
// Constraints: 0 = Struktur, 1 = Scherung, 2 = Biegung (Abstand 2)
const cA=[], cB=[], cL=[], cT=[];
function addC(a,b,t){ cA.push(a); cB.push(b); cT.push(t); cL.push(Math.hypot(restX[b]-restX[a], restZ[b]-restZ[a])); }
for(let j=0;j<PN;j++) for(let i=0;i<PN;i++){
  const k=j*PN+i;
  if(i<N) addC(k,k+1,0);
  if(j<N) addC(k,k+PN,0);
  if(i<N&&j<N) addC(k,k+PN+1,1);
  if(i>0&&j<N) addC(k,k+PN-1,1);
  if(i<N-1) addC(k,k+2,2);
  if(j<N-1) addC(k,k+2*PN,2);
}
const CA=Int32Array.from(cA), CB=Int32Array.from(cB), CL=Float32Array.from(cL), CT=Uint8Array.from(cT), CC=CA.length;

let seedOff=0;
function resetSheet(){
  for(let k=0;k<CNT;k++){ pos[k*3]=restX[k]; pos[k*3+1]=inv0[k]?0.001+Math.random()*0.004:0; pos[k*3+2]=restZ[k]; }
  prv.set(pos); inv.set(inv0);
}

// Voronoi-Saaten
const VMAX=160; let vSeeds=new Float32Array(VMAX*2), vK=0, vScale=-1, vSpacing=1;
const curS=new Float32Array(VMAX*2), pulse=new Float32Array(VMAX);
function makeSeeds(){
  vK=Math.min(VMAX,Math.round(5+12*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++){ vSeeds[q*2]=(rnd()*2-1)*HALF; vSeeds[q*2+1]=(rnd()*2-1)*HALF; }
  vSpacing=Math.sqrt(S*S/vK); vScale=P.scale;
}

function updateGrowth(t){
  const f=P.scale, tt=t*P.speed+seedOff, isV=P.field==='voronoi';
  if(isV){
    if(vScale!==P.scale) makeSeeds();
    const m=0.45/f;
    for(let q=0;q<vK;q++){
      curS[q*2]=vSeeds[q*2]+m*noise3(q*7.31,tt,1.7);
      curS[q*2+1]=vSeeds[q*2+1]+m*noise3(q*3.17,4.2,tt);
      pulse[q]=0.65+0.35*noise3(q*1.91,tt*2.0,8.8);
    }
  }
  const df=0.6*f, dr=P.drift;
  for(let k=0;k<CNT;k++){
    const fa=fadeA[k];
    if(fa===0){ grow[k]=0; drX[k]=0; drZ[k]=0; continue; }
    const x=restX[k], z=restZ[k]; let g;
    if(!isV){
      g=0.7*noise3(x*f,z*f,tt)+0.3*noise3(x*f*2.13+5.2,z*f*2.13-3.1,tt*1.7);
    } else {
      let d1=1e9,d2=1e9,n1=0;
      for(let q=0;q<vK;q++){ const dx=x-curS[q*2], dz=z-curS[q*2+1], d=dx*dx+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)*pulse[n1];
    }
    grow[k]=Math.max(-0.25, fa*(P.slack+P.amp*g));
    drX[k]=fa*dr*noise3(x*df+11.1,z*df,tt*1.3);
    drZ[k]=fa*dr*noise3(x*df,z*df-7.7,tt*1.3+3.0);
  }
}

function simNormals(){
  for(let j=0;j<PN;j++) for(let i=0;i<PN;i++){
    const k=j*PN+i;
    const il=(i>0?k-1:k)*3, ir=(i<N?k+1:k)*3, jd=(j>0?k-PN:k)*3, ju=(j<N?k+PN:k)*3;
    const ax=pos[ir]-pos[il], ay=pos[ir+1]-pos[il+1], az=pos[ir+2]-pos[il+2];
    const bx=pos[ju]-pos[jd], by=pos[ju+1]-pos[jd+1], bz=pos[ju+2]-pos[jd+2];
    let nx=by*az-bz*ay, ny=bz*ax-bx*az, nz=bx*ay-by*ax;
    const l=Math.hypot(nx,ny,nz)||1; nrm[k*3]=nx/l; nrm[k*3+1]=ny/l; nrm[k*3+2]=nz/l;
  }
}

function solve(){
  const kb=P.bend;
  for(let c=0;c<CC;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=CL[c]*(1+0.5*(grow[ia]+grow[ib]));
    const t=CT[c], k=t===0?1:(t===1?0.6:kb);
    const s=k*(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 j=1;j<N;j++) for(let i=1;i<N;i++){
    const k=j*PN+i, w=inv[k]; if(!w) continue;
    const a=(k-1)*3, b=(k+1)*3, c=(k-PN)*3, d=(k+PN)*3, o=k*3, s=kf*w;
    pos[o]  +=((pos[a]+pos[b]+pos[c]+pos[d])*0.25-pos[o])*s;
    pos[o+1]+=((pos[a+1]+pos[b+1]+pos[c+1]+pos[d+1])*0.25-pos[o+1])*s;
    pos[o+2]+=((pos[a+2]+pos[b+2]+pos[c+2]+pos[d+2])*0.25-pos[o+2])*s;
  }
}

function floorCollide(){
  for(let k=0;k<CNT;k++){
    const o=k*3;
    if(pos[o+1]<0){ pos[o+1]=0; prv[o]+=(pos[o]-prv[o])*0.4; prv[o+2]+=(pos[o+2]-prv[o+2])*0.4; }
  }
}

function step(dt){
  const dt2=dt*dt, damp=0.99, g=P.gravity, pr=P.pressure;
  simNormals();
  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 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+(nrm[o]*pr+drX[k])*dt2;
    pos[o+1]=y+vy+(nrm[o+1]*pr-g)*dt2;
    pos[o+2]=z+vz+(nrm[o+2]*pr+drZ[k])*dt2;
  }
  applyGrab();
  for(let it=0;it<ITER;it++) solve();
  if(P.round>0) smoothPass(P.round);
  floorCollide();
  applyGrab();
}

// Selbstkollision (Spatial Hash, Counting Sort)
const CR=H*0.8, CR2=CR*CR, CS=CR;
const GX=Math.ceil(S/CS)+3, GY=Math.ceil(1.6/CS)+3, GCELLS=GX*GX*GY;
const cellStart=new Int32Array(GCELLS+1), cursor=new Int32Array(GCELLS+1), cellOf=new Int32Array(CNT), sorted=new Int32Array(CNT);
function selfCollide(){
  cellStart.fill(0);
  for(let k=0;k<CNT;k++){
    const o=k*3;
    const cx=Math.min(GX-1,Math.max(0,Math.floor((pos[o]+HALF)/CS)+1));
    const cz=Math.min(GX-1,Math.max(0,Math.floor((pos[o+2]+HALF)/CS)+1));
    const cy=Math.min(GY-1,Math.max(0,Math.floor(pos[o+1]/CS)+1));
    const c=(cy*GX+cz)*GX+cx; cellOf[k]=c; cellStart[c+1]++;
  }
  for(let c=1;c<=GCELLS;c++) cellStart[c]+=cellStart[c-1];
  cursor.set(cellStart);
  for(let k=0;k<CNT;k++) sorted[cursor[cellOf[k]]++]=k;
  for(let k=0;k<CNT;k++){
    if(inv[k]===0) continue;
    const c=cellOf[k], cx=c%GX, cz=((c/GX)|0)%GX, cy=(c/(GX*GX))|0;
    const ki=k%PN, kj=(k/PN)|0, o=k*3;
    for(let dy=-1;dy<=1;dy++){ const y2=cy+dy; if(y2<0||y2>=GY) continue;
      for(let dz=-1;dz<=1;dz++){ const z2=cz+dz; if(z2<0||z2>=GX) continue;
        for(let dx=-1;dx<=1;dx++){ const x2=cx+dx; if(x2<0||x2>=GX) continue;
          const cc=(y2*GX+z2)*GX+x2;
          for(let m=cellStart[cc], e=cellStart[cc+1]; m<e; m++){
            const q=sorted[m]; if(q<=k) continue;
            const qi=q%PN, qj=(q/PN)|0;
            if(Math.abs(qi-ki)<=3 && Math.abs(qj-kj)<=3) continue;
            const p=q*3, 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
const grab={on:false, idx:[], w:[], off:[], target:new THREE.Vector3(), lift:0};
function startGrab(p){
  grab.idx.length=0; grab.w.length=0; grab.off.length=0;
  const R=P.grabR;
  for(let k=0;k<CNT;k++){
    if(inv0[k]===0) continue;
    const o=k*3, dx=pos[o]-p.x, dy=pos[o+1]-p.y, dz=pos[o+2]-p.z;
    const d=Math.sqrt(dx*dx+dy*dy+dz*dz);
    if(d<R){ const t=1-d/R, w=t*t*(3-2*t); grab.idx.push(k); grab.w.push(w); grab.off.push(dx,dy,dz); inv[k]=inv0[k]*(1-0.9*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;
  for(let n=0;n<grab.idx.length;n++){
    const o=grab.idx[n]*3, w=grab.w[n], s=w*0.5;
    const tx=T.x+grab.off[n*3], ty=T.y+grab.off[n*3+1]+L*w, tz=T.z+grab.off[n*3+2];
    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]=inv0[k]; grab.on=false; }

// Krümmung auf dem Sim-Gitter (Laplace · Normale), 3× geglättet
const scurv=new Float32Array(CNT), scurvT=new Float32Array(CNT);
function simCurvature(){
  simNormals();
  const ih2=1/(H*H);
  for(let j=0;j<PN;j++) for(let i=0;i<PN;i++){
    const k=j*PN+i;
    if(i===0||j===0||i===N||j===N){ scurv[k]=0; continue; }
    const o=k*3, a=o-3, b=o+3, c=o-PN*3, d=o+PN*3;
    const lx=(pos[a]+pos[b]+pos[c]+pos[d])*0.25-pos[o];
    const ly=(pos[a+1]+pos[b+1]+pos[c+1]+pos[d+1])*0.25-pos[o+1];
    const lz=(pos[a+2]+pos[b+2]+pos[c+2]+pos[d+2])*0.25-pos[o+2];
    scurv[k]=(lx*nrm[o]+ly*nrm[o+1]+lz*nrm[o+2])*ih2;
  }
  for(let pass=0;pass<3;pass++){
    scurvT.set(scurv);
    for(let j=1;j<N;j++) for(let i=1;i<N;i++){
      const k=j*PN+i;
      scurv[k]=(scurvT[k]*4+scurvT[k-1]+scurvT[k+1]+scurvT[k-PN]+scurvT[k+PN]
               +0.5*(scurvT[k-PN-1]+scurvT[k-PN+1]+scurvT[k+PN-1]+scurvT[k+PN+1]))/10;
    }
  }
}

// Stauchung pro Partikel (u = x-Richtung, v = z-Richtung) → feine Falten im Shader
const comp=new Float32Array(CNT*2);
function computeStrain(){
  for(let j=0;j<PN;j++) for(let i=0;i<PN;i++){
    const k=j*PN+i, o=k*3;
    const ku=i<N?k+1:k-1, kv=j<N?k+PN:k-PN, u=ku*3, v=kv*3;
    const du=Math.hypot(pos[u]-pos[o],pos[u+1]-pos[o+1],pos[u+2]-pos[o+2]);
    const dv=Math.hypot(pos[v]-pos[o],pos[v+1]-pos[o+1],pos[v+2]-pos[o+2]);
    const Lu=H*(1+0.5*(grow[k]+grow[ku])), Lv=H*(1+0.5*(grow[k]+grow[kv]));
    comp[k*2]=Math.min(1,Math.max(0,1-du/Lu));
    comp[k*2+1]=Math.min(1,Math.max(0,1-dv/Lv));
  }
}

// ------------------------------------------------------------------ Render-Mesh (Catmull-Rom 3x)
const UP=3, RN=N*UP, RP=RN+1, RC=RP*RP, HR=S/RN;
const ui=new Int32Array(RP*4), uw=new Float32Array(RP*4);
for(let I=0;I<RP;I++){
  const s=I/UP; let i0=Math.floor(s); if(i0>=N) i0=N-1; const t=s-i0, t2=t*t, t3=t2*t;
  const w=[(-t3+2*t2-t)/2,(3*t3-5*t2+2)/2,(-3*t3+4*t2+t)/2,(t3-t2)/2];
  for(let m=0;m<4;m++){ ui[I*4+m]=Math.min(N,Math.max(0,i0-1+m)); uw[I*4+m]=w[m]; }
}
const tmp=new Float32Array(PN*RP*3);
const rpos=new Float32Array(RC*3), rnrm=new Float32Array(RC*3), rnTmp=new Float32Array(RC*3), rcurv=new Float32Array(RC), rcurv2=new Float32Array(RC), rrest=new Float32Array(RC*2), rcomp=new Float32Array(RC*2);
for(let J=0;J<RP;J++) for(let I=0;I<RP;I++){ const k=J*RP+I; rrest[k*2]=-HALF+I*HR; rrest[k*2+1]=-HALF+J*HR; }
const idx=[];
for(let J=0;J<RN;J++) for(let I=0;I<RN;I++){
  const a=J*RP+I, b=a+1, c=a+RP, d=c+1; idx.push(a,c,b, b,c,d);
}
const geo=new THREE.BufferGeometry();
const posAttr=new THREE.BufferAttribute(rpos,3).setUsage(THREE.DynamicDrawUsage);
const nrmAttr=new THREE.BufferAttribute(rnrm,3).setUsage(THREE.DynamicDrawUsage);
const curvAttr=new THREE.BufferAttribute(rcurv,1).setUsage(THREE.DynamicDrawUsage);
const compAttr=new THREE.BufferAttribute(rcomp,2).setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position',posAttr); geo.setAttribute('normal',nrmAttr); geo.setAttribute('aCurv',curvAttr); geo.setAttribute('aComp',compAttr);
geo.setAttribute('aRest',new THREE.BufferAttribute(rrest,2));
geo.setIndex(idx);
geo.boundingSphere=new THREE.Sphere(new THREE.Vector3(0,0,0),3);

function updateRender(){
  for(let j=0;j<PN;j++) for(let I=0;I<RP;I++){
    let x=0,y=0,z=0;
    for(let m=0;m<4;m++){ const s=(j*PN+ui[I*4+m])*3, w=uw[I*4+m]; x+=pos[s]*w; y+=pos[s+1]*w; z+=pos[s+2]*w; }
    const o=(j*RP+I)*3; tmp[o]=x; tmp[o+1]=y; tmp[o+2]=z;
  }
  for(let J=0;J<RP;J++) for(let I=0;I<RP;I++){
    let x=0,y=0,z=0;
    for(let m=0;m<4;m++){ const s=(ui[J*4+m]*RP+I)*3, w=uw[J*4+m]; x+=tmp[s]*w; y+=tmp[s+1]*w; z+=tmp[s+2]*w; }
    const o=(J*RP+I)*3; rpos[o]=x; rpos[o+1]=y; rpos[o+2]=z;
  }
  // Stauchung (2 Kanäle) gleich interpolieren
  for(let j=0;j<PN;j++) for(let I=0;I<RP;I++){
    let a=0,b=0;
    for(let m=0;m<4;m++){ const s=(j*PN+ui[I*4+m])*2, w=uw[I*4+m]; a+=comp[s]*w; b+=comp[s+1]*w; }
    const o=(j*RP+I)*2; tmp[o]=a; tmp[o+1]=b;
  }
  for(let J=0;J<RP;J++) for(let I=0;I<RP;I++){
    let a=0,b=0;
    for(let m=0;m<4;m++){ const s=(ui[J*4+m]*RP+I)*2, w=uw[J*4+m]; a+=tmp[s]*w; b+=tmp[s+1]*w; }
    const o=(J*RP+I)*2; rcomp[o]=Math.max(0,a); rcomp[o+1]=Math.max(0,b);
  }
  // Krümmung: auf dem Sim-Gitter berechnet + geglättet, dann weich hochinterpoliert
  // (die 2. Ableitung des Catmull-Rom-Meshes springt an den Gitterknoten → Quadrat-Artefakte)
  simCurvature();
  for(let j=0;j<PN;j++) for(let I=0;I<RP;I++){
    let a=0;
    for(let m=0;m<4;m++) a+=scurv[j*PN+ui[I*4+m]]*uw[I*4+m];
    tmp[j*RP+I]=a;
  }
  for(let J=0;J<RP;J++) for(let I=0;I<RP;I++){
    let a=0;
    for(let m=0;m<4;m++) a+=tmp[ui[J*4+m]*RP+I]*uw[J*4+m];
    rcurv[J*RP+I]=a;
  }
  for(let J=0;J<RP;J++) for(let I=0;I<RP;I++){
    const k=J*RP+I, o=k*3;
    const il=(I>0?k-1:k)*3, ir=(I<RN?k+1:k)*3, jd=(J>0?k-RP:k)*3, ju=(J<RN?k+RP:k)*3;
    const ax=rpos[ir]-rpos[il], ay=rpos[ir+1]-rpos[il+1], az=rpos[ir+2]-rpos[il+2];
    const bx=rpos[ju]-rpos[jd], by=rpos[ju+1]-rpos[jd+1], bz=rpos[ju+2]-rpos[jd+2];
    let nx=by*az-bz*ay, ny=bz*ax-bx*az, nz=bx*ay-by*ax;
    const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
    rnrm[o]=nx; rnrm[o+1]=ny; rnrm[o+2]=nz;
  }
  // Normalen glätten (gegen Facetten/Treppen im Licht)
  for(let pass=0;pass<P.nSmooth;pass++){
    rnTmp.set(rnrm);
    for(let J=1;J<RN;J++) for(let I=1;I<RN;I++){
      const o=(J*RP+I)*3, a=o-3, b=o+3, c=o-RP*3, d=o+RP*3;
      let nx=rnTmp[o]*2+rnTmp[a]+rnTmp[b]+rnTmp[c]+rnTmp[d];
      let ny=rnTmp[o+1]*2+rnTmp[a+1]+rnTmp[b+1]+rnTmp[c+1]+rnTmp[d+1];
      let nz=rnTmp[o+2]*2+rnTmp[a+2]+rnTmp[b+2]+rnTmp[c+2]+rnTmp[d+2];
      const l=Math.hypot(nx,ny,nz)||1; rnrm[o]=nx/l; rnrm[o+1]=ny/l; rnrm[o+2]=nz/l;
    }
  }
  posAttr.needsUpdate=true; nrmAttr.needsUpdate=true; curvAttr.needsUpdate=true; compAttr.needsUpdate=true;
}

// ------------------------------------------------------------------ Haare
const HMAX=40000;
const hI=new Int32Array(HMAX), hFx=new Float32Array(HMAX), hFz=new Float32Array(HMAX), hCos=new Float32Array(HMAX), hSin=new Float32Array(HMAX), hL=new Float32Array(HMAX), hTilt=new Float32Array(HMAX);
let hPlaced=0;
// Packdichte aus Perlin-Feld: Rejection Sampling → Büschel, kahle Stellen, Übergänge
function placeHair(){
  const cl=P.cluster, cs=P.clusterSize, so=seedOff;
  let h=0, tries=0;
  while(h<HMAX && tries<HMAX*60){
    tries++;
    const x=(Math.random()*2-1)*1.05, z=(Math.random()*2-1)*1.05;
    let d=0.5+0.5*(0.7*noise3(x*cs,z*cs,so+21.3)+0.3*noise3(x*cs*2.7+4.1,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 fx=(x+HALF)/HR, fz=(z+HALF)/HR, I=Math.floor(fx), J=Math.floor(fz);
    hI[h]=J*RP+I; hFx[h]=fx-I; hFz[h]=fz-J;
    const a=noise3(x*1.3,z*1.3,so+5.5)*3.0+(Math.random()-0.5)*0.9;
    hCos[h]=Math.cos(a); hSin[h]=Math.sin(a);
    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);
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);
hairGeo.setAttribute('position',hpAttr); hairGeo.setAttribute('aN',hnAttr); hairGeo.setAttribute('aT',new THREE.BufferAttribute(ht,1));
hairGeo.boundingSphere=new THREE.Sphere(new THREE.Vector3(0,0,0),3);

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 tl=Math.min(1.5,Math.max(0,P.hairTilt+hTilt[h])), ct=Math.cos(tl), st=Math.sin(tl);
    const i00=hI[h], fx=hFx[h], fz=hFz[h];
    const a=i00*3, b=a+3, c=a+RP*3, d=c+3;
    const w00=(1-fx)*(1-fz), w10=fx*(1-fz), w01=(1-fx)*fz, w11=fx*fz;
    let bx=rpos[a]*w00+rpos[b]*w10+rpos[c]*w01+rpos[d]*w11;
    let by=rpos[a+1]*w00+rpos[b+1]*w10+rpos[c+1]*w01+rpos[d+1]*w11;
    let bz=rpos[a+2]*w00+rpos[b+2]*w10+rpos[c+2]*w01+rpos[d+2]*w11;
    let nx=rnrm[a]*w00+rnrm[b]*w10+rnrm[c]*w01+rnrm[d]*w11;
    let ny=rnrm[a+1]*w00+rnrm[b+1]*w10+rnrm[c+1]*w01+rnrm[d+1]*w11;
    let nz=rnrm[a+2]*w00+rnrm[b+2]*w10+rnrm[c+2]*w01+rnrm[d+2]*w11;
    let l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
    let tx=rpos[b]-rpos[a], ty=rpos[b+1]-rpos[a+1], tz=rpos[b+2]-rpos[a+2];
    const dn=tx*nx+ty*ny+tz*nz; tx-=nx*dn; ty-=ny*dn; tz-=nz*dn;
    l=Math.hypot(tx,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 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;
  }
  hairGeo.setDrawRange(0,cnt*2);
  hpAttr.needsUpdate=true; hnAttr.needsUpdate=true;
}

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

const skinFrag = /* glsl */`
#include <common>
#include <packing>
#include <shadowmap_pars_fragment>
uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uCamPos;
uniform vec3 uSkin;
uniform vec3 uDeep;
uniform float uScatter, uTrans, uRough, uSpec, uOil, uPore, uPoreScale, uFine, uFineFreq, uFineIrr, uCompGain, uCavity, uCurvScale, uAmbient, uShadowOn;
varying vec3 vWorldPos;
varying vec3 vNormalW;
varying vec2 vRest;
varying float vCurv;
varying vec2 vComp;

vec2 skHash2(vec2 p){ p = vec2(dot(p, vec2(127.1,311.7)), dot(p, vec2(269.5,183.3))); return fract(sin(p)*43758.5453); }
// Simplex 2D (Gradient-Noise, keine quadratischen Gitterartefakte)
vec3 skMod289(vec3 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec2 skMod289(vec2 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec3 skPermute(vec3 x){ return skMod289(((x*34.0)+1.0)*x); }
float skSnoise(vec2 v){
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1;
  i = skMod289(i);
  vec3 p = skPermute(skPermute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m; m = m*m;
  vec3 x = 2.0*fract(p*C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314*(a0*a0 + h*h);
  vec3 g; g.x = a0.x*x0.x + h.x*x0.y; g.yz = a0.yz*x12.xz + h.yz*x12.yw;
  return 130.0*dot(m, g);
}
float skCell(vec2 p){
  vec2 i = floor(p), f = fract(p); float d = 8.0;
  for(int y=-1; y<=1; y++) for(int x=-1; x<=1; x++){
    vec2 g = vec2(float(x), float(y));
    vec2 o = 0.1 + 0.8*skHash2(i+g);
    vec2 r = g + o - f; d = min(d, dot(r,r));
  }
  return sqrt(d);
}

float skinHeight(vec2 r, vec2 comp, float valley, out float poreMask){
  // Poren
  float ps = uPoreScale;
  float fw = fwidth(r.x*ps) + fwidth(r.y*ps);
  float aa = 1.0 - smoothstep(0.25, 0.9, fw);
  float c = skCell(r*ps);
  float pore = 1.0 - smoothstep(0.05, 0.24, c);
  poreMask = pore*aa;
  float h = -pore*uPore*aa*0.6/ps;
  h += skSnoise(r*ps*0.35)*0.18*uPore*aa/ps;

  // feine Falten: runde Rillen quer zur Stauchrichtung, organisch verzogen
  float fq = uFineFreq;
  float irr = uFineIrr;
  vec2 w = r + vec2(skSnoise(r*9.0), skSnoise(r*9.0 + 19.1))*(5.0/fq)*(1.0 + 2.0*irr)
             + vec2(skSnoise(r*31.0 + 4.0), skSnoise(r*31.0 - 8.0))*(1.5/fq)*irr;
  float ph = skSnoise(r*5.0 + 3.7)*2.4 + (skSnoise(r*13.0)*3.0 + skSnoise(r*29.0 + 5.0)*1.2)*irr;
  float aaU = 1.0 - smoothstep(0.6, 1.6, fwidth(w.x*fq));
  float aaV = 1.0 - smoothstep(0.6, 1.6, fwidth(w.y*fq));
  float gu = pow(0.5 + 0.5*sin(w.x*fq + ph), 3.0);
  float gv = pow(0.5 + 0.5*sin(w.y*fq*0.93 + ph*1.3 + 1.7), 3.0);
  // Falten auf endliche Länge zerstückeln
  float su = mix(1.0, smoothstep(-0.35, 0.45, skSnoise(vec2(w.x*fq*0.2, w.y*fq*0.07) + 2.0)), irr);
  float sv = mix(1.0, smoothstep(-0.35, 0.45, skSnoise(vec2(w.x*fq*0.07, w.y*fq*0.2) + 9.0)), irr);
  // zweite, feinere Lage diagonal
  vec2 dg = vec2(0.7071, 0.7071);
  float f2 = fq*1.9;
  float 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(vec2(td*0.08, dot(w, vec2(-dg.y, dg.x))*f2*0.25) + 5.0));
  float patchA = smoothstep(-0.4, 0.7, skSnoise(r*2.2 + 11.0));
  float base = 0.25*patchA + valley*0.6;
  float au = base + clamp(comp.x*uCompGain, 0.0, 1.0);
  float av = base + clamp(comp.y*uCompGain, 0.0, 1.0);
  h -= (gu*au*su*aaU + gv*av*sv*aaV + gd*(au + av)*0.3*irr*aaD*(fq/f2))*uFine*0.35/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); }

void main(){
  vec3 N = normalize(vNormalW);
  if(!gl_FrontFacing) N = -N;
  vec3 V = normalize(uCamPos - vWorldPos);
  vec3 L = normalize(uLightDir);
  float cav = clamp(vCurv*uCurvScale, -1.0, 1.0);
  float valley = max(cav, 0.0), ridge = max(-cav, 0.0);
  float poreMask;
  float h = skinHeight(vRest, vComp, valley, poreMask);
  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 occ = clamp(1.0 - smoothstep(0.0, 1.0, valley)*uCavity, 0.2, 1.0);

  // Diffus: geglättete Normale + Wrap-Lighting pro Kanal (Rot streut am weitesten)
  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 w = uScatter*vec3(0.6, 0.25, 0.12);
  vec3 diff = clamp((nl + w)/(1.0 + w), 0.0, 1.0);
  vec3 sh = mix(vec3(shadow), vec3(sqrt(shadow), pow(shadow, 0.8), shadow), uScatter);

  // 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: zwei GGX-Lobes (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);

  // Ölfilm: glättet die Mikrostruktur, sehr scharfe Lobe
  vec3 No = normalize(mix(Nb, N, 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;

  // Umgebungsreflexion: Softbox um die Lichtrichtung + heller Studiohimmel
  vec3 R = reflect(-V, No);
  float FvO = 0.028 + 0.972*pow(1.0 - nvO, 5.0);
  float softbox = smoothstep(0.86, 0.97, dot(R, L));
  float sky = smoothstep(0.1, 0.95, R.y);
  vec3 envSpec = (uLightColor*softbox*0.35*mix(0.25, 1.0, shadow) + vec3(sky*uAmbient*1.5))*FvO*(0.25 + uOil)*uSpec*occ;

  vec3 amb = mix(vec3(0.55, 0.5, 0.48), vec3(1.0), N.y*0.5 + 0.5)*uAmbient;

  vec3 col = uLightColor*(alb*diff*sh*occ + vec3(spec*uSpec*shadow))
           + alb*amb*occ
           + trans*uLightColor
           + envSpec;
  col = skACES(col);
  col = pow(col, vec3(1.0/2.2));
  gl_FragColor = vec4(col, 1.0);
}`;

const hairVert = /* glsl */`
attribute float aT;
attribute vec3 aN;
varying float vT;
varying vec3 vN;
void main(){
  vT = aT; vN = aN;
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}`;
const hairFrag = /* glsl */`
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform float uLightInt;
uniform float uAlpha;
varying float vT;
varying vec3 vN;
void main(){
  float l = clamp((dot(normalize(vN), normalize(uLightDir)) + 0.4)/1.4, 0.0, 1.0);
  vec3 c = uColor*(0.25 + 0.55*l*uLightInt)*mix(0.85, 1.25, vT);
  c = 1.0 - exp(-c*1.4);
  c = pow(c, vec3(1.0/2.2));
  gl_FragColor = vec4(c, uAlpha*(1.0 - 0.8*vT));
}`;

// ------------------------------------------------------------------ Szene
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));
renderer.setClearColor(0xffffff,1);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.VSMShadowMap;
document.getElementById('wrap').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,2.75,0.75);

const light=new THREE.DirectionalLight(0xffffff,1);
light.castShadow=true;
light.shadow.mapSize.set(2048,2048);
{ const sc=light.shadow.camera; sc.left=-1.6; sc.right=1.6; sc.top=1.6; sc.bottom=-1.6; sc.near=2; sc.far=10; sc.updateProjectionMatrix(); }
light.shadow.bias=-0.0002; light.shadow.normalBias=0.004;
light.shadow.radius=P.shadowSoft; light.shadow.blurSamples=16;
scene.add(light); scene.add(light.target);

const skinUniforms=THREE.UniformsUtils.merge([THREE.UniformsLib.lights,{
  uLightDir:{value:new THREE.Vector3(0,1,0)}, uLightColor:{value:new THREE.Color(1,1,1)}, uCamPos:{value:new THREE.Vector3()},
  uSkin:{value:new THREE.Color()}, uDeep:{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:3}, uCavity:{value:0}, uCurvScale:{value:0.12},
  uAmbient:{value:0.3}, uShadowOn:{value:1}
}]);
const skinMat=new THREE.ShaderMaterial({uniforms:skinUniforms, vertexShader:skinVert, fragmentShader:skinFrag, lights:true, side:THREE.DoubleSide});
const skin=new THREE.Mesh(geo,skinMat);
skin.castShadow=true; skin.receiveShadow=true; skin.frustumCulled=false;
scene.add(skin);

const hairMat=new THREE.ShaderMaterial({
  uniforms:{uColor:{value:new THREE.Color()}, uLightDir:{value:new THREE.Vector3(0,1,0)}, uLightInt:{value:1}, uAlpha:{value:0.5}},
  vertexShader:hairVert, fragmentShader:hairFrag, transparent:true, depthWrite:false
});
const hairLines=new THREE.LineSegments(hairGeo,hairMat);
hairLines.frustumCulled=false;
scene.add(hairLines);

// Maus-Greifen (vor OrbitControls registriert, damit es Vorrang hat)
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;
  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);
  startGrab(hit.point);
  cv.style.cursor='grabbing';
});
cv.addEventListener('pointermove',e=>{
  if(!grab.on) return;
  setNDC(e); ray.setFromCamera(ndc,camera);
  if(ray.ray.intersectPlane(dragPlane,tmpV)) grab.target.copy(tmpV);
});
const release=()=>{ if(grab.on) endGrab(); controls.enabled=true; cv.style.cursor='grab'; };
cv.addEventListener('pointerup',release);
cv.addEventListener('pointercancel',release);

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

function resize(){
  const st=document.getElementById('stage').getBoundingClientRect();
  const wide=window.innerWidth>760;
  const avW=st.width-(wide?48:32), avH=(wide?st.height:window.innerHeight)-64;
  const sz=Math.floor(Math.max(220,Math.min(avW,avH)));
  renderer.setSize(sz,sz);
}
window.addEventListener('resize',resize);
resize();

// ------------------------------------------------------------------ UI
const UI=[
  ['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,6,0.05],
  ['gravity','Schwerkraft','range',0,8,0.05],
  ['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','Greifen'],
  ['grabR','Radius','range',0.03,0.4,0.005],
  ['lift','Anheben','range',0,0.5,0.005],
  ['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,10,0.1],
  ['cavity','Faltenschatten','range',0,1,0.01],
  ['nSmooth','Mesh-Glättung','range',0,4,1],
  ['h','Haare'],
  ['hairCount','Anzahl','range',0,40000,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'],
  ['lightAz','Azimut','range',-180,180,1],
  ['lightEl','Höhe','range',5,90,1],
  ['lightInt','Intensität','range',0,6,0.05],
  ['ambient','Umgebung','range',0,1,0.01],
  ['shadows','Schatten','check'],
  ['shadowSoft','Schattenweichh.','range',1,16,0.5],
];
const ON_CHANGE={cluster:placeHair, clusterSize:placeHair};
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 [key,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[key];
    const st=+d[5];
    const fmt=()=>{ val.textContent = st>=1 ? String(Math.round(P[key])) : (+P[key]).toFixed(st<0.01?3:2); };
    fmt();
    inp.addEventListener('input',()=>{ P[key]=parseFloat(inp.value); fmt(); });
    if(ON_CHANGE[key]) inp.addEventListener('change',ON_CHANGE[key]);
  } else if(type==='check'){
    inp=document.createElement('input'); inp.type='checkbox'; inp.checked=P[key];
    inp.addEventListener('change',()=>{ P[key]=inp.checked; });
  } else if(type==='color'){
    inp=document.createElement('input'); inp.type='color'; inp.value=P[key];
    inp.addEventListener('input',()=>{ P[key]=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[key];
    inp.addEventListener('change',()=>{ P[key]=inp.value; });
  }
  row.appendChild(inp); row.appendChild(val); ctl.appendChild(row);
}
document.getElementById('reset').onclick=()=>{ endGrab(); resetSheet(); };
document.getElementById('reroll').onclick=()=>{ seedOff=Math.random()*100; vScale=-1; endGrab(); resetSheet(); placeHair(); };

// ------------------------------------------------------------------ Loop
const lightDir=new THREE.Vector3();
function updateUniforms(){
  const az=THREE.MathUtils.degToRad(P.lightAz), el=THREE.MathUtils.degToRad(P.lightEl);
  lightDir.set(Math.cos(el)*Math.sin(az), Math.sin(el), Math.cos(el)*Math.cos(az)).normalize();
  light.position.copy(lightDir).multiplyScalar(6);
  const u=skinUniforms;
  u.uLightDir.value.copy(lightDir);
  u.uLightColor.value.setRGB(1.0,0.97,0.93).multiplyScalar(P.lightInt);
  u.uCamPos.value.copy(camera.position);
  u.uSkin.value.set(P.skin); u.uDeep.value.set(P.deep);
  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.uShadowOn.value=P.shadows?1:0;
  light.shadow.radius=P.shadowSoft;
  hairMat.uniforms.uColor.value.set(P.hairColor);
  hairMat.uniforms.uLightDir.value.copy(lightDir);
  hairMat.uniforms.uLightInt.value=P.lightInt;
  hairMat.uniforms.uAlpha.value=P.hairAlpha;
}

resetSheet();
placeHair();
for(let i=0;i<80;i++){ updateGrowth(0); step(1/60/SUB); step(1/60/SUB); }

let last=performance.now(), simTime=0;
function frame(now){
  const dtReal=Math.min((now-last)/1000,0.05); last=now;
  if(P.animate) simTime+=dtReal;
  if(grab.on) grab.lift+=(P.lift-grab.lift)*0.08;
  updateGrowth(simTime);
  for(let s=0;s<SUB;s++) step(1/60/SUB);
  if(P.selfCol) selfCollide();
  computeStrain();
  updateRender();
  updateHair();
  updateUniforms();
  controls.update();
  renderer.render(scene,camera);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>