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>