index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Medusoid – spekulativer Quallen-Generator</title>
<style>
:root{--ink:#000;--mut:#8c8c8c;--line:#e8e8e8;}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--ink);
font:12px/1.45 "Helvetica Neue",Helvetica,Arial,sans-serif;overflow:hidden}
#app{display:flex;height:100%}
#stage{flex:1;position:relative;min-width:0;background:#2d3033}
#stage canvas{display:block;cursor:grab}
#stage canvas:active{cursor:grabbing}
#hint{position:absolute;left:16px;bottom:14px;color:#9aa0a6;pointer-events:none}
#status{position:absolute;left:16px;top:14px;color:#c9cdd1;pointer-events:none}
#panel{width:300px;border-left:1px solid var(--line);overflow-y:auto;background:#fff}
#head{padding:14px 14px 12px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:2}
.brand{font-size:15px;letter-spacing:.01em;margin-bottom:10px}
.brand span{color:var(--mut);font-size:12px;margin-left:4px}
.btns{display:flex;flex-wrap:wrap;gap:5px}
button{font:inherit;background:#fff;color:var(--ink);border:1px solid #d9d9d9;border-radius:2px;
padding:4px 9px;cursor:pointer}
button:hover{border-color:#000}
button:focus-visible,summary:focus-visible,input:focus-visible{outline:1px solid #000;outline-offset:2px}
.seedrow{display:flex;align-items:center;gap:8px;margin-top:10px;color:var(--mut)}
.seedrow input{width:84px;font:inherit;border:1px solid #d9d9d9;border-radius:2px;padding:3px 6px;color:var(--ink);background:#fff}
details{border-bottom:1px solid var(--line)}
summary{cursor:pointer;padding:10px 14px;list-style:none;font-size:13px}
summary::-webkit-details-marker{display:none}
summary::after{content:'+';float:right;color:var(--mut)}
details[open] summary::after{content:'–'}
details[open]{padding-bottom:8px}
.row{display:grid;grid-template-columns:1fr auto;gap:1px 8px;padding:4px 14px;align-items:center}
.row .val{color:var(--mut);font-variant-numeric:tabular-nums}
.row.inline{padding-top:6px;padding-bottom:6px}
.row input[type=range]{grid-column:1/3;width:100%;margin:0;height:16px;background:transparent;-webkit-appearance:none;appearance:none}
.row input[type=range]::-webkit-slider-runnable-track{height:1px;background:#cfcfcf}
.row input[type=range]::-moz-range-track{height:1px;background:#cfcfcf}
.row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:#000;margin-top:-4px;border:0}
.row input[type=range]::-moz-range-thumb{width:9px;height:9px;border-radius:50%;background:#000;border:0}
.row input[type=color]{width:34px;height:18px;border:1px solid #d9d9d9;border-radius:2px;padding:0;background:#fff}
.row input[type=checkbox]{accent-color:#000;margin:0}
@media (max-width:760px){
#app{flex-direction:column}
#panel{width:auto;height:46vh;border-left:0;border-top:1px solid var(--line)}
}
</style>
</head>
<body>
<div id="app">
<div id="stage">
<div id="status"></div>
<div id="hint">Ziehen dreht, Scrollen zoomt, Leertaste pausiert</div>
</div>
<aside id="panel">
<div id="head">
<div class="brand">Medusoid<span>v0.4</span></div>
<div class="btns">
<button id="bRand">Würfeln</button>
<button id="bMut">Mutieren</button>
<button id="bReset">Zurücksetzen</button>
<button id="bPause">Pause</button>
<button id="bCopy">Parameter kopieren</button>
<button id="bLoad">Parameter laden</button>
</div>
<div class="seedrow"><label for="seed">Seed</label><input id="seed" type="number" min="1" max="99999" step="1"></div>
</div>
<div id="groups"></div>
</aside>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';
/* ------------------------------------------------------------------ */
/* Hilfsfunktionen */
/* ------------------------------------------------------------------ */
const TAU = Math.PI * 2;
const clamp = (x,a,b) => x < a ? a : (x > b ? b : x);
const lerp = (a,b,t) => a + (b - a) * t;
function sstep(a,b,x){ x = clamp((x-a)/(b-a),0,1); return x*x*(3-2*x); }
function sgnpow(x,p){ return (x < 0 ? -1 : 1) * Math.pow(Math.abs(x), p); }
function mulberry32(a){
return function(){
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/* Perlin-Noise 3D (Ken Perlin, improved) mit Seed */
function makeNoise(seed){
const rnd = mulberry32(seed);
const perm = new Uint8Array(256);
for(let i=0;i<256;i++) perm[i] = i;
for(let i=255;i>0;i--){ const j = Math.floor(rnd()*(i+1)); const t = perm[i]; perm[i] = perm[j]; perm[j] = t; }
const p = new Uint8Array(512);
for(let i=0;i<512;i++) p[i] = perm[i & 255];
const fade = t => t*t*t*(t*(t*6-15)+10);
const mix = (a,b,t) => a + t*(b-a);
function grad(h,x,y,z){
h &= 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
return function(x,y,z){
let X = Math.floor(x), Y = Math.floor(y), Z = Math.floor(z);
x -= X; y -= Y; z -= Z;
X &= 255; Y &= 255; Z &= 255;
const u = fade(x), v = fade(y), w = fade(z);
const A = p[X]+Y, AA = p[A]+Z, AB = p[A+1]+Z;
const B = p[X+1]+Y, BA = p[B]+Z, BB = p[B+1]+Z;
return mix(
mix(mix(grad(p[AA],x,y,z), grad(p[BA],x-1,y,z), u),
mix(grad(p[AB],x,y-1,z), grad(p[BB],x-1,y-1,z), u), v),
mix(mix(grad(p[AA+1],x,y,z-1), grad(p[BA+1],x-1,y,z-1), u),
mix(grad(p[AB+1],x,y-1,z-1), grad(p[BB+1],x-1,y-1,z-1), u), v), w);
};
}
/* ------------------------------------------------------------------ */
/* Parameter-Schema (daraus wird die Oberfläche erzeugt) */
/* rr = Bereich für "Würfeln", p0 = Chance auf 0, rb = Neuaufbau */
/* ------------------------------------------------------------------ */
const NUM = (k,l,min,max,v,o) => Object.assign({k:k,l:l,min:min,max:max,v:v,step:(o&&o.int)?1:(max-min)/400}, o||{});
const BOOL = (k,l,v) => ({k:k,l:l,v:v,type:'bool'});
const COL = (k,l,v) => ({k:k,l:l,v:v,type:'color'});
const SCHEMA = [
{t:'Schirm: Grundform', open:true, items:[
NUM('bellRadius','Radius',0.3,2.2,0.6,{rr:[0.4,0.95]}),
NUM('bellHeight','Höhe',0.2,3.5,2.0,{rr:[1.3,3.0]}),
NUM('capsule','Kapselform (0 = Kuppel, 1 = Kapsel)',0,1,0.9,{rr:[0.5,1]}),
NUM('capFrac','Kapsel: Höhe der Kuppel',0.15,0.95,0.3,{rr:[0.2,0.6]}),
NUM('wallTaper','Kapsel: Verjüngung der Wand',-0.6,0.6,0.08,{rr:[-0.2,0.3]}),
NUM('coverage','Umgriff (nur Kuppelform)',0.7,1.35,1.0,{rr:[0.85,1.2]}),
NUM('domePower','Profil (zylindrisch bis flach)',0.3,3,1.0,{rr:[0.6,2.0]}),
NUM('shoulder','Schulter (kantig bis konisch)',0.4,1.8,1.0,{rr:[0.6,1.4]}),
NUM('flare','Randkrempe',-0.4,1,0.1,{rr:[-0.1,0.5]}),
NUM('curl','Rand heben / senken',-0.8,0.8,0,{rr:[-0.3,0.4]}),
NUM('lobeCount','Randlappen',0,16,8,{int:1,rr:[0,14]}),
NUM('lobeDepth','Lappentiefe',0,1.2,0.2,{rr:[0,0.5]}),
NUM('ribCount','Rippen',0,24,8,{int:1,rr:[3,16]}),
NUM('ribDepth','Rippentiefe',0,0.5,0,{rr:[0.05,0.25],p0:0.5}),
NUM('aniso','Elliptizität',-0.5,0.5,0,{rr:[-0.25,0.25]}),
NUM('skew','Randneigung',-0.8,0.8,0,{rr:[-0.3,0.3]}),
NUM('shellThick','Wandstärke',0.02,0.5,0.12,{rr:[0.06,0.22]}),
NUM('bellRings','Auflösung Ringe',5,160,44,{int:1,rb:'bell'}),
NUM('bellSegs','Auflösung Segmente',6,256,112,{int:1,rb:'bell'})
]},
{t:'Schichten und Krausen', open:true, items:[
NUM('layerCount','Zusatzschirme (Schichten)',0,4,1,{int:1,rb:'layers',rr:[0,3]}),
NUM('layerScale','Schicht: Radius je Stufe',0.4,1.3,0.74,{rr:[0.55,0.92]}),
NUM('layerHeight','Schicht: Höhe je Stufe',0.4,1.4,0.85,{rr:[0.6,1.1]}),
NUM('layerDrop','Schicht: Absenkung je Stufe',-0.5,0.8,0.12,{rr:[-0.1,0.35]}),
NUM('layerRot','Schicht: Verdrehung je Stufe',0,3.2,0.4,{rr:[0,3]}),
NUM('layerLag','Schicht: Pulsverzögerung',0,0.4,0.12,{rr:[0.03,0.3]}),
NUM('layerAlpha','Schicht: Deckkraft',0,0.9,0.32),
NUM('frillCount','Krausen-Bänder',0,3,2,{int:1,rb:'frills',rr:[0,3]}),
NUM('frillRes','Krausen: Auflösung',24,256,128,{int:1,rb:'frills'}),
NUM('frillLen','Krausen: Länge',0.05,1.5,0.45,{rr:[0.15,0.9]}),
NUM('frillFlare','Krausen: Ausstellung',0,1.5,0.6,{rr:[0.2,1.1]}),
NUM('frillDroop','Krausen: Herabhängen',0,1.5,0.7,{rr:[0.3,1.2]}),
NUM('frillAmp','Krausen: Faltenhöhe',0,1,0.35,{rr:[0.1,0.7]}),
NUM('frillFreq','Krausen: Falten (grob)',2,60,16,{int:1,rr:[6,40]}),
NUM('frillFreq2','Krausen: Falten (fein)',2,90,37,{int:1,rr:[20,70]}),
NUM('frillTwist','Krausen: Verdrehung',0,4,1.2,{rr:[0,3]}),
NUM('frillSpeed','Krausen: Wellentempo',0,2,0.3,{rr:[0.1,0.8]}),
NUM('frillFlow','Krausen: Flowfield',0,2,0.8,{rr:[0.3,1.5]}),
NUM('frillAlpha','Krausen: Deckkraft',0,0.9,0.4)
]},
{t:'Mutation (spekulativ)', items:[
NUM('lumpAmp','Wucherung: Stärke',0,0.6,0.05,{rr:[0.02,0.3],p0:0.25}),
NUM('lumpFreq','Wucherung: Frequenz',0.3,4,1.4,{rr:[0.5,3]}),
NUM('spikeAmp','Auswüchse: Stärke',0,0.8,0,{rr:[0.1,0.5],p0:0.6}),
NUM('spikeFreq','Auswüchse: Frequenz',0.5,6,2.5,{rr:[1,5]}),
NUM('spikeThr','Auswüchse: Schwelle',0,0.6,0.3,{rr:[0.1,0.5]})
]},
{t:'Puls und Bewegung', open:true, items:[
NUM('pulseRate','Pulsrate',0.1,2.5,0.5,{rr:[0.25,1.1]}),
NUM('pulseAmp','Kontraktion',0,0.7,0.28,{rr:[0.15,0.5]}),
NUM('pulseAsym','Asymmetrie (schnell zusammen, langsam auf)',0.12,0.7,0.3,{rr:[0.18,0.5]}),
NUM('pulseLag','Wellenlauf Apex zu Rand',0,0.6,0.2,{rr:[0,0.4]}),
NUM('pulseElastic','Nachschwingen',0,1,0.35,{rr:[0,0.8]}),
NUM('pulseIrr','Unregelmäßigkeit',0,1,0.2,{rr:[0,0.6]}),
NUM('flutterAmp','Randflattern: Stärke',0,1,0.35,{rr:[0.1,0.8]}),
NUM('flutterCount','Randflattern: Wellen',0,24,7,{int:1,rr:[3,16]}),
NUM('flutterSpeed','Randflattern: Tempo',0,2,0.35,{rr:[0.1,0.9]}),
NUM('thrust','Schub',0,4,1.6,{rr:[0.8,2.6]}),
NUM('bodyTilt','Neigung des Körpers',0,1,0.18,{rr:[0.05,0.5]}),
NUM('drift','Treiben im Flowfield',0,2,0.5,{rr:[0.1,1.2]})
]},
{t:'Schwimmen und Kamera', open:true, items:[
NUM('swimYaw','Schwimmrichtung: Seite (Grad)',-180,180,0,{rr:[-180,180]}),
NUM('swimElev','Schwimmrichtung: Steigung (Grad)',-80,80,30,{rr:[-30,60]}),
NUM('steer','Richtungswechsel (Wandern)',0,1,0.3,{rr:[0.1,0.7]}),
NUM('turnRate','Wendigkeit',0.1,4,1.2,{rr:[0.6,2.5]}),
BOOL('camFollow','Kamera folgt der Qualle',true),
NUM('camSmooth','Kamera: Trägheit (Sekunden)',0.1,4,1.3),
NUM('snowAmount','Schwebeteilchen: Menge',0,1,0.5),
NUM('snowSize','Schwebeteilchen: Größe',0.01,0.15,0.05)
]},
{t:'Flowfield (Perlin 3D)', items:[
NUM('flowStrength','Stärke',0,2,0.8,{rr:[0.4,1.4]}),
NUM('flowScale','Skala',0.1,2,0.4,{rr:[0.2,0.9]}),
NUM('flowSpeed','Zeitgeschwindigkeit',0,2,0.3,{rr:[0.1,0.7]}),
NUM('flowOct','Oktaven',1,3,2,{int:1}),
NUM('flowUp','Auftrieb im Feld',-0.5,0.5,0),
NUM('flowSwirl','Wirbel um die Achse',-1,1,0,{rr:[-0.4,0.4]}),
NUM('bellFlow','Einfluss auf den Schirm',0,1.5,0.6,{rr:[0.2,1.0]}),
BOOL('showField','Feld anzeigen',false)
]},
{t:'Randtentakel', open:true, items:[
NUM('tentCount','Anzahl',0,120,26,{int:1,rb:'tent',rr:[8,60]}),
NUM('tentSeg','Segmente',4,100,40,{int:1,rb:'tent'}),
NUM('tentLen','Länge',0.2,6,2.6,{rr:[1.2,4.5]}),
NUM('tentLenVar','Längenvarianz',0,1,0.35,{rr:[0,0.7]}),
NUM('tentAlt','Wechsel lang / kurz',0,0.8,0,{rr:[0.2,0.6],p0:0.7}),
NUM('tentThick','Dicke',0.004,0.08,0.02,{rr:[0.008,0.04]}),
NUM('tentTaper','Verjüngung',0,1,0.75,{rr:[0.4,0.95]}),
NUM('tentSides','Seiten (Querschnitt)',3,12,6,{int:1,rb:'tent'}),
NUM('tentRingT','Ansatz am Schirm',0.75,1,0.97),
NUM('tentSpread','Spreizung nach außen',0,1.5,0.5,{rr:[0,1.2]}),
NUM('tentCluster','Bündelung an Lappen',0,1,0,{rr:[0.3,0.9],p0:0.5}),
NUM('tentStiff','Steifigkeit',0,0.6,0.18,{rr:[0.08,0.3]}),
NUM('tentFlow','Flowfield-Einfluss',0,3,0.6,{rr:[0.2,1.4]}),
NUM('tentCoil','Spirale: Stärke',0,2,0,{rr:[0.2,1.0],p0:0.6}),
NUM('tentCoilFreq','Spirale: Windungen',0.2,6,2.5,{rr:[1,5]}),
NUM('tentCoilSpeed','Spirale: Tempo',0,6,1,{rr:[0.3,2]}),
NUM('tentBeads','Perlen: Stärke',0,1,0,{rr:[0.2,0.9],p0:0.6}),
NUM('beadFreq','Perlen: Anzahl',2,30,10,{rr:[4,20]})
]},
{t:'Mundarme', items:[
NUM('armCount','Anzahl',0,10,4,{int:1,rb:'arms',rr:[0,8]}),
NUM('armSeg','Segmente',4,80,32,{int:1,rb:'arms'}),
NUM('armLen','Länge',0.2,6,2.6,{rr:[1.5,4]}),
NUM('armWidth','Breite',0.02,0.5,0.14,{rr:[0.05,0.3]}),
NUM('armTaper','Verjüngung',0,1,0.6,{rr:[0.2,0.9]}),
NUM('armAttach','Ansatz (Mitte bis außen)',0,0.7,0.12,{rr:[0,0.35]}),
NUM('armRuffle','Kräuselung',0,1.5,0.5,{rr:[0,1.0]}),
NUM('armRuffleFreq','Kräuselfrequenz',0,20,7,{rr:[3,14]}),
NUM('armTwist','Verdrehung',0,3,0.6,{rr:[0,2]}),
NUM('armStiff','Steifigkeit',0,0.6,0.16,{rr:[0.06,0.3]}),
NUM('armFlow','Flowfield-Einfluss',0,3,0.5,{rr:[0.2,1.2]})
]},
{t:'Innenleben', items:[
NUM('organCount','Gonaden: Anzahl',0,8,4,{int:1,rb:'organs',rr:[0,8]}),
NUM('organSize','Gonaden: Größe',0.04,0.5,0.17,{rr:[0.08,0.3]}),
NUM('organT','Gonaden: Position',0.1,0.8,0.42,{rr:[0.25,0.6]}),
NUM('canalCount','Radialkanäle',0,32,8,{int:1,rb:'canals',rr:[0,24]}),
NUM('canalAlpha','Kanäle: Deckkraft',0,1,0.45)
]},
{t:'Physik und Zeit', items:[
NUM('timeScale','Zeitlupe bis Zeitraffer',0,2,1),
NUM('inertia','Reaktionsträgheit (Sekunden)',0.05,3,0.7,{rr:[0.4,1.6]}),
NUM('drag','Wasserwiderstand',0.01,0.4,0.1,{rr:[0.06,0.2]}),
NUM('tentGravity','Schwerkraft / Auftrieb',-1,1,0.2,{rr:[-0.1,0.5]})
]},
{t:'Licht und Material', open:true, items:[
NUM('lightAz','Licht: Richtung (Grad)',-180,180,40,{}),
NUM('lightEl','Licht: Höhe (Grad)',5,89,55,{}),
BOOL('lightFollow','Licht folgt der Kamera',false),
NUM('lightStrength','Licht: Stärke',0,1.5,1,{}),
NUM('specStr','Glanz: Stärke',0,2,1,{}),
NUM('gloss','Glanz: Schärfe',8,400,140,{}),
NUM('envStr','Spiegelung der Umgebung',0,2,0.8,{}),
BOOL('swap','Hell und Dunkel vertauschen',false),
COL('cLight','Farbe Hell','#ffffff'),
COL('cDark','Farbe Dunkel','#000000'),
COL('cBg','Hintergrund','#2d3033')
]},
{t:'Darstellung', items:[
BOOL('flat','Facetten (Low-Poly-Look)',false),
NUM('wire','Gitter',0,1,0),
NUM('bellAlpha','Schirm: Deckkraft',0,0.8,0.18),
NUM('innerAlpha','Innenwand: Deckkraft',0,0.9,0.45),
NUM('rimAlpha','Randleuchten',0,1,0.75),
NUM('fresnel','Randschärfe',0.5,5,2.6),
NUM('tentAlpha','Tentakel: Deckkraft',0,1,0.92),
NUM('tipFade','Ausblenden zur Spitze',0,1,0.35),
NUM('autoRot','Kamera dreht sich',0,1,0.12)
]}
];
const P = {};
SCHEMA.forEach(g => g.items.forEach(it => { P[it.k] = it.v; }));
P.seed = 1234;
const DEFAULTS = JSON.parse(JSON.stringify(P));
/* ------------------------------------------------------------------ */
/* Szene */
/* ------------------------------------------------------------------ */
const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x2d3033, 1);
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 200);
const group = new THREE.Group(); // der Quallenkörper (Schirm, Schichten, Krausen, Innenleben)
scene.add(group);
let az = 1.25, pol = 1.4, dist = 9.5;
const target = new THREE.Vector3(0,-0.7,0), camGoal = new THREE.Vector3();
function updateCamera(){
camera.position.set(
target.x + dist*Math.sin(pol)*Math.sin(az),
target.y + dist*Math.cos(pol),
target.z + dist*Math.sin(pol)*Math.cos(az));
camera.lookAt(target);
}
function resize(){
const w = Math.max(1, stage.clientWidth), h = Math.max(1, stage.clientHeight);
renderer.setSize(w,h);
camera.aspect = w/h;
camera.updateProjectionMatrix();
}
if(window.ResizeObserver) new ResizeObserver(resize).observe(stage);
else window.addEventListener('resize', resize);
resize();
(function(){
const cv = renderer.domElement; let drag = null;
cv.addEventListener('pointerdown', e => { drag = {x:e.clientX,y:e.clientY}; try{cv.setPointerCapture(e.pointerId);}catch(_){} });
cv.addEventListener('pointermove', e => {
if(!drag) return;
az -= (e.clientX-drag.x)*0.006;
pol = clamp(pol - (e.clientY-drag.y)*0.006, 0.12, Math.PI-0.12);
drag.x = e.clientX; drag.y = e.clientY;
});
const end = () => { drag = null; };
cv.addEventListener('pointerup', end); cv.addEventListener('pointercancel', end);
cv.addEventListener('wheel', e => { e.preventDefault(); dist = clamp(dist*Math.exp(e.deltaY*0.001), 2.5, 24); }, {passive:false});
})();
/* ------------------------------------------------------------------ */
/* Materialien */
/* ------------------------------------------------------------------ */
const VS = `
attribute float aT;
varying vec3 vN; varying vec3 vV; varying float vT;
void main(){
vec4 mv = modelViewMatrix * vec4(position,1.0);
vN = normalize(normalMatrix * normal);
vV = mv.xyz;
vT = aT;
gl_Position = projectionMatrix * mv;
}`;
const FS = `
uniform vec3 uA; uniform vec3 uB; uniform vec3 uRim;
uniform float uAlpha; uniform float uRimAlpha; uniform float uRimMix;
uniform float uPow; uniform float uFlat; uniform float uTip;
uniform vec3 uL; uniform vec3 uL2; uniform vec3 uUp;
uniform float uSpec; uniform float uGloss; uniform float uEnv; uniform float uLit;
varying vec3 vN; varying vec3 vV; varying float vT;
void main(){
vec3 vd = normalize(-vV);
vec3 ns = normalize(vN);
if(dot(ns,vd) < 0.0) ns = -ns;
vec3 nf = normalize(cross(dFdx(vV), dFdy(vV)));
if(dot(nf,vd) < 0.0) nf = -nf;
vec3 n = normalize(mix(ns, nf, uFlat));
float ndv = clamp(dot(n,vd), 0.0, 1.0);
float f = pow(1.0 - ndv, uPow);
float t = clamp(vT, 0.0, 1.0);
vec3 base = mix(uA, uB, t);
// Licht: gerichtetes Hauptlicht (diffus, weich umgriffen) plus schwaches Gegenlicht
float dl = 0.5 + 0.5*dot(n, uL);
float diff = 0.4 + 0.6*dl*uLit;
vec3 col = base*diff;
col = mix(col, uRim, f*uRimMix);
// Glanz: Blinn-Phong fürs Hauptlicht, breiter für das Gegenlicht
vec3 h = normalize(uL + vd);
vec3 h2 = normalize(uL2 + vd);
float s1 = pow(max(dot(n,h), 0.0), uGloss);
float s2 = 0.35*pow(max(dot(n,h2), 0.0), uGloss*0.5);
float spec = (s1 + s2)*uSpec*uLit;
// Spiegelung: weicher Himmel oben, heller Horizontstreifen, vor allem am Rand
vec3 r = reflect(-vd, n);
float up = dot(r, uUp);
float env = (smoothstep(-0.1, 1.0, up)*0.6 + pow(max(1.0 - abs(up), 0.0), 6.0)*0.4)*f*uEnv*0.5;
col += vec3(spec + env);
float a = (uAlpha + f*uRimAlpha)*(1.0 - uTip*t);
float hl = clamp(spec + env, 0.0, 1.0);
a = a + hl*(1.0 - a);
gl_FragColor = vec4(col, a);
}`;
/* gemeinsame Licht-Uniforms für alle Materialien */
const SH = {
uL:{value:new THREE.Vector3(0,1,0)}, uL2:{value:new THREE.Vector3(0,0.3,-1)}, uUp:{value:new THREE.Vector3(0,1,0)},
uSpec:{value:1}, uGloss:{value:140}, uEnv:{value:0.8}, uLit:{value:1}
};
function jelly(){
return new THREE.ShaderMaterial({
transparent:true, depthWrite:false, side:THREE.DoubleSide, extensions:{derivatives:true},
uniforms:Object.assign({
uA:{value:new THREE.Color()}, uB:{value:new THREE.Color()}, uRim:{value:new THREE.Color()},
uAlpha:{value:0.2}, uRimAlpha:{value:0.5}, uRimMix:{value:1}, uPow:{value:2}, uFlat:{value:0}, uTip:{value:0}
}, SH),
vertexShader:VS, fragmentShader:FS
});
}
const matOuter = jelly(), matInner = jelly(), matTent = jelly(), matArm = jelly(), matLayer = jelly(), matFrill = jelly(), matOrgan = jelly();
const matWire = new THREE.MeshBasicMaterial({color:0xffffff, wireframe:true, transparent:true, opacity:0.18, depthWrite:false});
const matCanal = new THREE.LineBasicMaterial({color:0xffffff, transparent:true, opacity:0.45, depthWrite:false});
const matField = new THREE.LineBasicMaterial({color:0xd0d0d0, transparent:true, opacity:0.5, depthWrite:false});
const organGeo = new THREE.IcosahedronGeometry(1,2);
const _ld = new THREE.Vector3(), _ld2 = new THREE.Vector3();
function applyMaterials(){
const Lc = P.swap ? P.cDark : P.cLight, Dc = P.swap ? P.cLight : P.cDark;
const set = (m,a,b,rim,alpha,ra,rm,tip) => {
const u = m.uniforms;
u.uA.value.set(a); u.uB.value.set(b); u.uRim.value.set(rim);
u.uAlpha.value = alpha; u.uRimAlpha.value = ra; u.uRimMix.value = rm; u.uPow.value = P.fresnel;
u.uFlat.value = P.flat ? 1 : 0; u.uTip.value = tip;
};
// innen außen Rand Deckkraft Randalpha Randfarbe Spitzenausblendung
set(matOuter, Lc, Lc, Lc, P.bellAlpha, P.rimAlpha, 1.0, 0);
set(matInner, Dc, Dc, Lc, P.innerAlpha, P.rimAlpha*0.3, 0.25, 0);
set(matLayer, Dc, Lc, Lc, P.layerAlpha, P.rimAlpha*0.5, 0.5, 0);
set(matFrill, Lc, Dc, Lc, P.frillAlpha, 0.4, 0.6, P.tipFade);
set(matTent, Dc, Lc, Lc, P.tentAlpha, 0.1, 0.45, P.tipFade);
set(matArm, Lc, Lc, Lc, P.tentAlpha*0.6, 0.3, 0.7, P.tipFade*0.6);
set(matOrgan, Dc, Dc, Lc, 0.88, 0.1, 0.35, 0);
matWire.color.set(Lc); matWire.opacity = P.wire; matWire.visible = P.wire > 0.005;
matCanal.color.set(Lc); matCanal.opacity = P.canalAlpha;
renderer.setClearColor(P.cBg, 1);
stage.style.background = P.cBg;
// Licht: Richtung in Weltkoordinaten (oder relativ zur Kamera), dann in den Kameraraum
const az = P.lightAz*Math.PI/180, el = P.lightEl*Math.PI/180;
_ld.set(Math.cos(el)*Math.sin(az), Math.sin(el), Math.cos(el)*Math.cos(az)).normalize();
_ld2.set(-_ld.x, 0.25, -_ld.z).normalize();
SH.uUp.value.set(0,1,0).transformDirection(camera.matrixWorldInverse);
if(P.lightFollow){ SH.uL.value.copy(_ld); SH.uL2.value.copy(_ld2); }
else { SH.uL.value.copy(_ld).transformDirection(camera.matrixWorldInverse); SH.uL2.value.copy(_ld2).transformDirection(camera.matrixWorldInverse); }
SH.uSpec.value = P.specStr; SH.uGloss.value = P.gloss; SH.uEnv.value = P.envStr; SH.uLit.value = P.lightStrength;
}
/* ------------------------------------------------------------------ */
/* Noise / Flowfield */
/* ------------------------------------------------------------------ */
let noiseF = makeNoise(1), noiseS = makeNoise(2);
function seedNoise(){ noiseF = makeNoise(P.seed*3+11); noiseS = makeNoise(P.seed); }
seedNoise();
let time = 0, phase = 0, prevC0 = 0;
const _F = {x:0,y:0,z:0}, _Fb = {x:0,y:0,z:0};
function flowVec(x,y,z,t,o,oct){
const s = P.flowScale, tt = t*P.flowSpeed;
const ox = tt*0.37, oy = tt*0.23, oz = tt*0.31;
const no = oct || P.flowOct;
let fx=0, fy=0, fz=0, amp=1, fr=s, norm=0;
for(let k=0;k<no;k++){
fx += amp*noiseF(x*fr+ox, y*fr+oy, z*fr+oz);
fy += amp*noiseF(x*fr+ox+31.7, y*fr+oy+17.3, z*fr+oz+7.1);
fz += amp*noiseF(x*fr+ox+11.9, y*fr+oy+43.1, z*fr+oz+23.7);
norm += amp; amp *= 0.5; fr *= 2.03;
}
const g = 1.6*P.flowStrength/norm;
fx *= g; fy *= g; fz *= g;
fy += P.flowUp*P.flowStrength;
if(P.flowSwirl !== 0){ fx += -z*P.flowSwirl*0.3*P.flowStrength; fz += x*P.flowSwirl*0.3*P.flowStrength; }
o.x = fx; o.y = fy; o.z = fz;
}
/* ------------------------------------------------------------------ */
/* Schirm */
/* ------------------------------------------------------------------ */
/* Kontraktion an Position t (0 Apex, 1 Rand); extra = zusätzliche Verzögerung (in Zyklen) */
function contractionAt(t, extra){
let u = phase - P.pulseLag*t - (extra || 0);
u -= Math.floor(u);
const a = P.pulseAsym;
if(u < a) return 0.5-0.5*Math.cos(Math.PI*u/a);
const d = (u-a)/(1-a);
let c = 0.5+0.5*Math.cos(Math.PI*d);
if(P.pulseElastic > 0.001) c += P.pulseElastic*0.3*Math.sin(TAU*2*d)*Math.exp(-4*d)*(1-d);
return c;
}
/* Alles, was nur von der Ringposition abhängt, einmal pro Ring */
function ringBase(t, c, rb){
const R = P.bellRadius, H = P.bellHeight;
const phi = t*Math.PI*0.5*P.coverage;
const s = Math.sin(phi), co = Math.cos(phi);
const w = sstep(0.6,1.0,t), w2 = w*w;
rb.t = t; rb.c = c; rb.w = w; rb.w2 = w2;
let r0 = R*Math.pow(Math.max(s,0), P.shoulder);
let y0 = H*sgnpow(co, P.domePower);
if(P.capsule > 0.001){
// Kapsel: Halbkugel-Kuppel plus gerade (leicht konische) Wand, gleichmäßig nach Bogenlänge verteilt
const hc = H*P.capFrac, wall = H-hc;
const arc = 1.5708*Math.sqrt((R*R+hc*hc)*0.5);
const tc = arc/(arc+wall+1e-6);
let rc, yc;
if(t < tc){ const ph = (t/tc)*1.5708; rc = R*Math.sin(ph); yc = wall+hc*Math.cos(ph); }
else { const v = (t-tc)/Math.max(1-tc,1e-4); rc = R*(1-P.wallTaper*v); yc = wall*(1-v); }
r0 = lerp(r0, rc, P.capsule); y0 = lerp(y0, yc, P.capsule);
}
rb.r0 = r0 + R*P.flare*w2;
rb.y0 = y0 + H*P.curl*w2;
rb.rib = sstep(0.05,0.5,t);
rb.kp = 1 - P.pulseAmp*c*(0.25+0.75*t);
rb.dy = -H*P.pulseAmp*0.35*c*t;
rb.wob = P.bellFlow*0.22*(0.1+0.9*t);
}
/* Punkt auf dem Schirm für Ring rb und Winkel th */
function bellPoint(rb, th, o){
const R = P.bellRadius, H = P.bellHeight;
const w = rb.w, w2 = rb.w2;
let r = rb.r0, y = rb.y0;
if(P.lobeDepth > 0.001 && P.lobeCount > 0 && w > 0){
const lb = 0.5+0.5*Math.cos(P.lobeCount*th); // 1 an den Lappenspitzen
const gap = Math.pow(1-lb, 1.2); // 1 in den Einkerbungen
y += H*P.lobeDepth*gap*w;
r *= 1 - 0.3*P.lobeDepth*gap*w2;
}
if(P.ribDepth > 0.001) r *= 1 + P.ribDepth*Math.cos(P.ribCount*th)*rb.rib;
let x = r*Math.cos(th), z = r*Math.sin(th);
x *= 1+P.aniso; z *= 1-P.aniso;
y += P.skew*x*w;
if(P.lumpAmp > 0.001){
const f = P.lumpFreq;
const n = noiseS(x*f+13.1, y*f+7.7, z*f+3.3);
const dx = x, dy = y-H*0.35, dz = z, dl = Math.hypot(dx,dy,dz) || 1;
const a = P.lumpAmp*R*n/dl;
x += dx*a; y += dy*a; z += dz*a;
}
if(P.spikeAmp > 0.001){
const f = P.spikeFreq;
const n = noiseS(x*f+51.7, y*f+11.3, z*f+29.9);
let sp = (n-P.spikeThr)/(0.75-P.spikeThr);
if(sp > 0){
sp = Math.min(sp,1);
const dx = x, dy = y-H*0.35, dz = z, dl = Math.hypot(dx,dy,dz) || 1;
const a = P.spikeAmp*R*sp*sp/dl;
x += dx*a; y += dy*a; z += dz*a;
}
}
// Pulsieren
x *= rb.kp; z *= rb.kp; y += rb.dy;
// Randflattern: laufende Wellen am Rand, stärker während der Kontraktion
if(P.flutterAmp > 0.001 && w2 > 0.0001){
const fa = P.flutterAmp*w2*(0.4+0.6*rb.c);
const ph = P.flutterCount*th - time*P.flutterSpeed*TAU;
y += H*0.22*fa*Math.sin(ph);
const rr = 1 + 0.06*fa*Math.sin(ph+1.4);
x *= rr; z *= rr;
}
// Flowfield-Wabern (eine Oktave, langsam)
if(rb.wob > 0.0005){
flowVec(x*0.8, y*0.8, z*0.8, time, _Fb, 1);
x += _Fb.x*rb.wob; y += _Fb.y*rb.wob; z += _Fb.z*rb.wob;
}
o.x = x; o.y = y; o.z = z;
}
const RB1 = {};
function bellVertex(t, th, c, o){ ringBase(t, c, RB1); bellPoint(RB1, th, o); }
const B = {outerM:null,innerM:null,wireM:null,canalM:null,organs:[],layers:[],frills:[],R:0,S:0,out:null,inn:null,canalCols:[]};
function disposeObj(o){ if(!o) return; if(o.parent) o.parent.remove(o); if(o.geometry && o.geometry !== organGeo) o.geometry.dispose(); }
function gridIndex(R,S){
const idx = [];
for(let i=0;i<R;i++) for(let j=0;j<S;j++){
const a = i*S+j, b = i*S+(j+1)%S, c = (i+1)*S+j, d = (i+1)*S+(j+1)%S;
idx.push(a,c,b, b,c,d);
}
return idx;
}
function gridGeo(R,S,idx){
const n = (R+1)*S;
const g = new THREE.BufferGeometry();
const pos = new THREE.BufferAttribute(new Float32Array(n*3),3); pos.setUsage(THREE.DynamicDrawUsage);
const nor = new THREE.BufferAttribute(new Float32Array(n*3),3); nor.setUsage(THREE.DynamicDrawUsage);
g.setAttribute('position',pos); g.setAttribute('normal',nor);
const t = new Float32Array(n);
for(let i=0;i<=R;i++) for(let j=0;j<S;j++) t[i*S+j] = i/R;
g.setAttribute('aT', new THREE.BufferAttribute(t,1));
g.setIndex(idx);
return g;
}
function buildBell(){
disposeObj(B.outerM); disposeObj(B.innerM); disposeObj(B.wireM);
const R = P.bellRings, S = P.bellSegs, idx = gridIndex(R,S);
const go = gridGeo(R,S,idx), gi = gridGeo(R,S,idx);
B.R = R; B.S = S; B.out = go.attributes.position.array; B.inn = gi.attributes.position.array;
B.outerM = new THREE.Mesh(go, matOuter); B.innerM = new THREE.Mesh(gi, matInner);
B.wireM = new THREE.Mesh(go, matWire);
B.innerM.renderOrder = 1; B.outerM.renderOrder = 3; B.wireM.renderOrder = 4;
[B.outerM,B.innerM,B.wireM].forEach(m => { m.frustumCulled = false; group.add(m); });
}
function fixApex(g,S){
const nrm = g.attributes.normal.array;
let x=0,y=0,z=0;
for(let j=0;j<S;j++){ x+=nrm[j*3]; y+=nrm[j*3+1]; z+=nrm[j*3+2]; }
const l = Math.hypot(x,y,z) || 1; x/=l; y/=l; z/=l;
for(let j=0;j<S;j++){ nrm[j*3]=x; nrm[j*3+1]=y; nrm[j*3+2]=z; }
g.attributes.normal.needsUpdate = true;
}
const _o = {x:0,y:0,z:0};
const RBm = {};
function updateBell(){
if(!B.outerM) return;
const R = B.R, S = B.S, out = B.out, inn = B.inn;
const py = P.bellHeight*0.5, k = 1-P.shellThick;
for(let i=0;i<=R;i++){
const t = i/R;
ringBase(t, contractionAt(t), RBm);
for(let j=0;j<S;j++){
bellPoint(RBm, j/S*TAU, _o);
const q = (i*S+j)*3;
out[q] = _o.x; out[q+1] = _o.y; out[q+2] = _o.z;
inn[q] = _o.x*k; inn[q+1] = py+(_o.y-py)*k; inn[q+2] = _o.z*k;
}
}
const go = B.outerM.geometry, gi = B.innerM.geometry;
go.attributes.position.needsUpdate = true; gi.attributes.position.needsUpdate = true;
go.computeVertexNormals(); gi.computeVertexNormals();
fixApex(go,S); fixApex(gi,S);
}
/* Zusatzschirme (Schichten) */
function buildLayers(){
B.layers.forEach(L => disposeObj(L.mesh)); B.layers = [];
const R = P.bellRings, S = P.bellSegs, idx = gridIndex(R,S);
for(let i=0;i<P.layerCount;i++){
const g = gridGeo(R,S,idx);
const m = new THREE.Mesh(g, matLayer);
m.frustumCulled = false; m.renderOrder = 2.5;
group.add(m);
B.layers.push({mesh:m, geo:g, arr:g.attributes.position.array});
}
}
function updateLayers(){
const R = P.bellRings, S = P.bellSegs;
for(let li=0; li<B.layers.length; li++){
const L = B.layers[li], n = li+1;
const rs = Math.pow(P.layerScale,n), hs = Math.pow(P.layerHeight,n);
const dy = -n*P.layerDrop, rot = n*P.layerRot, lag = n*P.layerLag;
const arr = L.arr;
for(let i=0;i<=R;i++){
const t = i/R;
ringBase(t, contractionAt(t,lag), RBm);
for(let j=0;j<S;j++){
bellPoint(RBm, j/S*TAU+rot, _o);
const q = (i*S+j)*3;
arr[q] = _o.x*rs; arr[q+1] = _o.y*hs+dy; arr[q+2] = _o.z*rs;
}
}
L.geo.attributes.position.needsUpdate = true;
L.geo.computeVertexNormals(); fixApex(L.geo,S);
}
}
/* Krausen: gefaltete Bänder, die vom Schirm herabhängen */
const BAND_T = [1.0, 0.72, 0.45];
const FROW = 16;
function buildFrills(){
B.frills.forEach(F => disposeObj(F.mesh)); B.frills = [];
const cols = P.frillRes, rows = FROW;
for(let b=0;b<P.frillCount;b++){
const n = (rows+1)*cols;
const idx = [];
for(let i=0;i<rows;i++) for(let j=0;j<cols;j++){
const a=i*cols+j, bb=i*cols+(j+1)%cols, c=(i+1)*cols+j, d=(i+1)*cols+(j+1)%cols;
idx.push(a,c,bb, bb,c,d);
}
const at = new Float32Array(n);
for(let i=0;i<=rows;i++) for(let j=0;j<cols;j++) at[i*cols+j] = i/rows;
const g = makeDynGeo(new Float32Array(n*3), new Float32Array(n*3), at, idx);
const m = new THREE.Mesh(g, matFrill);
m.frustumCulled = false; m.renderOrder = 2.2;
group.add(m);
B.frills.push({mesh:m, geo:g, arr:g.attributes.position.array, cols:cols});
}
}
const RBf = {}, _rowC = new Float32Array(FROW+1);
function updateFrills(){
const rows = FROW;
const f1 = Math.round(P.frillFreq), f2 = Math.round(P.frillFreq2);
for(let b=0; b<B.frills.length; b++){
const F = B.frills[b], cols = F.cols, arr = F.arr;
const tb = BAND_T[b];
ringBase(tb, contractionAt(tb), RBf);
const len = P.frillLen*(1-0.18*b);
for(let i=0;i<=rows;i++) _rowC[i] = contractionAt(tb, 0.25*(i/rows));
for(let j=0;j<cols;j++){
const th = j/cols*TAU, ct = Math.cos(th), st = Math.sin(th);
bellPoint(RBf, th, _o);
const x0 = _o.x, y0 = _o.y, z0 = _o.z;
let fx=0, fy=0, fz=0;
if(P.frillFlow > 0.001){ flowVec(x0*0.8,y0*0.8,z0*0.8,time,_Fb,1); fx=_Fb.x; fy=_Fb.y; fz=_Fb.z; }
for(let i=0;i<=rows;i++){
const s = i/rows;
const out = len*s*P.frillFlare*(1-0.5*P.pulseAmp*_rowC[i]);
const down = len*s*P.frillDroop;
const amp = P.frillAmp*len*Math.pow(s,1.2);
const ph1 = th*f1 + s*P.frillTwist*3 - time*P.frillSpeed*TAU*0.5 + b*1.7;
const ph2 = th*f2 - s*P.frillTwist*2 + time*P.frillSpeed*TAU*0.37;
const rad = amp*(Math.sin(ph1) + 0.4*Math.sin(ph2));
const tan = amp*0.3*Math.sin(ph2+0.6);
const fl = P.frillFlow*0.6*len*s*s;
const q = (i*cols+j)*3;
arr[q] = x0 + ct*(out+rad) - st*tan + fx*fl;
arr[q+1] = y0 - down + amp*0.45*Math.cos(ph1+1.2) + fy*fl;
arr[q+2] = z0 + st*(out+rad) + ct*tan + fz*fl;
}
}
F.geo.attributes.position.needsUpdate = true;
F.geo.computeVertexNormals();
}
}
/* Innenleben: Gonaden und Radialkanäle */
function buildOrgans(){
B.organs.forEach(m => { if(m.parent) m.parent.remove(m); });
B.organs = [];
for(let k=0;k<P.organCount;k++){
const m = new THREE.Mesh(organGeo, matOrgan);
m.renderOrder = 2; m.frustumCulled = false;
group.add(m); B.organs.push(m);
}
}
function buildCanals(){
disposeObj(B.canalM); B.canalM = null; B.canalCols = [];
const cc = P.canalCount, R = P.bellRings, S = P.bellSegs;
if(cc < 1) return;
for(let c=0;c<cc;c++) B.canalCols.push(Math.round(c*S/cc) % S);
const segs = cc*R + S;
const g = new THREE.BufferGeometry();
const a = new THREE.BufferAttribute(new Float32Array(segs*6),3); a.setUsage(THREE.DynamicDrawUsage);
g.setAttribute('position',a);
B.canalM = new THREE.LineSegments(g, matCanal);
B.canalM.frustumCulled = false; B.canalM.renderOrder = 2;
group.add(B.canalM);
}
function updateInner(){
if(!B.out) return;
const R = B.R, S = B.S, o = B.out, n = B.inn;
for(let k=0;k<B.organs.length;k++){
const m = B.organs[k];
const j = Math.round(k*S/B.organs.length) % S;
const i = clamp(Math.round(P.organT*R),1,R);
const q = (i*S+j)*3;
m.position.set(0.35*o[q]+0.65*n[q], 0.35*o[q+1]+0.65*n[q+1], 0.35*o[q+2]+0.65*n[q+2]);
m.rotation.set(0, -(j/S*TAU), 0);
m.scale.set(P.organSize*0.8, P.organSize*0.5, P.organSize*1.3);
}
if(B.canalM){
const arr = B.canalM.geometry.attributes.position.array;
let w = 0;
const mid = (i,j) => { const q = (i*S+j)*3; return [0.5*(o[q]+n[q]), 0.5*(o[q+1]+n[q+1]), 0.5*(o[q+2]+n[q+2])]; };
for(let c=0;c<B.canalCols.length;c++){
const j = B.canalCols[c];
for(let i=0;i<R;i++){
const a = mid(i,j), b = mid(i+1,j);
arr[w++]=a[0];arr[w++]=a[1];arr[w++]=a[2];arr[w++]=b[0];arr[w++]=b[1];arr[w++]=b[2];
}
}
for(let j=0;j<S;j++){
const a = mid(R,j), b = mid(R,(j+1)%S);
arr[w++]=a[0];arr[w++]=a[1];arr[w++]=a[2];arr[w++]=b[0];arr[w++]=b[1];arr[w++]=b[2];
}
B.canalM.geometry.attributes.position.needsUpdate = true;
}
}
/* ------------------------------------------------------------------ */
/* Ketten-Physik (Tentakel und Mundarme) – träge und weich */
/* ------------------------------------------------------------------ */
const FS_FLOW = 3.5, FS_GRAV = 14, FS_COIL = 9;
/* Feste Teilschritte (ca. 1/90 s), Anker wird zwischen den Frames interpoliert,
Flow-Kräfte werden nur einmal pro Frame gesampelt und zeitlich geglättet. */
function stepChain(it, n, len, ax, ay, az_, dx, dy, dz, dt, o){
const sub = clamp(Math.round(dt/(1/90)), 1, 4), hh = dt/sub;
const damp = Math.pow(1-o.drag, hh*60), h2 = hh*hh;
const stEff = 1 - Math.pow(1-o.stiff, hh*60);
const pos = it.pos, prv = it.prv, ff = it.ff, a0 = it.a0;
const kf = 1 - Math.exp(-dt/Math.max(0.02, o.lag));
for(let i=1;i<=n;i++){
const ix = i*3;
flowVec(pos[ix],pos[ix+1],pos[ix+2],time,_F,1);
ff[ix] += (_F.x*o.flow*FS_FLOW - ff[ix])*kf;
ff[ix+1] += (_F.y*o.flow*FS_FLOW - ff[ix+1])*kf;
ff[ix+2] += (_F.z*o.flow*FS_FLOW - ff[ix+2])*kf;
}
const gy = -o.grav*FS_GRAV;
for(let s=1;s<=sub;s++){
const f = s/sub;
pos[0] = lerp(a0[0],ax,f); pos[1] = lerp(a0[1],ay,f); pos[2] = lerp(a0[2],az_,f);
prv[0] = pos[0]; prv[1] = pos[1]; prv[2] = pos[2];
let tx=dx, ty=dy, tz=dz;
for(let i=1;i<=n;i++){
const ix = i*3, jx = ix-3;
let px=pos[ix], py=pos[ix+1], pz=pos[ix+2];
const vx=(px-prv[ix])*damp, vy=(py-prv[ix+1])*damp, vz=(pz-prv[ix+2])*damp;
prv[ix]=px; prv[ix+1]=py; prv[ix+2]=pz;
let fx=ff[ix], fy=ff[ix+1]+gy, fz=ff[ix+2];
if(i>=2){
tx=pos[jx]-pos[jx-3]; ty=pos[jx+1]-pos[jx-2]; tz=pos[jx+2]-pos[jx-1];
const l = Math.hypot(tx,ty,tz) || 1; tx/=l; ty/=l; tz/=l;
}
if(o.coil > 0.001){
let rx=0, ry=0, rz=1; if(Math.abs(tz) > 0.9){ rx=1; rz=0; }
let n1x=ty*rz-tz*ry, n1y=tz*rx-tx*rz, n1z=tx*ry-ty*rx;
const nl = Math.hypot(n1x,n1y,n1z) || 1; n1x/=nl; n1y/=nl; n1z/=nl;
const n2x=ty*n1z-tz*n1y, n2y=tz*n1x-tx*n1z, n2z=tx*n1y-ty*n1x;
const a = (i/n)*o.coilFreq*TAU - time*o.coilSpeed + o.phase;
const ca = Math.cos(a)*o.coil*FS_COIL, sa = Math.sin(a)*o.coil*FS_COIL;
fx += n1x*ca+n2x*sa; fy += n1y*ca+n2y*sa; fz += n1z*ca+n2z*sa;
}
px += vx+fx*h2; py += vy+fy*h2; pz += vz+fz*h2;
const q1x=px, q1y=py, q1z=pz; // Position vor den Bedingungen
// weiche Steifigkeit: Richtung der Verlängerung des vorherigen Segments
const st = stEff*(1-0.6*(i/n));
px += (pos[jx]+tx*len-px)*st; py += (pos[jx+1]+ty*len-py)*st; pz += (pos[jx+2]+tz*len-pz)*st;
// Seil-Bedingung (nur Dehnung wird verhindert)
const ex=px-pos[jx], ey=py-pos[jx+1], ez=pz-pos[jx+2], d=Math.hypot(ex,ey,ez);
if(d > len){ const k=len/d; px=pos[jx]+ex*k; py=pos[jx+1]+ey*k; pz=pos[jx+2]+ez*k; }
// Bedingungen wirken nur auf die Position, nicht auf die Geschwindigkeit:
// dadurch entsteht kein Peitscheneffekt, die Kette zieht träge nach
prv[ix]+=px-q1x; prv[ix+1]+=py-q1y; prv[ix+2]+=pz-q1z;
pos[ix]=px; pos[ix+1]=py; pos[ix+2]=pz;
}
}
a0[0]=ax; a0[1]=ay; a0[2]=az_;
}
/* Koordinatensysteme entlang einer Kette (Parallel Transport) */
const FR = new Float32Array(128*9);
function frames(p,n){
let nx=0, ny=0, nz=0;
for(let i=0;i<=n;i++){
const a = Math.max(0,i-1)*3, b = Math.min(n,i+1)*3;
let tx=p[b]-p[a], ty=p[b+1]-p[a+1], tz=p[b+2]-p[a+2];
let tl = Math.hypot(tx,ty,tz);
if(tl < 1e-6){ tx=0; ty=-1; tz=0; tl=1; }
tx/=tl; ty/=tl; tz/=tl;
if(i === 0){
let rx=0, ry=0, rz=1; if(Math.abs(tz) > 0.9){ rx=1; rz=0; }
nx=ty*rz-tz*ry; ny=tz*rx-tx*rz; nz=tx*ry-ty*rx;
} else {
const d = nx*tx+ny*ty+nz*tz; nx-=tx*d; ny-=ty*d; nz-=tz*d;
}
let nl = Math.hypot(nx,ny,nz);
if(nl < 1e-5){
let rx=1, ry=0, rz=0; if(Math.abs(tx) > 0.9){ rx=0; ry=1; }
nx=ty*rz-tz*ry; ny=tz*rx-tx*rz; nz=tx*ry-ty*rx; nl = Math.hypot(nx,ny,nz) || 1;
}
nx/=nl; ny/=nl; nz/=nl;
const o = i*9;
FR[o]=tx; FR[o+1]=ty; FR[o+2]=tz;
FR[o+3]=nx; FR[o+4]=ny; FR[o+5]=nz;
FR[o+6]=ty*nz-tz*ny; FR[o+7]=tz*nx-tx*nz; FR[o+8]=tx*ny-ty*nx;
}
}
function makeDynGeo(posArr, norArr, atArr, idx){
const g = new THREE.BufferGeometry();
const pa = new THREE.BufferAttribute(posArr,3); pa.setUsage(THREE.DynamicDrawUsage);
const na = new THREE.BufferAttribute(norArr,3); na.setUsage(THREE.DynamicDrawUsage);
g.setAttribute('position',pa); g.setAttribute('normal',na);
g.setAttribute('aT', new THREE.BufferAttribute(atArr,1));
g.setIndex(idx);
return g;
}
function newChainItem(n, extra){
return Object.assign({pos:new Float32Array((n+1)*3), prv:new Float32Array((n+1)*3),
ff:new Float32Array((n+1)*3), a0:new Float32Array(3), init:true}, extra);
}
function initChain(it, n, len, v, d){
for(let i=0;i<=n;i++){
const q=i*3;
it.pos[q]=v.x+d.x*len*i; it.pos[q+1]=v.y+d.y*len*i; it.pos[q+2]=v.z+d.z*len*i;
it.prv[q]=it.pos[q]; it.prv[q+1]=it.pos[q+1]; it.prv[q+2]=it.pos[q+2];
}
it.ff.fill(0);
it.a0[0]=v.x; it.a0[1]=v.y; it.a0[2]=v.z;
it.init = false;
}
/* ---- Tentakel ---- */
const T = {mesh:null, items:[], posA:null, norA:null, opt:{flow:1,grav:0.2,drag:0.1,stiff:0.18,lag:0.7,coil:0,coilFreq:2,coilSpeed:1,phase:0}};
function buildTents(){
disposeObj(T.mesh); T.mesh = null; T.items = [];
const Nn = P.tentCount; if(Nn < 1) return;
const n = P.tentSeg, sd = P.tentSides, rng = mulberry32(P.seed*13+5);
const vp = (n+1)*sd, total = Nn*vp;
const pos = new Float32Array(total*3), nor = new Float32Array(total*3), at = new Float32Array(total);
const idx = [];
for(let k=0;k<Nn;k++){
const b0 = k*vp;
for(let i=0;i<=n;i++) for(let m=0;m<sd;m++) at[b0+i*sd+m] = i/n;
for(let i=0;i<n;i++) for(let m=0;m<sd;m++){
const a=b0+i*sd+m, b=b0+i*sd+(m+1)%sd, c=b0+(i+1)*sd+m, d=b0+(i+1)*sd+(m+1)%sd;
idx.push(a,c,b, b,c,d);
}
const r2 = rng()*2-1;
T.items.push(newChainItem(n, {th0:(k+0.5)/Nn*TAU + r2*0.15*(TAU/Nn), r1:rng()*2-1, r2:r2, ph:rng()*TAU}));
}
T.posA = pos; T.norA = nor;
T.mesh = new THREE.Mesh(makeDynGeo(pos,nor,at,idx), matTent);
T.mesh.frustumCulled = false; T.mesh.renderOrder = 0;
scene.add(T.mesh);
}
const _v = new THREE.Vector3(), _d = new THREE.Vector3(), _up = new THREE.Vector3();
function updateTents(dt, stepping){
if(!T.mesh) return;
const Nn = T.items.length, n = P.tentSeg, sd = P.tentSides, o = T.opt;
o.flow = P.tentFlow; o.grav = P.tentGravity; o.drag = P.drag; o.stiff = P.tentStiff; o.lag = P.inertia;
o.coil = P.tentCoil; o.coilFreq = P.tentCoilFreq; o.coilSpeed = P.tentCoilSpeed;
const cRing = contractionAt(P.tentRingT);
const pos = T.posA, nor = T.norA;
for(let k=0;k<Nn;k++){
const it = T.items[k];
let th = it.th0;
if(P.tentCluster > 0.001 && P.lobeCount > 0){
const st = TAU/P.lobeCount, c = Math.round(th/st)*st;
th = lerp(th,c,P.tentCluster) + it.r2*st*0.06;
}
bellVertex(P.tentRingT, th, cRing, _o);
_v.set(_o.x,_o.y,_o.z).applyMatrix4(group.matrixWorld);
_d.set(Math.cos(th)*P.tentSpread, -1, Math.sin(th)*P.tentSpread).transformDirection(group.matrixWorld);
const lenMul = Math.max(0.08, (1+P.tentLenVar*it.r1)*(1-P.tentAlt*(k&1)));
const len = P.tentLen*lenMul/n;
if(it.init) initChain(it, n, len, _v, _d);
if(stepping){ o.phase = it.ph; stepChain(it,n,len,_v.x,_v.y,_v.z,_d.x,_d.y,_d.z,dt,o); }
else { it.pos[0]=_v.x; it.pos[1]=_v.y; it.pos[2]=_v.z; }
// Röhre aufbauen
frames(it.pos,n);
const base = k*(n+1)*sd, p = it.pos;
for(let i=0;i<=n;i++){
const f = i*9, s = i/n;
let r = P.tentThick*(1-P.tentTaper*s*0.92);
if(P.tentBeads > 0.001){ const bb = 0.5+0.5*Math.sin(s*P.beadFreq*TAU+it.ph); r *= 1+P.tentBeads*2*bb*bb*bb*bb; }
for(let m=0;m<sd;m++){
const ang = m/sd*TAU, ca = Math.cos(ang), sa = Math.sin(ang);
const ex = FR[f+3]*ca+FR[f+6]*sa, ey = FR[f+4]*ca+FR[f+7]*sa, ez = FR[f+5]*ca+FR[f+8]*sa;
const vi = (base+i*sd+m)*3;
pos[vi]=p[i*3]+ex*r; pos[vi+1]=p[i*3+1]+ey*r; pos[vi+2]=p[i*3+2]+ez*r;
nor[vi]=ex; nor[vi+1]=ey; nor[vi+2]=ez;
}
}
}
T.mesh.geometry.attributes.position.needsUpdate = true;
T.mesh.geometry.attributes.normal.needsUpdate = true;
}
/* ---- Mundarme (gekräuselte Bänder, 9 Stützpunkte quer) ---- */
const ACOLS = 9;
const A = {mesh:null, items:[], posA:null, norA:null, opt:{flow:1,grav:0.2,drag:0.1,stiff:0.16,lag:0.7,coil:0,coilFreq:1,coilSpeed:1,phase:0}};
function buildArms(){
disposeObj(A.mesh); A.mesh = null; A.items = [];
const Na = P.armCount; if(Na < 1) return;
const n = P.armSeg, rng = mulberry32(P.seed*29+3);
const vp = (n+1)*ACOLS, total = Na*vp;
const pos = new Float32Array(total*3), nor = new Float32Array(total*3), at = new Float32Array(total);
const idx = [];
for(let k=0;k<Na;k++){
const b0 = k*vp;
for(let i=0;i<=n;i++) for(let c=0;c<ACOLS;c++) at[b0+i*ACOLS+c] = i/n;
for(let i=0;i<n;i++) for(let c=0;c<ACOLS-1;c++){
const a=b0+i*ACOLS+c, b=a+1, c2=b0+(i+1)*ACOLS+c, d=c2+1;
idx.push(a,c2,b, b,c2,d);
}
A.items.push(newChainItem(n, {th0:k/Na*TAU + 0.3, r1:rng()*2-1, ph:rng()*TAU}));
}
A.posA = pos; A.norA = nor;
A.mesh = new THREE.Mesh(makeDynGeo(pos,nor,at,idx), matArm);
A.mesh.frustumCulled = false; A.mesh.renderOrder = 0.5;
scene.add(A.mesh);
}
function updateArms(dt, stepping){
if(!A.mesh) return;
const Na = A.items.length, n = P.armSeg, o = A.opt;
o.flow = P.armFlow; o.grav = P.tentGravity; o.drag = P.drag; o.stiff = P.armStiff; o.lag = P.inertia;
const py = P.bellHeight*0.5, kk = 1-P.shellThick;
const tA = Math.max(0.02, P.armAttach), cA = contractionAt(tA);
const pos = A.posA, nor = A.norA;
for(let k=0;k<Na;k++){
const it = A.items[k], th = it.th0;
bellVertex(tA, th, cA, _o);
_v.set(_o.x*kk, py+(_o.y-py)*kk, _o.z*kk).applyMatrix4(group.matrixWorld);
_d.set(Math.cos(th)*0.15, -1, Math.sin(th)*0.15).transformDirection(group.matrixWorld);
const len = P.armLen*(1+0.2*it.r1)/n;
if(it.init) initChain(it, n, len, _v, _d);
if(stepping){ o.phase = it.ph; stepChain(it,n,len,_v.x,_v.y,_v.z,_d.x,_d.y,_d.z,dt,o); }
else { it.pos[0]=_v.x; it.pos[1]=_v.y; it.pos[2]=_v.z; }
frames(it.pos,n);
const base = k*(n+1)*ACOLS, p = it.pos;
for(let i=0;i<=n;i++){
const f = i*9, s = i/n;
const nx=FR[f+3], ny=FR[f+4], nz=FR[f+5], bx=FR[f+6], by=FR[f+7], bz=FR[f+8];
const ang = P.armTwist*TAU*s, ca = Math.cos(ang), sa = Math.sin(ang);
const sx = nx*ca+bx*sa, sy = ny*ca+by*sa, sz = nz*ca+bz*sa; // Querrichtung
const dx = -nx*sa+bx*ca, dy = -ny*sa+by*ca, dz = -nz*sa+bz*ca; // Flächennormale
const w = P.armWidth*(1-P.armTaper*s);
const ro = P.armRuffle*w*(Math.sin(s*P.armRuffleFreq*TAU+time*1.3+it.ph)
+ 0.35*Math.sin(s*P.armRuffleFreq*2.3*TAU-time*0.9+it.ph*2))*(0.25+0.75*s);
const cx = p[i*3], cy = p[i*3+1], cz = p[i*3+2];
for(let c=0;c<ACOLS;c++){
const u = c/(ACOLS-1)*2-1;
const off = ro*u*Math.abs(u) + w*0.10*Math.sin(u*3+s*5+it.ph);
const v0 = (base+i*ACOLS+c)*3;
pos[v0] = cx+sx*w*u+dx*off;
pos[v0+1] = cy+sy*w*u+dy*off;
pos[v0+2] = cz+sz*w*u+dz*off;
nor[v0]=dx; nor[v0+1]=dy; nor[v0+2]=dz;
}
}
}
A.mesh.geometry.attributes.position.needsUpdate = true;
A.mesh.geometry.attributes.normal.needsUpdate = true;
}
/* ---- Flowfield-Anzeige ---- */
const FV = {mesh:null, base:[]};
(function(){
const nx=5, ny=6, nz=5;
for(let a=0;a<nx;a++) for(let b=0;b<ny;b++) for(let c=0;c<nz;c++){
FV.base.push([lerp(-3,3,a/(nx-1)), lerp(1.5,-4,b/(ny-1)), lerp(-3,3,c/(nz-1))]);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(FV.base.length*6),3));
FV.mesh = new THREE.LineSegments(g, matField);
FV.mesh.frustumCulled = false; FV.mesh.renderOrder = 5;
scene.add(FV.mesh);
})();
function updateField(){
FV.mesh.visible = !!P.showField;
if(!P.showField) return;
const arr = FV.mesh.geometry.attributes.position.array;
const ox = Math.round(target.x/1.5)*1.5, oy = Math.round(target.y/1.1)*1.1, oz = Math.round(target.z/1.5)*1.5;
for(let i=0;i<FV.base.length;i++){
const b = FV.base[i], bx = b[0]+ox, by = b[1]+oy, bz = b[2]+oz;
flowVec(bx,by,bz,time,_F);
arr[i*6]=bx; arr[i*6+1]=by; arr[i*6+2]=bz;
arr[i*6+3]=bx+_F.x*0.7; arr[i*6+4]=by+_F.y*0.7; arr[i*6+5]=bz+_F.z*0.7;
}
FV.mesh.geometry.attributes.position.needsUpdate = true;
}
/* ------------------------------------------------------------------ */
/* Simulation */
/* ------------------------------------------------------------------ */
const bodyPos = new THREE.Vector3(), vel = new THREE.Vector3();
const qBody = new THREE.Quaternion(), qTarget = new THREE.Quaternion(), qWob = new THREE.Quaternion();
const eWob = new THREE.Euler(), heading = new THREE.Vector3(), _Y = new THREE.Vector3(0,1,0), _vp = new THREE.Vector3();
let paused = false, spin = 0;
function step(dt, stepping){
if(stepping){
time += dt;
const irr = 1 + P.pulseIrr*0.6*noiseF(time*0.23, 3.3, 8.8);
phase += P.pulseRate*irr*dt;
const c0 = contractionAt(0.4), dc = c0 - prevC0; prevC0 = c0;
// Schwimmrichtung: Grundrichtung plus langsames Wandern (Perlin)
const yaw = P.swimYaw*Math.PI/180 + P.steer*1.3*noiseF(time*0.04, 4.1, 2.7);
const el = clamp(P.swimElev*Math.PI/180 + P.steer*0.5*noiseF(time*0.05, 8.3, 1.9), -1.35, 1.35);
heading.set(Math.cos(el)*Math.sin(yaw), Math.sin(el), Math.cos(el)*Math.cos(yaw));
qTarget.setFromUnitVectors(_Y, heading);
qBody.slerp(qTarget, 1-Math.exp(-dt*P.turnRate));
// leichtes Taumeln und langsames Drehen um die eigene Achse
spin += P.bodyTilt*0.6*noiseF(time*0.1, 9.1, 3.3)*dt;
eWob.set(P.bodyTilt*0.9*noiseF(time*0.17,1.7,2.2), spin, P.bodyTilt*0.9*noiseF(time*0.15,7.7,4.1));
qWob.setFromEuler(eWob);
group.quaternion.copy(qBody).multiply(qWob);
// Schub entlang der Körperachse, nur während der Kontraktion
_up.copy(_Y).applyQuaternion(group.quaternion);
if(dc > 0) vel.addScaledVector(_up, P.thrust*dc);
flowVec(bodyPos.x,bodyPos.y,bodyPos.z,time,_F,1);
vel.x += _F.x*P.drift*dt*0.8; vel.y += _F.y*P.drift*dt*0.8; vel.z += _F.z*P.drift*dt*0.8;
vel.y -= 0.12*dt;
// seitliches Rutschen stärker bremsen als Vorwärtsbewegung
_vp.copy(_up).multiplyScalar(vel.dot(_up));
vel.sub(_vp).multiplyScalar(Math.exp(-3.0*dt)).add(_vp);
vel.multiplyScalar(Math.exp(-1.0*dt));
bodyPos.addScaledVector(vel, dt);
}
group.position.copy(bodyPos);
group.updateMatrixWorld(true);
updateBell();
updateLayers();
updateFrills();
updateTents(dt, stepping);
updateArms(dt, stepping);
updateInner();
updateField();
}
/* Schwebeteilchen: ruhen in der Welt und zeigen die Fahrt der Kamera */
const SNOW_N = 1400, SNOW_BOX = 26;
const snow = {pos:new Float32Array(SNOW_N*3), pts:null};
(function(){
const rnd = mulberry32(77);
for(let i=0;i<SNOW_N*3;i++) snow.pos[i] = (rnd()-0.5)*SNOW_BOX;
const g = new THREE.BufferGeometry();
const a = new THREE.BufferAttribute(snow.pos,3); a.setUsage(THREE.DynamicDrawUsage);
g.setAttribute('position', a);
let map = null;
try{
const cv = document.createElement('canvas'); cv.width = cv.height = 32;
const cx = cv.getContext('2d');
if(cx){
const gr = cx.createRadialGradient(16,16,0,16,16,16);
gr.addColorStop(0,'rgba(255,255,255,1)'); gr.addColorStop(0.5,'rgba(255,255,255,0.6)'); gr.addColorStop(1,'rgba(255,255,255,0)');
cx.fillStyle = gr; cx.fillRect(0,0,32,32);
map = new THREE.CanvasTexture(cv);
}
}catch(e){}
const m = new THREE.PointsMaterial({size:0.05, map:map, transparent:true, opacity:0.55, depthWrite:false, sizeAttenuation:true, color:0xffffff});
snow.mat = m;
snow.pts = new THREE.Points(g, m);
snow.pts.frustumCulled = false; snow.pts.renderOrder = -1;
scene.add(snow.pts);
})();
function updateSnow(dt, stepping){
const n = Math.floor(SNOW_N*P.snowAmount);
snow.pts.visible = n > 0;
if(n < 1) return;
snow.pts.geometry.setDrawRange(0, n);
snow.mat.size = P.snowSize;
snow.mat.color.set(P.swap ? P.cDark : P.cLight);
const p = snow.pos, cx = target.x, cy = target.y, cz = target.z;
for(let i=0;i<n;i++){
const q = i*3;
let x = p[q], y = p[q+1], z = p[q+2];
if(stepping){
flowVec(x,y,z,time,_F,1);
x += _F.x*0.15*dt; y += _F.y*0.15*dt; z += _F.z*0.15*dt;
}
let dx = x-cx, dy = y-cy, dz = z-cz;
dx -= SNOW_BOX*Math.round(dx/SNOW_BOX); dy -= SNOW_BOX*Math.round(dy/SNOW_BOX); dz -= SNOW_BOX*Math.round(dz/SNOW_BOX);
p[q] = cx+dx; p[q+1] = cy+dy; p[q+2] = cz+dz;
}
snow.pts.geometry.attributes.position.needsUpdate = true;
}
/* ------------------------------------------------------------------ */
/* Neuaufbau-Warteschlange */
/* ------------------------------------------------------------------ */
const pend = new Set();
function queue(k){
if(k === 'all'){ ['bell','layers','frills','tent','arms','organs','canals'].forEach(x => pend.add(x)); return; }
pend.add(k);
if(k === 'bell'){ pend.add('canals'); pend.add('layers'); }
}
function flush(){
if(!pend.size) return;
if(pend.has('bell')) buildBell();
if(pend.has('layers')) buildLayers();
if(pend.has('frills')) buildFrills();
if(pend.has('tent')) buildTents();
if(pend.has('arms')) buildArms();
if(pend.has('organs')) buildOrgans();
if(pend.has('canals')) buildCanals();
pend.clear();
}
/* ------------------------------------------------------------------ */
/* Zufall, Mutation, Palette */
/* ------------------------------------------------------------------ */
function randomize(){
P.seed = 1 + Math.floor(Math.random()*99998);
SCHEMA.forEach(g => g.items.forEach(it => {
if(it.type || !it.rr) return;
let v;
if(it.p0 && Math.random() < it.p0) v = Math.max(it.min, 0);
else v = lerp(it.rr[0], it.rr[1], Math.random());
if(it.int) v = Math.round(v);
P[it.k] = clamp(v, it.min, it.max);
}));
seedNoise(); queue('all'); resetChains(); refreshUI();
}
function mutate(){
SCHEMA.forEach(g => g.items.forEach(it => {
if(it.rb) return;
if(it.type === 'bool') return;
if(it.type === 'color') return;
let v = P[it.k] + (Math.random()-0.5)*2*0.12*(it.max-it.min);
if(it.int) v = Math.round(v);
P[it.k] = clamp(v, it.min, it.max);
}));
refreshUI();
}
function resetChains(){ T.items.forEach(i => { i.init = true; }); A.items.forEach(i => { i.init = true; }); }
/* ------------------------------------------------------------------ */
/* Oberfläche */
/* ------------------------------------------------------------------ */
const UI = {};
function fmt(it,v){
if(it.int) return String(v);
const r = it.max-it.min, d = r >= 50 ? 0 : r >= 5 ? 1 : r >= 0.5 ? 2 : 3;
return (+v).toFixed(d);
}
function buildUI(){
const host = document.getElementById('groups');
SCHEMA.forEach(g => {
const det = document.createElement('details'); if(g.open) det.open = true;
const sum = document.createElement('summary'); sum.textContent = g.t; det.appendChild(sum);
g.items.forEach(it => {
const row = document.createElement('div'); row.className = 'row';
const lbl = document.createElement('span'); lbl.textContent = it.l;
const val = document.createElement('span'); val.className = 'val';
const inp = document.createElement('input');
if(it.type === 'bool'){
inp.type = 'checkbox'; inp.checked = !!P[it.k]; row.classList.add('inline');
inp.addEventListener('input', () => { P[it.k] = inp.checked; });
row.appendChild(lbl); row.appendChild(inp);
} else if(it.type === 'color'){
inp.type = 'color'; inp.value = P[it.k]; row.classList.add('inline');
inp.addEventListener('input', () => { P[it.k] = inp.value; });
row.appendChild(lbl); row.appendChild(inp);
} else {
inp.type = 'range'; inp.min = it.min; inp.max = it.max; inp.step = it.step; inp.value = P[it.k];
val.textContent = fmt(it,P[it.k]);
inp.addEventListener('input', () => {
const v = it.int ? parseInt(inp.value,10) : parseFloat(inp.value);
P[it.k] = v; val.textContent = fmt(it,v);
if(it.rb) queue(it.rb);
});
row.appendChild(lbl); row.appendChild(val); row.appendChild(inp);
}
UI[it.k] = {it:it, inp:inp, val:val};
det.appendChild(row);
});
host.appendChild(det);
});
}
function refreshUI(){
for(const k in UI){
const u = UI[k], v = P[k];
if(u.it.type === 'bool') u.inp.checked = !!v;
else if(u.it.type === 'color') u.inp.value = v;
else { u.inp.value = v; u.val.textContent = fmt(u.it,v); }
}
document.getElementById('seed').value = P.seed;
}
buildUI();
document.getElementById('seed').addEventListener('change', e => {
P.seed = clamp(parseInt(e.target.value,10) || 1, 1, 99999);
e.target.value = P.seed;
seedNoise(); queue('all'); resetChains();
});
document.getElementById('bRand').addEventListener('click', randomize);
document.getElementById('bMut').addEventListener('click', mutate);
document.getElementById('bReset').addEventListener('click', () => {
Object.keys(DEFAULTS).forEach(k => { P[k] = DEFAULTS[k]; });
seedNoise(); queue('all'); resetChains(); refreshUI();
});
const bPause = document.getElementById('bPause');
function setPause(v){ paused = v; bPause.textContent = paused ? 'Weiter' : 'Pause'; }
bPause.addEventListener('click', () => setPause(!paused));
window.addEventListener('keydown', e => {
if(e.code === 'Space' && !/^(INPUT|BUTTON|TEXTAREA)$/.test(e.target.tagName)){ e.preventDefault(); setPause(!paused); }
});
document.getElementById('bCopy').addEventListener('click', () => {
const txt = JSON.stringify(P);
const done = () => flash('Parameter kopiert');
if(navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done, () => window.prompt('Parameter kopieren:', txt));
else window.prompt('Parameter kopieren:', txt);
});
document.getElementById('bLoad').addEventListener('click', () => {
const txt = window.prompt('Parameter-JSON einfügen:');
if(!txt) return;
try{
const o = JSON.parse(txt);
Object.keys(P).forEach(k => {
if(!(k in o) || typeof o[k] !== typeof P[k]) return;
const u = UI[k];
P[k] = (u && !u.it.type) ? clamp(o[k], u.it.min, u.it.max) : o[k];
});
P.seed = clamp(Math.round(P.seed) || 1, 1, 99999);
seedNoise(); queue('all'); resetChains(); refreshUI(); flash('Parameter geladen');
}catch(err){ flash('Das war kein gültiges JSON'); }
});
const statusEl = document.getElementById('status'); let flashT = 0;
function flash(msg){ statusEl.textContent = msg; clearTimeout(flashT); flashT = setTimeout(() => { statusEl.textContent = ''; }, 1800); }
/* ------------------------------------------------------------------ */
/* Start */
/* ------------------------------------------------------------------ */
queue('all');
refreshUI();
let last = performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt = clamp((now-last)/1000, 0, 1/30); last = now;
flush();
if(P.autoRot > 0) az += dt*P.autoRot*0.5;
updateCamera();
camera.updateMatrixWorld();
camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
applyMaterials();
const sdt = paused ? 0 : dt*P.timeScale;
step(sdt, sdt > 1e-5);
if(P.camFollow){
camGoal.set(bodyPos.x, bodyPos.y-0.7, bodyPos.z);
target.lerp(camGoal, 1-Math.exp(-dt/Math.max(0.05,P.camSmooth)));
}
updateSnow(sdt, sdt > 1e-5);
renderer.render(scene, camera);
}
requestAnimationFrame(frame);
window.__medusoid = {P:P, T:T, A:A, B:B}; // zum Debuggen in der Konsole
})();
</script>
</body>
</html>