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>