index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coral – Blob-Wachstum v6</title>
<style>
:root{--ink:#161616;--mut:#8b8b8b;--line:#e8e8e8;--acc:#ff4d6d}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#fff;color:var(--ink);
font:12px/1.45 -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif}
#c{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
#ui{position:fixed;top:12px;left:12px;width:276px;max-height:calc(100% - 24px);overflow:auto;
background:#fff;border:1px solid var(--line);border-radius:10px;padding:12px 12px 6px}
#ui.hide{display:none}
#menu{position:fixed;top:12px;right:12px;background:#fff;border:1px solid var(--line);border-radius:8px;
padding:6px 10px;cursor:pointer;font:inherit}
h1{font-size:14px;margin:0 0 2px;font-weight:600}
.sub{color:var(--mut);margin:0 0 10px}
.row{display:flex;gap:6px;margin:6px 0}
button,select{font:inherit;color:var(--ink);background:#fff;border:1px solid #dcdcdc;border-radius:7px;padding:5px 9px;cursor:pointer}
button:hover{background:#f6f6f6}
button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
button.pk.on{background:var(--acc);border-color:var(--acc)}
button.grow{flex:1}
select{width:100%}
details{border-top:1px solid var(--line);padding:7px 0}
summary{cursor:pointer;font-weight:600;list-style:none;display:flex;justify-content:space-between}
summary::after{content:"+";color:var(--mut);font-weight:400}
details[open] summary::after{content:"–"}
.sl{display:block;margin:7px 0 0}
.sl .t{display:flex;justify-content:space-between;color:#555}
.sl .t b{font-weight:400;color:var(--mut);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;margin:2px 0 0;accent-color:var(--ink);height:16px}
.chk{display:flex;align-items:center;gap:7px;margin:7px 0 0;color:#555;cursor:pointer}
.chk input{accent-color:var(--ink);margin:0}
input[type=color]{width:100%;height:24px;border:1px solid #dcdcdc;border-radius:6px;padding:1px;background:#fff}
pre{margin:8px 0 0;padding:8px;background:#fafafa;border:1px solid var(--line);border-radius:7px;
font:11px/1.5 ui-monospace,Menlo,monospace;color:#555;white-space:pre-wrap}
.flist{display:flex;flex-wrap:wrap;gap:5px;margin:6px 0}
.flist button{padding:3px 8px}
.flist button.sel{border-color:var(--acc);color:var(--acc)}
.note{color:var(--mut);margin:6px 0 0}
label.file{display:block;margin:7px 0 0;color:#555}
label.file input{display:block;width:100%;margin-top:3px;font:inherit}
#stat{position:fixed;left:14px;bottom:10px;color:var(--mut);pointer-events:none}
#hint{position:fixed;right:14px;bottom:10px;color:var(--mut);pointer-events:none;text-align:right}
button:disabled{opacity:.4;cursor:default}
button:disabled:hover{background:#fff}
.prog{display:none;margin:8px 0 2px}
.prog.on{display:block}
.prog .bar{height:4px;background:#eee;border-radius:2px;overflow:hidden}
.prog .bar i{display:block;height:100%;width:0;background:var(--acc);transition:width .15s}
.prog span{display:block;color:var(--mut);margin-top:4px}
#busy{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border:1px solid var(--line);
border-radius:8px;padding:8px 14px;display:none;color:#555}
</style>
</head>
<body>
<canvas id="c"></canvas>
<button id="menu">Menü</button>
<div id="busy">Berechne Blob-Mesh …</div>
<div id="ui">
<h1>Coral</h1>
<p class="sub">Blob-Korallen mit dickem Stamm und Vertex Paint.</p>
<div class="row">
<button class="grow" id="bGrow">Wachsen</button>
<button id="bDice">Neu würfeln</button>
</div>
<label class="sl"><span class="t">Zeit<b id="tlv"></b></span><input type="range" id="tl" min="0" max="1" step="0.001" value="0"></label>
<details open id="dX"><summary>Starres Modell</summary>
<p class="note">Rechnet den ausgewachsenen Zustand ohne Animation in hoher Auflösung und zeigt ihn statt der Animation.</p>
<div id="gX"></div>
<label class="chk"><input type="checkbox" id="expHead">Kopf mitexportieren</label>
<div class="row"><button class="grow" id="bStatic">Starr rendern</button><button id="bCancel" disabled>Abbrechen</button></div>
<div class="prog" id="prog"><div class="bar"><i id="progBar"></i></div><span id="progTxt"></span></div>
<div class="row"><button class="grow" id="bDownload" disabled>Herunterladen (GLB)</button></div>
<div class="row"><button class="grow" id="bBack" disabled>Zurück zur Animation</button></div>
</details>
<details open id="dP"><summary>Verteilung auf dem Kopf</summary>
<p class="note">Mit Vertex Paint bestimmst du, wo Korallen sitzen. Hellere Bereiche werden dichter besetzt. Die Korallen wachsen erst neu, wenn du auf „Neu wachsen“ drückst.</p>
<div class="row"><button class="grow" id="bRegrow">Neu wachsen</button></div>
<div class="row">
<button id="bPaint" class="pk">Malen</button>
<button id="bErase">Radieren</button>
</div>
<div class="row">
<button id="bFill">Alles füllen</button>
<button id="bClear">Leeren</button>
</div>
<div id="gP"></div>
</details>
<details open id="dL"><summary>L-System</summary>
<label class="sl"><span class="t">Wuchsform</span>
<select id="preset">
<option value="tentakel">Tentakel</option>
<option value="mix">Mix</option>
<option value="gabel">Gabel (dichotom)</option>
<option value="finger">Finger (lateral)</option>
<option value="bukett">Bukett (trifurk)</option>
</select></label>
<pre id="rules"></pre>
</details>
<details open id="dB"><summary>Blob-Form</summary>
<div id="gB"></div>
</details>
<details id="dF"><summary>Kraftfelder</summary>
<label class="chk"><input type="checkbox" id="fOn">Kraftfelder aktiv</label>
<p class="note">Ein Feld verlängert das Wachstum, solange eine Spitze darin liegt, und lenkt sie ab.</p>
<div class="row">
<button id="fAdd">Feld hinzufügen</button>
<button id="fDel">Löschen</button>
</div>
<div class="flist" id="flist"></div>
<div id="fsel" style="display:none">
<label class="sl"><span class="t">Art</span>
<select id="fMode">
<option value="attract">Anziehen</option>
<option value="dir">Richtung (Pfeil)</option>
<option value="repel">Abstoßen</option>
</select></label>
<label class="sl"><span class="t">Stärke<b id="fStrV"></b></span><input type="range" id="fStr" min="0" max="1" step="0.01"></label>
<label class="sl"><span class="t">Radius<b id="fRadV"></b></span><input type="range" id="fRad" min="0.2" max="2.5" step="0.01"></label>
<div class="row">
<button id="tmT" class="on">Verschieben</button>
<button id="tmR">Drehen</button>
<button id="tmS">Dehnen</button>
</div>
</div>
<label class="chk"><input type="checkbox" id="fShow" checked>Felder anzeigen</label>
<div id="gF"></div>
</details>
<details id="dS"><summary>Struktur</summary><div id="gS"></div></details>
<details open id="dM"><summary>Bewegung</summary><div id="gM"></div></details>
<details id="dT"><summary>Material und Kopf</summary>
<div id="gT"></div>
<label class="chk"><input type="checkbox" id="rigShow">Rig anzeigen</label>
<label class="file">Kopf laden (.glb)<input type="file" id="fileHead" accept=".glb,.gltf"></label>
<label class="file">Normal Map laden<input type="file" id="fileNrm" accept="image/*"></label>
</details>
<div style="height:6px"></div>
</div>
<div id="stat"></div>
<div id="hint">Maus: Korallen meiden · Klick: anziehen · Rechte Taste: Kamera · G / R / S: Feld bewegen, drehen, dehnen</div>
<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>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { mergeGeometries, mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
const $ = s => document.querySelector(s);
const ACC = 0xff4d6d;
/*==CORE_A==*/
const V3 = THREE.Vector3;
const SUB = 1; // Bones pro L-System-Segment (Live-Animation: ein Bone je Knoten)
const GEO = 2; // Unterteilung der Kurve fürs Mesh (ohne zusätzliche Bones)
const PATHREF = 0.4; // Pfadlänge, ab der ein Ast voll biegsam ist
const P = {
density: 5, sym: true, seed: 7, preset: 'mix',
iter: 6, lenScale: 1.3, branching: 0.5, angle: 30, angleVar: 0.4, wobble: 0.06, curl: 0.1,
segLen: 0.2, lenDecay: 0.92, radius: 0.05, rTip: 0.026, rRatio: 0.8,
trunk: 1.8, trunkLen: 0.2,
tropUp: 0.25, stop: 0.03,
blob: 0.3, blobFreq: 8, club: 0.3, merge: 2.0, voxel: 0.024, maxBones: 10000, maxVerts: 600000, baseSize: 0, smoothIt: 6, maxCells: 5, fuse: false,
fieldsOn: false, boost: 4, stretch: 0.6, bend: 0.6,
flow: 2.6, flowScale: 2.0, flowSpeed: 0.4, sway: 2.0, stiffExp: 0.8, thin: 0.7, vari: 0.6,
wave: 0.5, waveFreq: 3, waveSpeed: 1.4,
mouseR: 0.35, mouseF: 2.2, speed: 1.4,
brush: 0.18, brushStr: 0.6, paintMode: false, erase: false,
hiVerts: 2.5, hiVox: 0.004, hiSmooth: 8,
coral: '#ffffff', rough: 0.22, bg: '#f2f2f0', rig: false
};
const PRESETS = {
tentakel: { fork: .07, tri: 0, lat: .10, cont: .83 },
mix: { fork: .40, tri: .12, lat: .26, cont: .22 },
gabel: { fork: .72, tri: 0, lat: .06, cont: .22 },
finger: { fork: .12, tri: 0, lat: .52, cont: .36 },
bukett: { fork: .22, tri: .46, lat: 0, cont: .32 }
};
/* ---------- Zufall + Noise ---------- */
function hash32(a){ a = Math.imul(a ^ (a >>> 16), 0x85ebca6b); a = Math.imul(a ^ (a >>> 13), 0xc2b2ae35); return (a ^ (a >>> 16)) >>> 0; }
function rand01(id, k){ return hash32((Math.imul(id | 0, 374761393) + Math.imul(k | 0, 668265263) + 12345) | 0) / 4294967296; }
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; }; }
const perm = new Uint8Array(512);
(function(){ const p = []; for (let i = 0; i < 256; i++) p.push(i); const r = mulberry32(1337);
for (let i = 255; i > 0; i--){ const j = Math.floor(r() * (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]; })();
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
const lerp = (t, a, b) => a + t * (b - a);
function grad(h, x, y, z){ h &= 15; const u = h < 8 ? x : y, v = h < 4 ? y : (h === 12 || h === 14 ? x : z); return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); }
function noise3(x, y, z){
const fx = Math.floor(x), fy = Math.floor(y), fz = Math.floor(z);
const X = fx & 255, Y = fy & 255, Z = fz & 255; x -= fx; y -= fy; z -= fz;
const u = fade(x), v = fade(y), w = fade(z);
const A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z, B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;
return lerp(w,
lerp(v, lerp(u, grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z)), lerp(u, grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z))),
lerp(v, lerp(u, grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1)), lerp(u, grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1))));
}
const smooth01 = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
/* ---------- Kopf-Daten: Oberfläche, Paint-Gewichte, Sampler ---------- */
let headPos = null, headNrm = null, headIdx = null, headPaint = null;
let triArea = null, cumA = null, cumW = null, totalW = 0, headArea = 1;
let sPaint = 1;
const CELL = 0.12, hashGrid = new Map();
let hSamples = null;
const hkey = (x, y, z) => ((x + 64) * 128 + (y + 64)) * 128 + (z + 64);
function initHeadData(geo, keepPaint){
headPos = geo.attributes.position.array; headNrm = geo.attributes.normal.array; headIdx = geo.index.array;
const nV = headPos.length / 3, tc = headIdx.length / 3;
if (!keepPaint || !headPaint || headPaint.length !== nV){ headPaint = new Float32Array(nV).fill(1); }
triArea = new Float32Array(tc); cumA = new Float32Array(tc); cumW = new Float32Array(tc);
let acc = 0;
for (let t = 0; t < tc; t++){
const a = headIdx[t * 3] * 3, b = headIdx[t * 3 + 1] * 3, c = headIdx[t * 3 + 2] * 3;
const ux = headPos[b] - headPos[a], uy = headPos[b + 1] - headPos[a + 1], uz = headPos[b + 2] - headPos[a + 2];
const vx = headPos[c] - headPos[a], vy = headPos[c + 1] - headPos[a + 1], vz = headPos[c + 2] - headPos[a + 2];
const cx = uy * vz - uz * vy, cy = uz * vx - ux * vz, cz = ux * vy - uy * vx;
triArea[t] = 0.5 * Math.sqrt(cx * cx + cy * cy + cz * cz); acc += triArea[t]; cumA[t] = acc;
}
headArea = acc;
updatePaintWeights();
buildSurfaceHash();
}
function updatePaintWeights(){
let acc = 0; const tc = triArea.length;
for (let t = 0; t < tc; t++){
const a = headIdx[t * 3], b = headIdx[t * 3 + 1], c = headIdx[t * 3 + 2];
acc += triArea[t] * (headPaint[a] + headPaint[b] + headPaint[c]) / 3; cumW[t] = acc;
}
totalW = acc;
}
function sampleSurface(useW, rnd, outP, outN){
const cum = useW ? cumW : cumA, tc = cum.length, total = cum[tc - 1];
const r = rnd() * total; let lo = 0, hi = tc - 1;
while (lo < hi){ const mid = (lo + hi) >> 1; if (cum[mid] < r) lo = mid + 1; else hi = mid; }
let u = rnd(), v = rnd(); if (u + v > 1){ u = 1 - u; v = 1 - v; } const w = 1 - u - v;
const a = headIdx[lo * 3], b = headIdx[lo * 3 + 1], c = headIdx[lo * 3 + 2];
outP.set(w * headPos[a * 3] + u * headPos[b * 3] + v * headPos[c * 3],
w * headPos[a * 3 + 1] + u * headPos[b * 3 + 1] + v * headPos[c * 3 + 1],
w * headPos[a * 3 + 2] + u * headPos[b * 3 + 2] + v * headPos[c * 3 + 2]);
outN.set(w * headNrm[a * 3] + u * headNrm[b * 3] + v * headNrm[c * 3],
w * headNrm[a * 3 + 1] + u * headNrm[b * 3 + 1] + v * headNrm[c * 3 + 1],
w * headNrm[a * 3 + 2] + u * headNrm[b * 3 + 2] + v * headNrm[c * 3 + 2]).normalize();
sPaint = w * headPaint[a] + u * headPaint[b] + v * headPaint[c];
}
function buildSurfaceHash(){
hashGrid.clear();
const rnd = mulberry32(99), n = 40000, p = new V3(), nn = new V3();
hSamples = new Float32Array(n * 6);
for (let i = 0; i < n; i++){
sampleSurface(false, rnd, p, nn);
hSamples.set([p.x, p.y, p.z, nn.x, nn.y, nn.z], i * 6);
const k = hkey(Math.floor(p.x / CELL), Math.floor(p.y / CELL), Math.floor(p.z / CELL));
let l = hashGrid.get(k); if (!l){ l = []; hashGrid.set(k, l); } l.push(i);
}
}
const NS = { d: 1e9, nx: 0, ny: 1, nz: 0 };
function nearestSurface(p){
const cx = Math.floor(p.x / CELL), cy = Math.floor(p.y / CELL), cz = Math.floor(p.z / CELL);
let best = 1e9, bi = -1;
for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++){
const l = hashGrid.get(hkey(cx + x, cy + y, cz + z)); if (!l) continue;
for (const i of l){
const dx = hSamples[i * 6] - p.x, dy = hSamples[i * 6 + 1] - p.y, dz = hSamples[i * 6 + 2] - p.z;
const d = dx * dx + dy * dy + dz * dz; if (d < best){ best = d; bi = i; }
}
}
if (bi < 0) return null;
NS.d = Math.sqrt(best); NS.nx = hSamples[bi * 6 + 3]; NS.ny = hSamples[bi * 6 + 4]; NS.nz = hSamples[bi * 6 + 5];
return NS;
}
/* ---------- Seeds: Poisson-Disk, Abstand nach Paint-Intensität ---------- */
let seeds = [];
function makeSeeds(){
if (!headIdx || totalW <= 0){ seeds = []; return; }
const rnd = mulberry32(P.seed * 7919 + 1), dmin = 0.52 / P.density;
const capC = Math.max(2, Math.floor(P.maxBones / (1.8 * 12))), maxN = P.sym ? Math.floor(capC / 2) : capC;
const out = [], p = new V3(), n = new V3(), m = new V3();
let tries = 0, fails = 0;
while (out.length < maxN && tries < 90000 && fails < 6000){
tries++; sampleSurface(true, rnd, p, n);
if (sPaint < 0.06){ fails++; continue; }
const sp = dmin / (0.35 + 0.65 * sPaint);
if (P.sym && p.x < sp * 0.5){ fails++; continue; }
let ok = true;
for (const s of out){
const d = Math.max(sp, s.sp), d2 = d * d;
if (s.pos.distanceToSquared(p) < d2){ ok = false; break; }
if (P.sym){ m.set(-s.pos.x, s.pos.y, s.pos.z); if (m.distanceToSquared(p) < d2){ ok = false; break; } }
}
if (ok){ out.push({ pos: p.clone(), n: n.clone(), sp, paint: sPaint, id: hash32((Math.imul(P.seed, 1000003) + Math.imul(out.length + 1, 7919)) | 0) }); fails = 0; }
else fails++;
}
seeds = out;
}
/* ---------- Kraftfelder (Auswertung) ---------- */
let FS = [];
const _lp = new V3(), _mp = new V3();
function evalField(f, p, out, mir){
_lp.copy(p).applyMatrix4(f.inv); const d = _lp.length(); if (d >= 1) return;
const t = 1 - d, w = t * t * (3 - 2 * t) * f.strength;
let dx, dy, dz;
if (f.mode === 'dir'){ dx = f.ax; dy = f.ay; dz = f.az; }
else { dx = f.cx - p.x; dy = f.cy - p.y; dz = f.cz - p.z; const l = Math.hypot(dx, dy, dz) || 1; dx /= l; dy /= l; dz /= l;
if (f.mode === 'repel'){ dx = -dx; dy = -dy; dz = -dz; } }
if (f.mode === 'repel') out.neg += w; else out.pos += w;
out.B.x += (mir ? -dx : dx) * w; out.B.y += dy * w; out.B.z += dz * w;
}
function fieldAt(p){
const out = { pos: 0, neg: 0, B: new V3() };
for (const f of FS){
evalField(f, p, out, false);
if (P.sym && Math.abs(f.cx) > 0.01) evalField(f, _mp.set(-p.x, p.y, p.z), out, true);
}
out.pos = Math.min(1, out.pos); out.neg = Math.min(1, out.neg);
if (out.B.lengthSq() > 1) out.B.normalize();
return out;
}
/* ---------- L-System ---------- */
let segCap = 150;
function weights(fi){
const pr = PRESETS[P.preset], b = P.branching, bm = 0.4 + 1.2 * b, cm = Math.max(0.05, 1.6 - 1.2 * b);
let wf = pr.fork * bm, wt = pr.tri * bm, wl = pr.lat * bm, wc = pr.cont * cm;
if (fi){ wc *= 1 + fi.pos * 1.5; wf *= 1 - 0.5 * fi.pos; wt *= 1 - 0.5 * fi.pos; }
const s = wf + wt + wl + wc; return [wf / s, wt / s, wl / s, wc / s];
}
function frameOf(dir, ref){
const u = ref.clone().addScaledVector(dir, -ref.dot(dir));
if (u.lengthSq() < 1e-6) u.set(dir.y, -dir.x, 0.0001);
u.normalize(); return [u, new V3().crossVectors(dir, u)];
}
const _n = new V3();
/* Umweltsensitives, stochastisches L-System. A = Apex; pro Schritt entsteht ein Segment F,
danach Gabel, Trifurkation, Seitenast oder Fortsetzung. Felder verschieben Tiefe, Länge, Richtung. */
function randPerp(dir, id, k){
const [u, v] = frameOf(dir, new V3(0, 1, 0)), a = rand01(id, k) * 6.2832;
return u.multiplyScalar(Math.cos(a)).addScaledVector(v, Math.sin(a)).normalize();
}
function growCoral(seed){
const branches = []; let segs = 0;
const rb = P.baseSize * (seed.sp || 0.2);
const p0 = seed.pos.clone().addScaledVector(seed.n, 0.25 * rb - 0.012);
const d0 = seed.n.clone().add(new V3(0, P.tropUp * 0.3, 0)).normalize();
const rs = P.radius * (0.85 + 0.3 * rand01(seed.id, 50));
branches.push({ pts: [p0], gens: [0], parent: -1, parentCtrl: 0, rStart: rs, rEnd: rs });
let active = [{ pos: p0.clone(), dir: d0, up: Math.abs(d0.y) > 0.9 ? new V3(1, 0, 0) : new V3(0, 1, 0), r: rs,
len: P.segLen * P.lenScale * (0.85 + 0.3 * rand01(seed.id, 51)), gen: 1, maxGen: P.iter, id: seed.id, branch: 0, roll: rand01(seed.id, 52) * 6.2832,
cAxis: randPerp(d0, seed.id, 53), cAng: P.curl * (0.4 + rand01(seed.id, 54) * 1.2) * (rand01(seed.id, 55) < 0.5 ? -1 : 1) }];
const hardMax = P.iter + Math.ceil(P.boost) + 1;
while (active.length && segs < segCap){
const next = [];
for (const a of active){
if (segs >= segCap) break;
if (a.gen > hardMax) continue;
const fi = fieldAt(a.pos);
const allowed = a.maxGen + Math.round(P.boost * fi.pos) - Math.round(P.boost * 0.8 * fi.neg);
if (a.gen > allowed) continue;
const g8 = a.gen * 8;
let type;
if (a.gen === allowed || (a.gen > 2 && rand01(a.id, g8 + 6) < P.stop)) type = 'end';
else { const w = weights(fi), u = rand01(a.id, g8);
type = u < w[0] ? 'fork' : u < w[0] + w[1] ? 'tri' : u < w[0] + w[1] + w[2] ? 'lat' : 'cont'; }
// glatte Krümmung: Richtung dreht sich um eine langsam wandernde Achse
const dir = a.dir.clone();
let cAxis = a.cAxis;
if (a.cAng !== 0){
dir.applyAxisAngle(cAxis, a.cAng);
cAxis = cAxis.clone().applyAxisAngle(dir, (rand01(a.id, 100 + a.gen) - 0.5) * 0.5);
}
{ const [fu, fv] = frameOf(dir, a.up), ph = rand01(a.id, g8 + 2) * 6.2832, ea = P.wobble * rand01(a.id, g8 + 1);
dir.addScaledVector(fu, Math.cos(ph) * ea).addScaledVector(fv, Math.sin(ph) * ea); }
dir.y += P.tropUp * 0.12;
if (fi.B.lengthSq() > 1e-6) dir.addScaledVector(fi.B, P.bend * 0.45);
dir.normalize();
const segLen = a.len * (1 + P.stretch * fi.pos) * (1 - 0.35 * fi.neg);
const e = a.pos.clone().addScaledVector(dir, segLen);
const hit = nearestSurface(e);
if (hit && hit.d < 0.02 + a.r * 0.8){
_n.set(hit.nx, hit.ny, hit.nz); const dn = dir.dot(_n);
if (dn < 0.3){ dir.addScaledVector(_n, 0.3 - dn + 0.25).normalize(); e.copy(a.pos).addScaledVector(dir, segLen); }
}
const br = branches[a.branch];
br.pts.push(e); br.gens.push(a.gen); br.rEnd = Math.max(P.rTip, a.r * 0.93); segs++;
if (type === 'end') continue;
const [u, v] = frameOf(dir, a.up);
const ro = a.roll + 2.39996;
const axisAt = ang => u.clone().multiplyScalar(Math.cos(ang)).addScaledVector(v, Math.sin(ang)).normalize();
const spawn = (d, rMul, lMul, mg, idx, roll) => {
const r = Math.max(P.rTip, a.r * rMul), nid = hash32((Math.imul(a.id, 31) + a.gen * 977 + idx + 1) | 0);
d.normalize();
branches.push({ pts: [e.clone()], gens: [a.gen], parent: a.branch, parentCtrl: br.pts.length - 1, rStart: r, rEnd: r });
next.push({ pos: e.clone(), dir: d, up: u.clone(), r, len: a.len * lMul, gen: a.gen + 1, maxGen: mg, id: nid,
branch: branches.length - 1, roll, cAxis: randPerp(d, nid, 53),
cAng: P.curl * (0.4 + rand01(nid, 54) * 1.2) * (rand01(nid, 55) < 0.5 ? -1 : 1) });
};
const cont = () => next.push({ pos: e.clone(), dir: dir.clone(), up: u.clone(), r: Math.max(P.rTip, a.r * 0.94),
len: a.len * Math.sqrt(P.lenDecay), gen: a.gen + 1, maxGen: a.maxGen, id: a.id, branch: a.branch, roll: a.roll,
cAxis, cAng: a.cAng * 0.985 + (rand01(a.id, 300 + a.gen) - 0.5) * P.curl * 0.3 });
const ang = P.angle * Math.PI / 180 * (1 + (rand01(a.id, g8 + 3) * 2 - 1) * P.angleVar);
if (type === 'cont') cont();
else if (type === 'fork'){
const ax = axisAt(ro);
spawn(dir.clone().applyAxisAngle(ax, ang), P.rRatio, P.lenDecay, a.maxGen, 0, ro);
spawn(dir.clone().applyAxisAngle(ax, -ang * (0.75 + 0.5 * rand01(a.id, g8 + 4))), P.rRatio, P.lenDecay, a.maxGen, 1, ro);
} else if (type === 'tri'){
for (let k = 0; k < 3; k++) spawn(dir.clone().applyAxisAngle(axisAt(ro + k * 2.0944), ang * 0.8), P.rRatio * 0.95, P.lenDecay, a.maxGen, k, ro);
} else {
cont();
const sa = ang * 1.9 * (0.8 + 0.4 * rand01(a.id, g8 + 5));
const mg = Math.min(a.maxGen, a.gen + 2 + Math.floor(rand01(a.id, g8 + 7) * 3));
spawn(dir.clone().applyAxisAngle(axisAt(ro), sa), 0.62, 0.85, mg, 2, ro);
}
}
active = next;
}
return branches;
}
function mirrorBranches(bs){ return bs.map(b => Object.assign({}, b, { pts: b.pts.map(p => new V3(-p.x, p.y, p.z)) })); }
/* ---------- Auto-Rig + Blob-Mesh ----------
Jede Koralle wird als Metaball-Feld aus Kapseln (Radius pro Knoten, modelliert mit Blob/Kuppen)
aufgebaut. Innerhalb eines Astes gilt das Maximum (gleichmäßige Kapsel), zwischen Ästen die Summe
(weiche Verschmelzung an Gabeln). Daraus entsteht per Surface Nets ein glattes, geschlossenes Mesh. */
let rig = null, T = 0, playing = true, frameNo = 0;
let GCAP = 0, Fg, FsG, CG, bestG, bestValG, cellVertG, scratchG, scratchSegG, stackG = null, ctG = null;
function ensureGrid(n){
if (n <= GCAP) return;
GCAP = Math.ceil(n * 1.15);
Fg = new Float32Array(GCAP); bestG = new Int32Array(GCAP); bestValG = new Float32Array(GCAP);
cellVertG = new Int32Array(GCAP).fill(-1); scratchG = new Float32Array(GCAP); scratchSegG = new Int32Array(GCAP); stackG = stackG || new Int32Array(4194304); ctG = ctG || new Int32Array(8388608);
FsG = new Float32Array(GCAP); CG = new Float32Array(GCAP);
}
const CEDGES = [[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]];
function taubin(posArr, idxArr, iters){
const nV = posArr.length / 3, ix = Int32Array.from(idxArr), deg = new Int32Array(nV + 1);
for (let i = 0; i < ix.length; i += 3){ deg[ix[i]] += 2; deg[ix[i + 1]] += 2; deg[ix[i + 2]] += 2; }
const start = new Int32Array(nV + 1); for (let i = 0; i < nV; i++) start[i + 1] = start[i] + deg[i];
const fill = start.slice(0, nV), nbr = new Int32Array(start[nV]);
for (let i = 0; i < ix.length; i += 3){
const a = ix[i], b = ix[i + 1], c = ix[i + 2];
nbr[fill[a]++] = b; nbr[fill[a]++] = c; nbr[fill[b]++] = a; nbr[fill[b]++] = c; nbr[fill[c]++] = a; nbr[fill[c]++] = b;
}
let A = Float32Array.from(posArr), B = new Float32Array(A.length);
for (let it = 0; it < iters * 2; it++){
const lam = (it & 1) ? -0.53 : 0.5;
for (let v = 0; v < nV; v++){
const s0 = start[v], e0 = start[v + 1], v3 = v * 3;
if (e0 === s0){ B[v3] = A[v3]; B[v3 + 1] = A[v3 + 1]; B[v3 + 2] = A[v3 + 2]; continue; }
let x = 0, y = 0, z = 0;
for (let k = s0; k < e0; k++){ const n3 = nbr[k] * 3; x += A[n3]; y += A[n3 + 1]; z += A[n3 + 2]; }
const inv = 1 / (e0 - s0);
B[v3] = A[v3] + lam * (x * inv - A[v3]); B[v3 + 1] = A[v3 + 1] + lam * (y * inv - A[v3 + 1]); B[v3 + 2] = A[v3 + 2] + lam * (z * inv - A[v3 + 2]);
}
const t = A; A = B; B = t;
}
return A;
}
function splatSegs(pr, k0, k1, g, S, ST, SS, segBase, kR){
const ox = g.ox, oy = g.oy, oz = g.oz, vs = g.vs, NX = g.NX, NY = g.NY, LAY = g.LAY, zb = g.zb, z1 = g.z1;
let sc = 0;
for (let k = k0; k < k1; k++){
const a = pr.Pe[k], b = pr.Pe[k + 1], ra = pr.rad[k], rb = pr.rad[k + 1], Rm = kR * Math.max(ra, rb);
const zs0 = Math.max(zb, Math.floor((Math.min(a.z, b.z) - Rm - oz) / vs)), zs1 = Math.min(z1, Math.ceil((Math.max(a.z, b.z) + Rm - oz) / vs));
if (zs0 > zs1) continue;
const x0 = Math.max(0, Math.floor((Math.min(a.x, b.x) - Rm - ox) / vs)), x1 = Math.min(NX - 1, Math.ceil((Math.max(a.x, b.x) + Rm - ox) / vs));
const y0 = Math.max(0, Math.floor((Math.min(a.y, b.y) - Rm - oy) / vs)), y1 = Math.min(NY - 1, Math.ceil((Math.max(a.y, b.y) + Rm - oy) / vs));
const abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z, ab2 = (abx * abx + aby * aby + abz * abz) || 1e-9;
for (let z = zs0; z <= zs1; z++){ const pz = oz + z * vs, zo = (z - zb) * LAY;
for (let y = y0; y <= y1; y++){ const py = oy + y * vs, yo = zo + y * NX;
for (let x = x0; x <= x1; x++){
const px = ox + x * vs;
let t = ((px - a.x) * abx + (py - a.y) * aby + (pz - a.z) * abz) / ab2; t = t < 0 ? 0 : t > 1 ? 1 : t;
const dx = px - a.x - abx * t, dy = py - a.y - aby * t, dz = pz - a.z - abz * t;
const d2 = dx * dx + dy * dy + dz * dz, R = kR * (ra + (rb - ra) * t), R2 = R * R;
if (d2 < R2){
const q = 1 - d2 / R2, val = q * q * q, id = yo + x;
if (val > S[id]){ if (S[id] === 0) ST[sc++] = id; S[id] = val; if (SS) SS[id] = segBase + k; }
}
}
}
}
}
return sc;
}
function polygonize(F, touched, nx, ny, nz, ox, oy, oz, vs, iso, CV, pushV, pushT){
const nxy = nx * ny, offs = [0, 1, nx, 1 + nx, nxy, 1 + nxy, nx + nxy, 1 + nx + nxy], cv = new Float32Array(8), visited = [];
for (const q of touched){
const qz = Math.floor(q / nxy), rem = q - qz * nxy, qy = Math.floor(rem / nx), qx = rem - qy * nx;
for (let dz = 0; dz < 2; dz++){ const cz = qz - dz; if (cz < 0 || cz > nz - 2) continue;
for (let dy = 0; dy < 2; dy++){ const cy = qy - dy; if (cy < 0 || cy > ny - 2) continue;
for (let dx = 0; dx < 2; dx++){ const cx = qx - dx; if (cx < 0 || cx > nx - 2) continue;
const cid = cx + cy * nx + cz * nxy; if (CV[cid] !== -1) continue;
visited.push(cid);
let mask = 0;
for (let c = 0; c < 8; c++){ cv[c] = F[cid + offs[c]] - iso; if (cv[c] > 0) mask |= 1 << c; }
if (mask === 0 || mask === 255){ CV[cid] = -2; continue; }
let sx = 0, sy = 0, sz = 0, cnt = 0;
for (let e = 0; e < 12; e++){
const a = CEDGES[e][0], b = CEDGES[e][1];
if (((mask >> a) & 1) !== ((mask >> b) & 1)){
const t = cv[a] / (cv[a] - cv[b]);
sx += (a & 1) + t * ((b & 1) - (a & 1)); sy += ((a >> 1) & 1) + t * (((b >> 1) & 1) - ((a >> 1) & 1)); sz += ((a >> 2) & 1) + t * (((b >> 2) & 1) - ((a >> 2) & 1)); cnt++;
}
}
CV[cid] = pushV(ox + (cx + sx / cnt) * vs, oy + (cy + sy / cnt) * vs, oz + (cz + sz / cnt) * vs);
}
}
}
}
const step = [1, nx, nxy], dim = [nx, ny, nz], cc = [0, 0, 0];
const quad = (lx, ly, lz, axis, lowerInside) => {
const u = (axis + 1) % 3, v = (axis + 2) % 3;
cc[0] = lx; cc[1] = ly; cc[2] = lz;
if (cc[u] < 1 || cc[v] < 1 || cc[u] > dim[u] - 2 || cc[v] > dim[v] - 2 || cc[axis] > dim[axis] - 2) return;
const c0 = lx + ly * nx + lz * nxy, c1 = c0 - step[u], c3 = c0 - step[v], c2 = c1 - step[v];
const v0 = CV[c0], v1 = CV[c1], v2 = CV[c2], v3 = CV[c3];
if (v0 < 0 || v1 < 0 || v2 < 0 || v3 < 0) return;
if (lowerInside){ pushT(v0, v1, v2); pushT(v0, v2, v3); } else { pushT(v0, v2, v1); pushT(v0, v3, v2); }
};
for (const q of touched){
const qz = Math.floor(q / nxy), rem = q - qz * nxy, qy = Math.floor(rem / nx), qx = rem - qy * nx;
const inQ = F[q] - iso > 0, co = [qx, qy, qz];
for (let axis = 0; axis < 3; axis++){
if (co[axis] + 1 < dim[axis]){ const inN = F[q + step[axis]] - iso > 0; if (inQ !== inN) quad(qx, qy, qz, axis, inQ); }
if (co[axis] >= 1 && inQ && F[q - step[axis]] === 0){ const l = [qx, qy, qz]; l[axis] -= 1; quad(l[0], l[1], l[2], axis, false); }
}
}
return visited;
}
function disposeRig(){
if (!rig) return;
scene.remove(rig.mesh); rig.mesh.geometry.dispose(); rig.skeleton.dispose();
scene.remove(rig.lines); rig.lines.geometry.dispose(); rig.lines.material.dispose();
rig = null;
}
function buildRig(corals){
disposeRig();
const bones = [], parent = [], birth = [], rest = [], dirsA = [], dsA = [], pathA = [], radA = [], rA = [], rB = [], phA = [], wdA = [];
const pos = [], uvs = [], sk = [], skw = [], idx = [];
let vcount = 0, maxB = 0, nBr = 0;
const kR = Math.max(1.25, P.merge), iso = Math.pow(1 - 1 / (kR * kR), 3);
// Voxelgröße aus dem Vertex-Budget (Limit für die Live-Animation)
let area = 0;
for (const coral of corals){
for (const br of coral.branches){
if (br.pts.length < 2) continue;
let bl = 0; for (let k = 1; k < br.pts.length; k++) bl += br.pts[k].distanceTo(br.pts[k - 1]);
const rm = (br.rStart + br.rEnd) * 0.5 * 1.15 * (br.parent < 0 ? 1 + 0.45 * P.trunk * Math.min(1, P.trunkLen / Math.max(bl, 1e-3)) : 1);
area += bl * 6.2832 * rm;
}
if (coral.base) area += 6.2832 * coral.base.r * coral.base.r * 0.5;
}
const VOX = Math.max(P.voxel, Math.sqrt(1.6 * area / P.maxVerts));
const bls = [];
let mnx = 1e9, mny = 1e9, mnz = 1e9, mxx = -1e9, mxy = -1e9, mxz = -1e9;
const cboxes = []; let cb = null;
const grow = (q, rr) => {
if (q.x - rr < mnx) mnx = q.x - rr; if (q.x + rr > mxx) mxx = q.x + rr;
if (q.y - rr < mny) mny = q.y - rr; if (q.y + rr > mxy) mxy = q.y + rr;
if (q.z - rr < mnz) mnz = q.z - rr; if (q.z + rr > mxz) mxz = q.z + rr;
if (q.x - rr < cb[0]) cb[0] = q.x - rr; if (q.x + rr > cb[3]) cb[3] = q.x + rr;
if (q.y - rr < cb[1]) cb[1] = q.y - rr; if (q.y + rr > cb[4]) cb[4] = q.y + rr;
if (q.z - rr < cb[2]) cb[2] = q.z - rr; if (q.z + rr > cb[5]) cb[5] = q.z + rr;
};
for (let ci = 0; ci < corals.length; ci++){
const coral = corals[ci];
const brs = coral.branches;
cb = [1e9, 1e9, 1e9, -1e9, -1e9, -1e9]; cboxes[ci] = cb;
const forkSet = new Set();
brs.forEach(b => { if (b.parent >= 0) forkSet.add(b.parent * 65536 + b.parentCtrl); });
// Bewegungsvariation räumlich kohärent: Nachbarn schwingen ähnlich, verschmolzene Stellen reißen nicht
const r0 = brs[0].pts[0];
const th = noise3(r0.x * 1.1 + 11.3, r0.y * 1.1, r0.z * 1.1) * 7, ph0 = noise3(r0.x * 1.1, r0.y * 1.1 + 23.1, r0.z * 1.1) * 2.5;
const wdx = Math.cos(th) * Math.cos(ph0), wdy = Math.sin(ph0), wdz = Math.sin(th) * Math.cos(ph0);
const cA = Math.max(-1, Math.min(1, noise3(r0.x * 1.6 + 3.7, r0.y * 1.6, r0.z * 1.6) * 1.8));
const cB = Math.max(-1, Math.min(1, noise3(r0.x * 1.6, r0.y * 1.6 + 9.2, r0.z * 1.6) * 1.8));
const cP = noise3(r0.x * 1.2, r0.y * 1.2, r0.z * 1.2 + 41.0) * 9;
for (let bi = 0; bi < brs.length; bi++){
const br = brs[bi]; if (br.pts.length < 2) continue;
nBr++;
const n = br.pts.length, Me = (n - 1) * GEO + 1;
const leaf = !forkSet.has(bi * 65536 + (n - 1));
const curve = new THREE.CatmullRomCurve3(br.pts, false, 'centripetal');
const Pe = []; for (let k = 0; k < Me; k++) Pe.push(curve.getPoint(k / (Me - 1)));
const rad = [], cum = [0];
for (let k = 1; k < Me; k++) cum.push(cum[k - 1] + Pe[k].distanceTo(Pe[k - 1]));
for (let k = 0; k < Me; k++){
const u = k / (Me - 1);
let r = br.rStart + (br.rEnd - br.rStart) * u;
if (br.parent < 0){ const ts = Math.min(1, cum[k] / P.trunkLen); r *= 1 + P.trunk * Math.pow(1 - smooth01(0, 1, ts), 1.5); }
const bn = noise3(Pe[k].x * P.blobFreq + 7.3, Pe[k].y * P.blobFreq, Pe[k].z * P.blobFreq);
r *= Math.max(0.5, 1 + P.blob * bn * 1.2);
if (leaf) r *= 1 + P.club * smooth01(0.5, 1, u);
rad.push(Math.max(r, 0.9 * VOX));
grow(Pe[k], kR * rad[k]);
}
const nb = [];
const ra = cA + (rand01(coral.id, 200 + bi) - 0.5) * 0.3, rb = cB + (rand01(coral.id, 600 + bi) - 0.5) * 0.3, ph = cP + (rand01(coral.id, 800 + bi) - 0.5) * 0.6;
for (let j = 0; j < n; j++){
const id = bones.length; nb.push(id); bones.push(new THREE.Bone());
const par = j > 0 ? nb[j - 1] : (br.parent < 0 ? -1 : brs[br.parent].nodeBone[br.parentCtrl]);
const pj = br.pts[j];
parent.push(par); rest.push(pj.x, pj.y, pj.z);
let b;
if (j === 0 && par >= 0) b = birth[par];
else if (j === 0) b = 1 + coral.delay;
else b = br.gens[j] + 1 + coral.delay;
birth.push(b); if (b > maxB) maxB = b;
pathA.push(par < 0 ? 0 : pathA[par] + Math.hypot(pj.x - rest[par * 3], pj.y - rest[par * 3 + 1], pj.z - rest[par * 3 + 2]));
radA.push(rad[j * GEO]); rA.push(ra); rB.push(rb); phA.push(ph); wdA.push(wdx, wdy, wdz);
if (j < n - 1){ const d = br.pts[j + 1].clone().sub(pj), l = d.length(); d.divideScalar(l || 1); dirsA.push(d.x, d.y, d.z); dsA.push(l); }
else { const d = pj.clone().sub(br.pts[j - 1]).normalize(); dirsA.push(d.x, d.y, d.z); dsA.push(0); }
}
br.nodeBone = nb;
let trunkK = 0; if (br.parent < 0) while (trunkK < Me - 1 && cum[trunkK] < P.trunkLen * 1.4) trunkK++;
bls.push({ id: bls.length, Pe, rad, nb, n, Me, base: false, coral: ci, trunkK });
}
// Fundament: weiche Kuppel, hängt am Wurzel-Bone
const root = brs[0] && brs[0].nodeBone ? brs[0].nodeBone[0] : -1;
if (coral.base && root >= 0){
const r = Math.max(coral.base.r, 0.9 * VOX);
bls.push({ id: bls.length, Pe: [coral.base.a, coral.base.b], rad: [r, r], nb: [root], n: 1, Me: 2, base: true, coral: ci, trunkK: 1 });
grow(coral.base.a, kR * r); grow(coral.base.b, kR * r);
}
}
if (!bls.length){ $('#stat').textContent = 'Keine Korallen erzeugt. Mit Malen Bereiche füllen und „Neu wachsen“ drücken.'; return; }
// Segmenttabelle (für Splat und Skin)
const segA = [], segBr = [], segJ = [], segO = [], segRa = [], segRb = [], corSeg = new Map();
for (const bl of bls){
bl.segBase = segBr.length;
for (let k = 0; k < bl.Me - 1; k++){
const a = bl.Pe[k], b = bl.Pe[k + 1];
segA.push(a.x, a.y, a.z, b.x, b.y, b.z); segBr.push(bl.id); segJ.push(Math.floor(k / GEO)); segO.push(k % GEO); segRa.push(bl.rad[k]); segRb.push(bl.rad[k + 1]);
}
const cs = corSeg.get(bl.coral); if (!cs) corSeg.set(bl.coral, [bl.segBase, segBr.length]); else cs[1] = segBr.length;
}
const vcor = []; let curCoral = -1, vsMax = 0;
const pushV = (x, y, z) => { pos.push(x, y, z); vcor.push(curCoral); return vcount++; };
const pushT = (a, b, c) => { idx.push(a, b, c); };
const gridFor = box => {
const sx = box[3] - box[0], sy = box[4] - box[1], sz = box[5] - box[2];
const v = Math.max(VOX, Math.cbrt(sx * sy * sz / (P.maxCells * 1e6)));
const nx = Math.ceil(sx / v) + 3, ny = Math.ceil(sy / v) + 3, nz = Math.ceil(sz / v) + 3;
return { ox: box[0] - v, oy: box[1] - v, oz: box[2] - v, vs: v, NX: nx, NY: ny, LAY: nx * ny, zb: 0, z1: nz - 1, nz };
};
if (P.fuse){
// ein Gitter: innerhalb einer Koralle Summe, zwischen Korallen Maximum, Stämme zusätzlich als Summe (verschmelzende Basis)
const g = gridFor([mnx, mny, mnz, mxx, mxy, mxz]); ensureGrid(g.LAY * g.nz); vsMax = g.vs;
const touched = [];
let c0 = 0;
while (c0 < bls.length){
const cor = bls[c0].coral; let c1 = c0; while (c1 < bls.length && bls[c1].coral === cor) c1++;
let cc = 0;
for (let q = c0; q < c1; q++){
const bl = bls[q], sc = splatSegs(bl, 0, bl.Me - 1, g, scratchG, stackG, null, 0, kR);
for (let i = 0; i < sc; i++){ const id = stackG[i]; if (CG[id] === 0) ctG[cc++] = id; CG[id] += scratchG[id]; scratchG[id] = 0; }
}
for (let i = 0; i < cc; i++){ const id = ctG[i]; if (Fg[id] === 0 && FsG[id] === 0) touched.push(id); if (CG[id] > Fg[id]) Fg[id] = CG[id]; CG[id] = 0; }
for (let q = c0; q < c1; q++){
const bl = bls[q]; if (!bl.trunkK) continue;
const sc = splatSegs(bl, 0, bl.trunkK, g, scratchG, stackG, null, 0, kR);
for (let i = 0; i < sc; i++){ const id = stackG[i]; if (Fg[id] === 0 && FsG[id] === 0) touched.push(id); FsG[id] += scratchG[id]; scratchG[id] = 0; }
}
c0 = c1;
}
for (const id of touched){ if (FsG[id] > Fg[id]) Fg[id] = FsG[id]; FsG[id] = 0; }
curCoral = -1;
const visited = polygonize(Fg, touched, g.NX, g.NY, g.nz, g.ox, g.oy, g.oz, g.vs, iso, cellVertG, pushV, pushT);
for (const id of touched) Fg[id] = 0;
for (const id of visited) cellVertG[id] = -1;
} else {
// jede Koralle als eigenes geschlossenes Mesh: kein Vertex gehört zu zwei Korallen
let c0 = 0;
while (c0 < bls.length){
const cor = bls[c0].coral; let c1 = c0; while (c1 < bls.length && bls[c1].coral === cor) c1++;
const g = gridFor(cboxes[cor]); ensureGrid(g.LAY * g.nz); if (g.vs > vsMax) vsMax = g.vs;
const touched = [];
for (let q = c0; q < c1; q++){
const bl = bls[q], sc = splatSegs(bl, 0, bl.Me - 1, g, scratchG, stackG, null, 0, kR);
for (let i = 0; i < sc; i++){ const id = stackG[i]; if (Fg[id] === 0) touched.push(id); Fg[id] += scratchG[id]; scratchG[id] = 0; }
}
curCoral = cor;
const visited = polygonize(Fg, touched, g.NX, g.NY, g.nz, g.ox, g.oy, g.oz, g.vs, iso, cellVertG, pushV, pushT);
for (const id of touched) Fg[id] = 0;
for (const id of visited) cellVertG[id] = -1;
c0 = c1;
}
}
const vs = vsMax;
// Skin: jeder Vertex gehört zu der Kapsel, deren Oberfläche ihm am nächsten liegt.
// Liegen zwei Äste fast gleich nah (Verschmelzung), werden beide Ketten weich gemischt.
const HC = Math.max(vs * 3, 0.08), shash = new Map();
const hk = (x, y, z) => ((x + 1024) * 2048 + (y + 1024)) * 2048 + (z + 1024);
for (let sg = 0; sg < segBr.length; sg++){
const e = kR * Math.max(segRa[sg], segRb[sg]) + 2 * vs, o = sg * 6;
const x0 = Math.floor((Math.min(segA[o], segA[o + 3]) - e) / HC), x1 = Math.floor((Math.max(segA[o], segA[o + 3]) + e) / HC);
const y0 = Math.floor((Math.min(segA[o + 1], segA[o + 4]) - e) / HC), y1 = Math.floor((Math.max(segA[o + 1], segA[o + 4]) + e) / HC);
const z0 = Math.floor((Math.min(segA[o + 2], segA[o + 5]) - e) / HC), z1 = Math.floor((Math.max(segA[o + 2], segA[o + 5]) + e) / HC);
for (let z = z0; z <= z1; z++) for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++){
const k = hk(x, y, z); let l = shash.get(k); if (!l){ l = []; shash.set(k, l); } l.push(sg);
}
}
const NB = bones.length, fuseW = new Float32Array(NB), totW = new Float32Array(NB);
const bsW = t => { const t2 = t * t, t3 = t2 * t; return [(1 - t) * (1 - t) * (1 - t) / 6, (3 * t3 - 6 * t2 + 4) / 6, (-3 * t3 + 3 * t2 + 3 * t + 1) / 6, t3 / 6]; };
for (let v = 0; v < vcount; v++){
const px = pos[v * 3], py = pos[v * 3 + 1], pz = pos[v * 3 + 2];
uvs.push(px * 6 + pz * 4, py * 6 + pz * 2);
const l = shash.get(hk(Math.floor(px / HC), Math.floor(py / HC), Math.floor(pz / HC)));
let aS = -1, aD = 1e9, aT = 0, bS = -1, bD = 1e9, bT = 0;
const vc = vcor[v];
if (l) for (const sg of l){
if (vc >= 0 && bls[segBr[sg]].coral !== vc) continue;
const o = sg * 6, ax = segA[o], ay = segA[o + 1], az = segA[o + 2];
const abx = segA[o + 3] - ax, aby = segA[o + 4] - ay, abz = segA[o + 5] - az;
let t = ((px - ax) * abx + (py - ay) * aby + (pz - az) * abz) / ((abx * abx + aby * aby + abz * abz) || 1e-9);
t = t < 0 ? 0 : t > 1 ? 1 : t;
const dx = px - ax - abx * t, dy = py - ay - aby * t, dz = pz - az - abz * t;
const sd = Math.sqrt(dx * dx + dy * dy + dz * dz) - (segRa[sg] + (segRb[sg] - segRa[sg]) * t);
if (sd < aD){
if (aS >= 0 && segBr[aS] !== segBr[sg]){ bS = aS; bD = aD; bT = aT; }
aS = sg; aD = sd; aT = t;
} else if (segBr[sg] !== segBr[aS] && sd < bD){ bS = sg; bD = sd; bT = t; }
}
if (aS < 0){
const cs = corSeg.get(vc) || [0, segBr.length];
for (let sg = cs[0]; sg < cs[1]; sg++){
const o = sg * 6, ax = segA[o], ay = segA[o + 1], az = segA[o + 2], abx = segA[o + 3] - ax, aby = segA[o + 4] - ay, abz = segA[o + 5] - az;
let t = ((px - ax) * abx + (py - ay) * aby + (pz - az) * abz) / ((abx * abx + aby * aby + abz * abz) || 1e-9); t = t < 0 ? 0 : t > 1 ? 1 : t;
const sd = Math.hypot(px - ax - abx * t, py - ay - aby * t, pz - az - abz * t) - (segRa[sg] + (segRb[sg] - segRa[sg]) * t);
if (sd < aD){ aD = sd; aS = sg; aT = t; }
}
}
const blA = bls[segBr[aS]];
const rA = segRa[aS] + (segRb[aS] - segRa[aS]) * aT;
const band = bS >= 0 ? 0.6 * (rA + segRa[bS] + (segRb[bS] - segRa[bS]) * bT) : 0;
if (bS >= 0 && bD - aD < band){
const w = 0.5 + 0.5 * smooth01(0, band, bD - aD);
const two = (bl, sg, te, ww) => {
if (bl.base) return [bl.nb[0], bl.nb[0], ww, 0];
const t = (segO[sg] + te) / GEO, j = segJ[sg], q = bsW(t);
return [bl.nb[j], bl.nb[j + 1], ww * (q[0] + q[1]), ww * (q[2] + q[3])];
};
const blB = bls[segBr[bS]], A = two(blA, aS, aT, w), B = two(blB, bS, bT, 1 - w);
sk.push(A[0], A[1], B[0], B[1]); skw.push(A[2], A[3], B[2], B[3]);
if (blA.coral !== blB.coral){ fuseW[A[0]] += A[2]; fuseW[A[1]] += A[3]; fuseW[B[0]] += B[2]; fuseW[B[1]] += B[3]; }
} else if (blA.base){
sk.push(blA.nb[0], blA.nb[0], blA.nb[0], blA.nb[0]); skw.push(1, 0, 0, 0);
} else {
const t = (segO[aS] + aT) / GEO, j = segJ[aS], q = bsW(t);
sk.push(blA.nb[Math.max(0, j - 1)], blA.nb[j], blA.nb[j + 1], blA.nb[Math.min(blA.n - 1, j + 2)]);
skw.push(q[0], q[1], q[2], q[3]);
}
}
for (let v = 0; v < vcount; v++) for (let k = 0; k < 4; k++) totW[sk[v * 4 + k]] += skw[v * 4 + k];
// verschmolzene Stellen versteifen (inkl. Vorfahren), damit Nachbar-Korallen nicht auseinanderreißen
const fix = new Float32Array(NB);
for (let i = 0; i < NB; i++) fix[i] = totW[i] > 0 ? Math.min(1, 3 * fuseW[i] / totW[i]) : 0;
for (let i = NB - 1; i >= 0; i--){ const pp = parent[i]; if (pp >= 0 && fix[i] * 0.85 > fix[pp]) fix[pp] = fix[i] * 0.85; }
const N = bones.length;
if (!N || !vcount){ $('#stat').textContent = 'Keine Korallen erzeugt.'; return; }
const restPos = new Float32Array(rest), restOff = new Float32Array(N * 3);
for (let i = 0; i < N; i++){
const p = parent[i];
restOff[i * 3] = rest[i * 3] - (p < 0 ? 0 : rest[p * 3]);
restOff[i * 3 + 1] = rest[i * 3 + 1] - (p < 0 ? 0 : rest[p * 3 + 1]);
restOff[i * 3 + 2] = rest[i * 3 + 2] - (p < 0 ? 0 : rest[p * 3 + 2]);
}
const posF = P.smoothIt > 0 ? taubin(pos, idx, P.smoothIt) : Float32Array.from(pos);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(posF, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(sk, 4));
geo.setAttribute('skinWeight', new THREE.Float32BufferAttribute(skw, 4));
geo.setIndex(new THREE.Uint32BufferAttribute(idx, 1));
geo.computeVertexNormals();
const mesh = new THREE.SkinnedMesh(geo, coralMat); mesh.frustumCulled = false;
for (let i = 0; i < N; i++){
bones[i].position.set(restOff[i * 3], restOff[i * 3 + 1], restOff[i * 3 + 2]);
if (parent[i] < 0) mesh.add(bones[i]); else bones[parent[i]].add(bones[i]);
}
scene.add(mesh); mesh.updateMatrixWorld(true);
const skeleton = new THREE.Skeleton(bones);
mesh.bind(skeleton, mesh.matrixWorld);
const lg = new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 6), 3));
const lines = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: ACC, transparent: true, opacity: 0.85, depthTest: false }));
lines.frustumCulled = false; lines.renderOrder = 10; lines.visible = P.rig; scene.add(lines);
rig = { mesh, skeleton, lines, bones, N, verts: vcount, vox: vs, parent: Int32Array.from(parent), birth: Float32Array.from(birth), restPos, restOff,
dirs: Float32Array.from(dirsA), ds: Float32Array.from(dsA), path: Float32Array.from(pathA), rad: Float32Array.from(radA),
rA: Float32Array.from(rA), rB: Float32Array.from(rB), ph: Float32Array.from(phA), wd: Float32Array.from(wdA),
fix, bend: new Float32Array(N * 3), btgt: new Float32Array(N * 3), qw: new Float32Array(N * 4), Tmax: maxB + 0.05, branches: nBr };
for (let i = 0; i < N; i++) rig.qw[i * 4 + 3] = 1;
}
const _q1 = new THREE.Quaternion(), _q2 = new THREE.Quaternion(), _q3 = new THREE.Quaternion(), _ax = new V3();
const mouse = { active: false, down: false, ox: 0, oy: 0, oz: 0, dx: 0, dy: 0, dz: 1 };
function updateRig(time, dt){
const r = rig, N = r.N, ro = r.restOff, rp = r.restPos, dr = r.dirs, ds = r.ds, bi = r.birth, par = r.parent, bs = r.bend, qw = r.qw, bones = r.bones;
const fx = r.fix, bt = r.btgt, parity = (frameNo++) & 1, path = r.path, rad = r.rad, rA = r.rA, rB = r.rB, ph = r.ph, wd = r.wd;
const S = SUB, f = P.flowScale, amp = P.flow, tt = time * P.flowSpeed, al = 1 - Math.exp(-dt * 5);
const mr = P.mouseR, mr2 = mr * mr, vr = P.vari * 0.8, wv = P.wave, wf = P.waveFreq * 6.2832, ws = P.waveSpeed, se = P.stiffExp, th = P.thin, r0 = P.radius;
for (let i = 0; i < N; i++){
const i3 = i * 3, b = bones[i];
let k = (T - bi[i]) * S + 1; k = k < 0 ? 0 : k > 1 ? 1 : k; const ke = k * (2 - k);
b.position.set(ro[i3] * ke, ro[i3 + 1] * ke, ro[i3 + 2] * ke);
const sc = ke < 1e-4 ? 1e-4 : ke; b.scale.set(sc, sc, sc);
const am = 1 + vr * rA[i];
const flex = Math.pow(Math.min(1, path[i] / PATHREF), se) * (1 + th * (1 - Math.min(1, rad[i] / r0))) * (1 + vr * rB[i]) * (1 - fx[i]);
const px = rp[i3], py = rp[i3 + 1], pz = rp[i3 + 2];
let bx, by, bz;
if ((i & 1) === parity){
bx = noise3(px * f + tt, py * f, pz * f) * amp * am;
by = noise3(px * f, py * f + tt + 17.3, pz * f) * amp * am;
bz = noise3(px * f, py * f + 5.1, pz * f + tt + 31.7) * amp * am;
if (wv > 0){ const s = Math.sin(path[i] * wf - time * ws + ph[i]) * wv * am; bx += wd[i3] * s; by += wd[i3 + 1] * s; bz += wd[i3 + 2] * s; }
if (mouse.active){
const wx = px - mouse.ox, wy = py - mouse.oy, wz = pz - mouse.oz;
const t = wx * mouse.dx + wy * mouse.dy + wz * mouse.dz;
const vx = wx - mouse.dx * t, vy = wy - mouse.dy * t, vz = wz - mouse.dz * t;
const d2 = vx * vx + vy * vy + vz * vz;
if (d2 < mr2 && t > 0){ const d = Math.sqrt(d2) + 1e-4, fl = 1 - d / mr, s = P.mouseF * fl * fl / d * (mouse.down ? -1.5 : 1); bx += vx * s; by += vy * s; bz += vz * s; }
}
bt[i3] = bx; bt[i3 + 1] = by; bt[i3 + 2] = bz;
} else { bx = bt[i3]; by = bt[i3 + 1]; bz = bt[i3 + 2]; }
bs[i3] += (bx - bs[i3]) * al; bs[i3 + 1] += (by - bs[i3 + 1]) * al; bs[i3 + 2] += (bz - bs[i3 + 2]) * al;
const dx = dr[i3], dy = dr[i3 + 1], dz = dr[i3 + 2];
const ax = dy * bs[i3 + 2] - dz * bs[i3 + 1], ay = dz * bs[i3] - dx * bs[i3 + 2], az = dx * bs[i3 + 1] - dy * bs[i3];
const l = Math.sqrt(ax * ax + ay * ay + az * az);
let ang = l * ds[i] * flex * P.sway; if (ang > 0.9) ang = 0.9;
const p = par[i];
if (p < 0) _q2.set(0, 0, 0, 1); else _q2.fromArray(qw, p * 4);
if (ang > 1e-5){ _ax.set(ax / l, ay / l, az / l); _q1.setFromAxisAngle(_ax, ang); } else _q1.set(0, 0, 0, 1);
_q3.multiplyQuaternions(_q1, _q2); _q3.toArray(qw, i * 4);
_q1.copy(_q2).invert();
b.quaternion.multiplyQuaternions(_q1, _q3);
}
}
/* ---------- Starres Hochauflösungs-Modell (schichtweise, ohne Rig) ---------- */
const GEO_HI = 4;
const tick = () => new Promise(r => setTimeout(r, 0));
function buildPrims(corals, minVox){
const prims = [];
for (let ci = 0; ci < corals.length; ci++){
const coral = corals[ci];
const brs = coral.branches;
const forkSet = new Set();
brs.forEach(b => { if (b.parent >= 0) forkSet.add(b.parent * 65536 + b.parentCtrl); });
for (let bi = 0; bi < brs.length; bi++){
const br = brs[bi]; if (br.pts.length < 2) continue;
const n = br.pts.length, Me = (n - 1) * GEO_HI + 1;
const leaf = !forkSet.has(bi * 65536 + (n - 1));
const curve = new THREE.CatmullRomCurve3(br.pts, false, 'centripetal');
const Pe = []; for (let k = 0; k < Me; k++) Pe.push(curve.getPoint(k / (Me - 1)));
const rad = [], cum = [0];
for (let k = 1; k < Me; k++) cum.push(cum[k - 1] + Pe[k].distanceTo(Pe[k - 1]));
for (let k = 0; k < Me; k++){
const u = k / (Me - 1);
let r = br.rStart + (br.rEnd - br.rStart) * u;
if (br.parent < 0){ const ts = Math.min(1, cum[k] / P.trunkLen); r *= 1 + P.trunk * Math.pow(1 - smooth01(0, 1, ts), 1.5); }
const bn = noise3(Pe[k].x * P.blobFreq + 7.3, Pe[k].y * P.blobFreq, Pe[k].z * P.blobFreq);
r *= Math.max(0.5, 1 + P.blob * bn * 1.2);
if (leaf) r *= 1 + P.club * smooth01(0.5, 1, u);
rad.push(Math.max(r, 0.9 * minVox));
}
let trunkK = 0; if (br.parent < 0) while (trunkK < Me - 1 && cum[trunkK] < P.trunkLen * 1.4) trunkK++;
prims.push({ Pe, rad, Me, coral: ci, trunkK });
}
if (coral.base && brs[0] && brs[0].pts.length >= 2){
const r = Math.max(coral.base.r, 0.9 * minVox);
prims.push({ Pe: [coral.base.a, coral.base.b], rad: [r, r], Me: 2, coral: ci, trunkK: 1 });
}
}
return prims;
}
function smoothTris(P3, I, nV, iters){
const S = new Float32Array(nV * 3), C = new Float32Array(nV);
for (let it = 0; it < iters * 2; it++){
const lam = (it & 1) ? -0.53 : 0.5;
S.fill(0); C.fill(0);
for (let t = 0; t < I.length; t += 3){
const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3;
S[a] += P3[b] + P3[c]; S[a + 1] += P3[b + 1] + P3[c + 1]; S[a + 2] += P3[b + 2] + P3[c + 2];
S[b] += P3[a] + P3[c]; S[b + 1] += P3[a + 1] + P3[c + 1]; S[b + 2] += P3[a + 2] + P3[c + 2];
S[c] += P3[a] + P3[b]; S[c + 1] += P3[a + 1] + P3[b + 1]; S[c + 2] += P3[a + 2] + P3[b + 2];
C[I[t]] += 2; C[I[t + 1]] += 2; C[I[t + 2]] += 2;
}
for (let v = 0; v < nV; v++){
const c = C[v]; if (!c) continue; const v3 = v * 3;
P3[v3] += lam * (S[v3] / c - P3[v3]); P3[v3 + 1] += lam * (S[v3 + 1] / c - P3[v3 + 1]); P3[v3 + 2] += lam * (S[v3 + 2] / c - P3[v3 + 2]);
}
}
}
async function buildStatic(corals, onProg, isCancelled){
const kR = Math.max(1.25, P.merge), iso = Math.pow(1 - 1 / (kR * kR), 3);
let area = 0;
for (const coral of corals){
for (const br of coral.branches){
if (br.pts.length < 2) continue;
let bl = 0; for (let k = 1; k < br.pts.length; k++) bl += br.pts[k].distanceTo(br.pts[k - 1]);
area += bl * 6.2832 * (br.rStart + br.rEnd) * 0.5 * 1.15 * (br.parent < 0 ? 1 + 0.45 * P.trunk * Math.min(1, P.trunkLen / Math.max(bl, 1e-3)) : 1);
}
if (coral.base) area += 6.2832 * coral.base.r * coral.base.r * 0.5;
}
const vs = Math.max(P.hiVox, Math.sqrt(1.6 * area / (P.hiVerts * 1e6)));
const prims = buildPrims(corals, vs);
if (!P.fuse){
let cap = 0, F = null, S = null, ST = null, CV = null;
let pos = new Float32Array(1 << 21), np = 0, ix = new Uint32Array(1 << 22), ni = 0, vcount = 0;
const pushV = (x, y, z) => { if (np + 3 > pos.length){ const b = new Float32Array(pos.length * 2); b.set(pos); pos = b; } pos[np++] = x; pos[np++] = y; pos[np++] = z; return vcount++; };
const pushT = (a, b, c) => { if (ni + 3 > ix.length){ const n2 = new Uint32Array(ix.length * 2); n2.set(ix); ix = n2; } ix[ni++] = a; ix[ni++] = b; ix[ni++] = c; };
let p0 = 0, lastT = performance.now();
while (p0 < prims.length){
if (isCancelled()) return null;
const cor = prims[p0].coral; let p1 = p0; while (p1 < prims.length && prims[p1].coral === cor) p1++;
const b = [1e9, 1e9, 1e9, -1e9, -1e9, -1e9];
for (let q = p0; q < p1; q++){ const pr = prims[q]; for (let k = 0; k < pr.Me; k++){ const c = pr.Pe[k], rr = kR * pr.rad[k];
if (c.x - rr < b[0]) b[0] = c.x - rr; if (c.y - rr < b[1]) b[1] = c.y - rr; if (c.z - rr < b[2]) b[2] = c.z - rr;
if (c.x + rr > b[3]) b[3] = c.x + rr; if (c.y + rr > b[4]) b[4] = c.y + rr; if (c.z + rr > b[5]) b[5] = c.z + rr; } }
const sx = b[3] - b[0], sy = b[4] - b[1], sz = b[5] - b[2], vc = Math.max(vs, Math.cbrt(sx * sy * sz / 12e6));
const nx = Math.ceil(sx / vc) + 3, ny = Math.ceil(sy / vc) + 3, nz = Math.ceil(sz / vc) + 3, n = nx * ny * nz;
if (n > cap){ cap = Math.ceil(n * 1.2); F = new Float32Array(cap); S = new Float32Array(cap); ST = new Int32Array(cap); CV = new Int32Array(cap).fill(-1); }
const g = { ox: b[0] - vc, oy: b[1] - vc, oz: b[2] - vc, vs: vc, NX: nx, NY: ny, LAY: nx * ny, zb: 0, z1: nz - 1 };
const touched = [];
for (let q = p0; q < p1; q++){
const sc = splatSegs(prims[q], 0, prims[q].Me - 1, g, S, ST, null, 0, kR);
for (let i = 0; i < sc; i++){ const id = ST[i]; if (F[id] === 0) touched.push(id); F[id] += S[id]; S[id] = 0; }
}
const visited = polygonize(F, touched, nx, ny, nz, g.ox, g.oy, g.oz, vc, iso, CV, pushV, pushT);
for (const id of touched) F[id] = 0;
for (const id of visited) CV[id] = -1;
p0 = p1;
if (performance.now() - lastT > 60){ onProg(0.8 * p1 / prims.length, `Voxel ${vs.toFixed(4)} · ${(vcount / 1e6).toFixed(2)} Mio. Vertices`); await tick(); lastT = performance.now(); }
}
return finishStatic(pos.slice(0, np), ix.slice(0, ni), vcount, vs, onProg, isCancelled);
}
const mn = [1e9, 1e9, 1e9], mx = [-1e9, -1e9, -1e9];
for (const pr of prims){
let zmin = 1e9, zmax = -1e9, rmax = 0;
for (let k = 0; k < pr.Me; k++){
const q = pr.Pe[k], rr = kR * pr.rad[k];
if (q.x - rr < mn[0]) mn[0] = q.x - rr; if (q.x + rr > mx[0]) mx[0] = q.x + rr;
if (q.y - rr < mn[1]) mn[1] = q.y - rr; if (q.y + rr > mx[1]) mx[1] = q.y + rr;
if (q.z - rr < mn[2]) mn[2] = q.z - rr; if (q.z + rr > mx[2]) mx[2] = q.z + rr;
if (q.z < zmin) zmin = q.z; if (q.z > zmax) zmax = q.z; if (rr > rmax) rmax = rr;
}
pr.zmin = zmin - rmax; pr.zmax = zmax + rmax;
}
const ox = mn[0] - 2 * vs, oy = mn[1] - 2 * vs, oz = mn[2] - 2 * vs;
const NX = Math.ceil((mx[0] - mn[0]) / vs) + 5, NY = Math.ceil((mx[1] - mn[1]) / vs) + 5, NZ = Math.ceil((mx[2] - mn[2]) / vs) + 5;
const LAY = NX * NY, K = Math.max(2, Math.min(NZ, Math.floor(4e6 / LAY) - 2)), CAP = LAY * (K + 2);
for (const pr of prims){ pr.gz0 = Math.floor((pr.zmin - oz) / vs) - 1; pr.gz1 = Math.ceil((pr.zmax - oz) / vs) + 1; }
const F = new Float32Array(CAP), S = new Float32Array(CAP), ST = new Int32Array(CAP), Fs = new Float32Array(CAP), C = new Float32Array(CAP), CT = new Int32Array(CAP);
const CV = new Int32Array(LAY * (K + 1)), prev = new Int32Array(LAY).fill(-1);
let pos = new Float32Array(1 << 21), np = 0, ix = new Uint32Array(1 << 22), ni = 0, vcount = 0;
const pushV = (x, y, z) => { if (np + 3 > pos.length){ const b = new Float32Array(pos.length * 2); b.set(pos); pos = b; } pos[np++] = x; pos[np++] = y; pos[np++] = z; return vcount++; };
const pushT = (a, b, c) => { if (ni + 3 > ix.length){ const n2 = new Uint32Array(ix.length * 2); n2.set(ix); ix = n2; } ix[ni++] = a; ix[ni++] = b; ix[ni++] = c; };
const cv = new Float32Array(8), offs = [0, 1, NX, 1 + NX, LAY, 1 + LAY, NX + LAY, 1 + NX + LAY], step = [1, NX, LAY], cc = [0, 0, 0];
for (let z0 = 1; z0 < NZ - 1; z0 += K){
if (isCancelled()) return null;
const z1 = Math.min(NZ - 1, z0 + K), zb = z0 - 1, L = z1 - zb + 1;
F.fill(0, 0, L * LAY);
const touched = [], g = { ox, oy, oz, vs, NX, NY, LAY, zb, z1 };
let p0 = 0;
while (p0 < prims.length){
const cor = prims[p0].coral; let p1 = p0; while (p1 < prims.length && prims[p1].coral === cor) p1++;
let cc = 0;
for (let q = p0; q < p1; q++){
const pr = prims[q]; if (pr.gz1 < zb || pr.gz0 > z1) continue;
const sc = splatSegs(pr, 0, pr.Me - 1, g, S, ST, null, 0, kR);
for (let i = 0; i < sc; i++){ const id = ST[i]; if (C[id] === 0) CT[cc++] = id; C[id] += S[id]; S[id] = 0; }
}
for (let i = 0; i < cc; i++){ const id = CT[i]; if (F[id] === 0 && Fs[id] === 0) touched.push(id); if (C[id] > F[id]) F[id] = C[id]; C[id] = 0; }
for (let q = p0; q < p1; q++){
const pr = prims[q]; if (!pr.trunkK || pr.gz1 < zb || pr.gz0 > z1) continue;
const sc = splatSegs(pr, 0, pr.trunkK, g, S, ST, null, 0, kR);
for (let i = 0; i < sc; i++){ const id = ST[i]; if (F[id] === 0 && Fs[id] === 0) touched.push(id); Fs[id] += S[id]; S[id] = 0; }
}
p0 = p1;
}
for (const id of touched){ if (Fs[id] > F[id]) F[id] = Fs[id]; Fs[id] = 0; }
CV.fill(-1, 0, (L - 1) * LAY); CV.set(prev, 0);
for (const q of touched){
const qz = Math.floor(q / LAY), rem = q - qz * LAY, qy = Math.floor(rem / NX), qx = rem - qy * NX;
for (let dz = 0; dz < 2; dz++){ const cl = qz - dz; if (cl < 1 || cl > L - 2) continue;
for (let dy = 0; dy < 2; dy++){ const cy = qy - dy; if (cy < 0 || cy > NY - 2) continue;
for (let dx = 0; dx < 2; dx++){ const cx = qx - dx; if (cx < 0 || cx > NX - 2) continue;
const cid = cx + cy * NX + cl * LAY; if (CV[cid] !== -1) continue;
let mask = 0;
for (let c = 0; c < 8; c++){ cv[c] = F[cid + offs[c]] - iso; if (cv[c] > 0) mask |= 1 << c; }
if (mask === 0 || mask === 255){ CV[cid] = -2; continue; }
let sx = 0, sy = 0, sz = 0, cnt = 0;
for (let e = 0; e < 12; e++){
const ea = CEDGES[e][0], eb = CEDGES[e][1];
if (((mask >> ea) & 1) !== ((mask >> eb) & 1)){
const t = cv[ea] / (cv[ea] - cv[eb]);
sx += (ea & 1) + t * ((eb & 1) - (ea & 1)); sy += ((ea >> 1) & 1) + t * (((eb >> 1) & 1) - ((ea >> 1) & 1)); sz += ((ea >> 2) & 1) + t * (((eb >> 2) & 1) - ((ea >> 2) & 1)); cnt++;
}
}
CV[cid] = pushV(ox + (cx + sx / cnt) * vs, oy + (cy + sy / cnt) * vs, oz + (zb + cl + sz / cnt) * vs);
}
}
}
}
const dimL = [NX, NY, L];
const quad = (lx, ly, lz, axis, lowerInside) => {
const u = (axis + 1) % 3, v = (axis + 2) % 3;
cc[0] = lx; cc[1] = ly; cc[2] = lz;
if (cc[u] < 1 || cc[v] < 1 || cc[u] > dimL[u] - 2 || cc[v] > dimL[v] - 2 || cc[axis] > dimL[axis] - 2) return;
const c0 = lx + ly * NX + lz * LAY, c1 = c0 - step[u], c3 = c0 - step[v], c2 = c1 - step[v];
const v0 = CV[c0], v1 = CV[c1], v2 = CV[c2], v3 = CV[c3];
if (v0 < 0 || v1 < 0 || v2 < 0 || v3 < 0) return;
if (lowerInside){ pushT(v0, v1, v2); pushT(v0, v2, v3); } else { pushT(v0, v2, v1); pushT(v0, v3, v2); }
};
for (const q of touched){
const qz = Math.floor(q / LAY), rem = q - qz * LAY, qy = Math.floor(rem / NX), qx = rem - qy * NX;
const inQ = F[q] - iso > 0, co = [qx, qy, qz];
for (let axis = 0; axis < 3; axis++){
if (qz >= 1 && qz <= L - 2 && co[axis] + 1 < dimL[axis]){
const inN = F[q + step[axis]] - iso > 0;
if (inQ !== inN) quad(qx, qy, qz, axis, inQ);
}
if (inQ && co[axis] >= 1 && F[q - step[axis]] === 0){
const lz = axis === 2 ? qz - 1 : qz;
if (lz >= 1 && lz <= L - 2){ const l = [qx, qy, qz]; l[axis] -= 1; quad(l[0], l[1], l[2], axis, false); }
}
}
}
prev.set(CV.subarray((L - 2) * LAY, (L - 1) * LAY));
onProg(0.8 * Math.min(1, z1 / (NZ - 1)), `Voxel ${vs.toFixed(4)} · ${(vcount / 1e6).toFixed(2)} Mio. Vertices`);
await tick();
}
return finishStatic(pos.slice(0, np), ix.slice(0, ni), vcount, vs, onProg, isCancelled);
}
async function finishStatic(posF, ixF, vcount, vs, onProg, isCancelled){
for (let it = 0; it < P.hiSmooth; it++){
if (isCancelled()) return null;
smoothTris(posF, ixF, vcount, 1);
onProg(0.8 + 0.18 * (it + 1) / Math.max(1, P.hiSmooth), 'Glätten …');
await tick();
}
return { pos: posF, ix: ixF, verts: vcount, vs };
}
/*==CORE_B==*/
/* ------------------------------------------------------------ Szene */
const canvas = $('#c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
const scene = new THREE.Scene();
scene.background = new THREE.Color(P.bg);
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
const camera = new THREE.PerspectiveCamera(38, innerWidth / innerHeight, 0.05, 100);
camera.position.set(0.5, 0.8, 5.4);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0.45, 0);
controls.enableDamping = true;
controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
canvas.addEventListener('contextmenu', e => e.preventDefault());
const key = new THREE.DirectionalLight(0xffffff, 1.7); key.position.set(2.5, 4, 3); scene.add(key);
const rim = new THREE.DirectionalLight(0xffffff, 0.8); rim.position.set(-3, 2, -3); scene.add(rim);
const headMat = new THREE.MeshStandardMaterial({ color: 0xffffff, vertexColors: true, roughness: 0.95, metalness: 0, envMapIntensity: 0.3 });
const coralMat = new THREE.MeshPhysicalMaterial({ color: P.coral, roughness: P.rough, metalness: 0, clearcoat: 0.6, clearcoatRoughness: 0.15 });
/* ------------------------------------------------------------ Kopf + Vertex Paint */
let headMesh = null, headCol = null;
const BASE = 0.045, PINK = [0.78, 0.2, 0.3];
function setHeadColor(i){
const p = P.paintMode ? headPaint[i] : 0;
headCol[i * 3] = BASE + (PINK[0] - BASE) * p; headCol[i * 3 + 1] = BASE + (PINK[1] - BASE) * p; headCol[i * 3 + 2] = BASE + (PINK[2] - BASE) * p;
}
function refreshHeadColors(){
if (!headCol) return;
for (let i = 0; i < headPaint.length; i++) setHeadColor(i);
headMesh.geometry.attributes.color.needsUpdate = true;
}
function setHead(root){
root.updateMatrixWorld(true);
const list = [];
root.traverse(o => {
if (!o.isMesh || !o.geometry || !o.geometry.attributes.position) return;
let g = o.geometry.clone(); g.applyMatrix4(o.matrixWorld);
if (g.index) g = g.toNonIndexed();
const pa = g.attributes.position, pA = new Float32Array(pa.count * 3);
for (let i = 0; i < pa.count; i++){ pA[i * 3] = pa.getX(i); pA[i * 3 + 1] = pa.getY(i); pA[i * 3 + 2] = pa.getZ(i); }
const h = new THREE.BufferGeometry(); h.setAttribute('position', new THREE.BufferAttribute(pA, 3)); list.push(h);
});
if (!list.length) return;
let geo = mergeGeometries(list, false);
geo.computeBoundingBox();
const c = geo.boundingBox.getCenter(new V3()), sz = geo.boundingBox.getSize(new V3());
const s = 2 / Math.max(sz.x, sz.y, sz.z);
geo.translate(-c.x, -c.y, -c.z); geo.scale(s, s, s);
geo = mergeVertices(geo, 1e-4);
geo.computeVertexNormals();
headCol = new Float32Array(geo.attributes.position.count * 3);
geo.setAttribute('color', new THREE.BufferAttribute(headCol, 3));
if (headMesh){ scene.remove(headMesh); headMesh.geometry.dispose(); }
headMesh = new THREE.Mesh(geo, headMat); scene.add(headMesh);
initHeadData(geo, false);
refreshHeadColors();
makeSeeds(); rebuild({ regrow: true });
}
function placeholderHead(){
const g = new THREE.SphereGeometry(1, 96, 64); g.scale(0.78, 1, 0.86);
return new THREE.Mesh(g, headMat);
}
function loadHeadURL(url){
new GLTFLoader().load(url, gltf => setHead(gltf.scene), undefined, () => {
$('#stat').textContent = 'HEAD_ONLY.glb nicht gefunden, Platzhalterkopf aktiv. Kopf über das Menü laden.';
setHead(placeholderHead());
});
}
const brushRing = new THREE.Mesh(new THREE.RingGeometry(0.94, 1, 56), new THREE.MeshBasicMaterial({ color: ACC, side: THREE.DoubleSide, depthTest: false, transparent: true, opacity: 0.9 }));
brushRing.visible = false; brushRing.renderOrder = 20; scene.add(brushRing);
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();
let painting = false, brushHit = null;
function paintAt(pt, dt){
const r = P.brush, r2 = r * r, str = P.brushStr * dt * 3 * (P.erase ? -1 : 1), n = headPaint.length;
const apply = (cx, cy, cz) => {
for (let i = 0; i < n; i++){
const dx = headPos[i * 3] - cx, dy = headPos[i * 3 + 1] - cy, dz = headPos[i * 3 + 2] - cz, d2 = dx * dx + dy * dy + dz * dz;
if (d2 < r2){ const f = 1 - Math.sqrt(d2) / r, w = f * f * (3 - 2 * f); headPaint[i] = Math.min(1, Math.max(0, headPaint[i] + str * w)); setHeadColor(i); }
}
};
apply(pt.x, pt.y, pt.z);
if (P.sym) apply(-pt.x, pt.y, pt.z);
headMesh.geometry.attributes.color.needsUpdate = true;
}
function markDirty(on = true){ $('#bRegrow').classList.toggle('pk', on); $('#bRegrow').classList.toggle('on', on); }
function setPaintMode(on){
P.paintMode = on; $('#bPaint').classList.toggle('on', on);
if (!on){ brushRing.visible = false; painting = false; }
refreshHeadColors();
}
/* ------------------------------------------------------------ Kraftfelder (Objekte) */
const fields = []; let fid = 0, selField = null;
const fieldsGroup = new THREE.Group(); scene.add(fieldsGroup);
const FCOL = { attract: 0xff4d6d, dir: 0x3d7bff, repel: 0x9a9a9a };
function addField(o = {}){
const f = { id: ++fid, mode: o.mode || 'attract', strength: o.strength ?? 0.85 };
const g = new THREE.Group();
g.position.copy(o.pos || new V3(0, 1.2, 0.3));
g.scale.setScalar(o.radius || 0.85);
const mat = new THREE.LineBasicMaterial({ color: FCOL[f.mode], transparent: true, opacity: 0.9 });
for (let a = 0; a < 3; a++){
const pts = []; for (let i = 0; i < 64; i++){ const t = i / 64 * Math.PI * 2, c = Math.cos(t), s = Math.sin(t);
pts.push(a === 0 ? new V3(c, s, 0) : a === 1 ? new V3(0, c, s) : new V3(s, 0, c)); }
g.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), mat));
}
const arrow = new THREE.Group();
arrow.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([new V3(0, -0.7, 0), new V3(0, 0.85, 0)]), mat));
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.2, 12), new THREE.MeshBasicMaterial({ color: FCOL[f.mode] }));
cone.position.y = 0.95; arrow.add(cone); g.add(arrow);
const pick = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 14), new THREE.MeshBasicMaterial({ color: FCOL[f.mode], transparent: true, opacity: 0.06, depthWrite: false }));
pick.userData.field = f; g.add(pick);
Object.assign(f, { obj: g, mat, cone, arrow, pick });
arrow.visible = f.mode === 'dir';
fieldsGroup.add(g); fields.push(f);
selectField(f); refreshList();
return f;
}
function setFieldMode(f, m){
f.mode = m; f.mat.color.setHex(FCOL[m]); f.cone.material.color.setHex(FCOL[m]); f.pick.material.color.setHex(FCOL[m]);
f.arrow.visible = m === 'dir';
}
function refreshList(){
const l = $('#flist'); l.innerHTML = '';
fields.forEach((f, i) => {
const b = document.createElement('button'); b.textContent = 'Feld ' + (i + 1);
if (f === selField) b.className = 'sel';
b.onclick = () => selectField(f); l.appendChild(b);
});
}
function syncFieldUI(){
const box = $('#fsel');
if (!selField){ box.style.display = 'none'; return; }
box.style.display = 'block';
$('#fMode').value = selField.mode;
$('#fStr').value = selField.strength; $('#fStrV').textContent = selField.strength.toFixed(2);
const r = selField.obj.scale.x; $('#fRad').value = Math.min(2.5, Math.max(0.2, r)); $('#fRadV').textContent = r.toFixed(2);
}
function selectField(f){
selField = f; refreshList(); syncFieldUI();
if (f && P.fieldsOn && $('#fShow').checked) transform.attach(f.obj); else transform.detach();
}
function removeField(){
if (!selField) return;
fieldsGroup.remove(selField.obj); fields.splice(fields.indexOf(selField), 1);
transform.detach(); selField = fields[fields.length - 1] || null;
if (selField && P.fieldsOn && $('#fShow').checked) transform.attach(selField.obj);
refreshList(); syncFieldUI(); schedule(1);
}
function prepareFields(){
FS = !P.fieldsOn ? [] : fields.map(f => {
f.obj.updateMatrixWorld(true);
const ax = new V3(0, 1, 0).applyQuaternion(f.obj.quaternion);
return { inv: f.obj.matrixWorld.clone().invert(), mode: f.mode, strength: f.strength,
cx: f.obj.position.x, cy: f.obj.position.y, cz: f.obj.position.z, ax: ax.x, ay: ax.y, az: ax.z };
});
}
/* ------------------------------------------------------------ Rebuild */
let lastMs = 400;
function rebuild(opts = {}){
if (!headIdx) return;
const t0 = performance.now();
const wasFull = !rig || T >= rig.Tmax - 0.01;
prepareFields();
const nC = Math.max(1, seeds.length * (P.sym ? 2 : 1));
const boneCap = P.maxBones / (1.8 * nC);
const rAvg = P.radius * 0.7, segL = P.segLen * P.lenScale * 0.8;
const vertCap = P.maxVerts * P.voxel * P.voxel / (1.6 * 6.2832 * rAvg * nC * segL);
segCap = Math.max(4, Math.floor(Math.min(boneCap, vertCap)));
const corals = [];
for (const s of seeds){
const br = growCoral(s), delay = rand01(s.id, 77) * 0.6;
const rb = P.baseSize * s.sp;
const base = rb > 0.004 ? { a: s.pos.clone().addScaledVector(s.n, -0.7 * rb), b: s.pos.clone().addScaledVector(s.n, -0.45 * rb), r: rb } : null;
corals.push({ branches: br, delay, id: s.id, base });
if (P.sym) corals.push({ branches: mirrorBranches(br), delay, id: s.id,
base: base && { a: new V3(-base.a.x, base.a.y, base.a.z), b: new V3(-base.b.x, base.b.y, base.b.z), r: base.r } });
}
if (staticMesh) exitStatic();
lastCorals = corals;
buildRig(corals);
lastMs = performance.now() - t0;
$('#busy').style.display = 'none';
if (!rig) return;
if (opts.regrow){ T = 0; playing = true; }
else if (wasFull) T = rig.Tmax;
else T = Math.min(T, rig.Tmax);
$('#stat').textContent = `${corals.length} Korallen · ${rig.branches} Äste · ${rig.N} Bones · ${(rig.verts / 1000).toFixed(0)}k Vertices · Voxel ${rig.vox.toFixed(3)} · ${Math.round(lastMs)} ms`;
$('#rules').textContent = rulesText();
}
function rulesText(){
const w = weights(null).map(x => Math.round(x * 100));
return `A → F[+A][-A] ${w[0]}%\nA → F[+A][⟳A][-A] ${w[1]}%\nA → F[&A]A ${w[2]}%\nA → FA ${w[3]}%\n\nIm Feld f: Tiefe +${P.boost}·f\n F × (1 + ${P.stretch}·f)`;
}
let tmr = 0, pend = 0;
function schedule(kind){
pend = Math.max(pend, kind); clearTimeout(tmr);
$('#busy').style.display = 'block';
tmr = setTimeout(() => { setTimeout(() => { if (pend === 2) makeSeeds(); pend = 0; rebuild(); }, 20); }, Math.max(140, lastMs * 0.5));
}
/* ------------------------------------------------------------ UI */
function addSlider(host, label, k, min, max, step, kind, digits = 2){
const w = document.createElement('label'); w.className = 'sl';
w.innerHTML = `<span class="t">${label}<b></b></span><input type="range" min="${min}" max="${max}" step="${step}">`;
const inp = w.querySelector('input'), val = w.querySelector('b');
inp.value = P[k]; val.textContent = (+P[k]).toFixed(digits);
inp.oninput = () => { P[k] = parseFloat(inp.value); val.textContent = P[k].toFixed(digits); if (kind) schedule(kind); };
host.appendChild(w); return inp;
}
function addCheck(host, label, k, kind, fn){
const w = document.createElement('label'); w.className = 'chk';
w.innerHTML = `<input type="checkbox">${label}`;
const inp = w.querySelector('input'); inp.checked = P[k];
inp.onchange = () => { P[k] = inp.checked; if (fn) fn(); if (kind) schedule(kind); };
host.appendChild(w);
}
{
const X = $('#gX');
addSlider(X, 'Detail (Mio. Vertices)', 'hiVerts', 0.5, 8, 0.1, 0, 1);
addSlider(X, 'Feinste Voxelgröße', 'hiVox', 0.003, 0.02, 0.0005, 0, 4);
addSlider(X, 'Glättung', 'hiSmooth', 0, 16, 1, 0, 0);
const Pp = $('#gP');
addSlider(Pp, 'Packdichte', 'density', 0.6, 24, 0.1, 2, 1);
addCheck(Pp, 'Symmetrie an der Mittelachse', 'sym', 2);
addSlider(Pp, 'Pinselgröße', 'brush', 0.04, 0.6, 0.01, 0);
addSlider(Pp, 'Pinselstärke', 'brushStr', 0.05, 1, 0.01, 0);
const B = $('#gB');
addSlider(B, 'Länge der Tentakel', 'lenScale', 0.3, 6, 0.05, 1);
addCheck(B, 'Stämme verschmelzen (Basis morpht, Bewegung steifer)', 'fuse', 1);
addSlider(B, 'Stammdicke', 'trunk', 0, 4, 0.05, 1);
addSlider(B, 'Stammlänge (bis normal dünn)', 'trunkLen', 0.04, 0.8, 0.01, 1);
addSlider(B, 'Fundament (Größe)', 'baseSize', 0, 1.2, 0.01, 1);
addSlider(B, 'Glättung', 'smoothIt', 0, 10, 1, 1, 0);
addSlider(B, 'Blobigkeit', 'blob', 0, 1, 0.01, 1);
addSlider(B, 'Blob-Frequenz', 'blobFreq', 4, 50, 0.5, 1, 1);
addSlider(B, 'Kuppen an den Spitzen', 'club', 0, 1, 0.01, 1);
addSlider(B, 'Verschmelzung', 'merge', 1.3, 3, 0.05, 1);
addSlider(B, 'Dicke', 'radius', 0.01, 0.12, 0.001, 1, 3);
addSlider(B, 'Dicke der Spitzen', 'rTip', 0.004, 0.08, 0.0005, 1, 4);
addSlider(B, 'Auflösung (Voxelgröße)', 'voxel', 0.008, 0.05, 0.001, 1, 3);
addSlider(B, 'Limit Vertices', 'maxVerts', 40000, 600000, 10000, 1, 0);
addSlider(B, 'Gitter-Limit (Mio. Zellen, Speicher)', 'maxCells', 1, 10, 0.5, 1, 1);
addSlider(B, 'Limit Bones', 'maxBones', 1500, 20000, 500, 1, 0);
const S = $('#gS');
addSlider(S, 'Iterationen', 'iter', 2, 20, 1, 1, 0);
addSlider(S, 'Krümmung', 'curl', 0, 0.5, 0.005, 1, 3);
addSlider(S, 'Verzweigung', 'branching', 0, 1, 0.01, 1);
addSlider(S, 'Verzweigungswinkel', 'angle', 10, 70, 1, 1, 0);
addSlider(S, 'Winkelstreuung', 'angleVar', 0, 1, 0.01, 1);
addSlider(S, 'Unruhe', 'wobble', 0, 0.5, 0.005, 1, 3);
addSlider(S, 'Segmentlänge (Basis)', 'segLen', 0.03, 0.4, 0.0025, 1, 3);
addSlider(S, 'Längenabnahme', 'lenDecay', 0.7, 1, 0.005, 1, 3);
addSlider(S, 'Dickenabnahme', 'rRatio', 0.6, 0.95, 0.01, 1);
addSlider(S, 'Aufwärtsdrang', 'tropUp', 0, 1, 0.01, 1);
addSlider(S, 'Abbruchwahrscheinlichkeit', 'stop', 0, 0.3, 0.005, 1, 3);
const F = $('#gF');
addSlider(F, 'Höhenzuwachs im Feld', 'boost', 0, 8, 1, 1, 0);
addSlider(F, 'Streckung im Feld', 'stretch', 0, 1.5, 0.01, 1);
addSlider(F, 'Ablenkung zum Feld', 'bend', 0, 1.5, 0.01, 1);
const M = $('#gM');
addSlider(M, 'Bewegungsintensität', 'flow', 0, 10, 0.05);
addSlider(M, 'Biegsamkeit', 'sway', 0, 8, 0.05);
addSlider(M, 'Basissteifheit', 'stiffExp', 0.2, 3, 0.05);
addSlider(M, 'Dünne Äste biegen mehr', 'thin', 0, 2.5, 0.05);
addSlider(M, 'Variation pro Ast', 'vari', 0, 1, 0.01);
addSlider(M, 'Wellen', 'wave', 0, 4, 0.01);
addSlider(M, 'Wellenlänge (Frequenz)', 'waveFreq', 0.5, 16, 0.1, 0, 1);
addSlider(M, 'Wellentempo', 'waveSpeed', 0, 6, 0.05);
addSlider(M, 'Strömungsgröße', 'flowScale', 0.3, 8, 0.1);
addSlider(M, 'Tempo der Strömung', 'flowSpeed', 0, 1.5, 0.01);
addSlider(M, 'Radius der Maus', 'mouseR', 0.08, 1.2, 0.01);
addSlider(M, 'Kraft der Maus', 'mouseF', 0, 6, 0.1, 0, 1);
addSlider(M, 'Wachstumstempo', 'speed', 0.2, 4, 0.05);
const Tm = $('#gT');
addSlider(Tm, 'Glanz (Rauheit)', 'rough', 0.02, 1, 0.01, 0).addEventListener('input', () => { coralMat.roughness = P.rough; });
const cw = document.createElement('label'); cw.className = 'sl';
cw.innerHTML = '<span class="t">Farbe der Koralle</span><input type="color">'; Tm.appendChild(cw);
const ci = cw.querySelector('input'); ci.value = P.coral; ci.oninput = () => { P.coral = ci.value; coralMat.color.set(P.coral); };
const bw = document.createElement('label'); bw.className = 'sl';
bw.innerHTML = '<span class="t">Hintergrund</span><input type="color">'; Tm.appendChild(bw);
const bi = bw.querySelector('input'); bi.value = P.bg; bi.oninput = () => { P.bg = bi.value; scene.background.set(P.bg); };
}
$('#preset').onchange = e => { P.preset = e.target.value; schedule(1); $('#rules').textContent = rulesText(); };
$('#rigShow').onchange = e => { P.rig = e.target.checked; if (rig) rig.lines.visible = P.rig && !staticMode; };
$('#menu').onclick = () => $('#ui').classList.toggle('hide');
$('#bGrow').onclick = () => { T = 0; playing = true; };
$('#bDice').onclick = () => { P.seed = (Math.random() * 100000) | 0; schedule(2); T = 0; playing = true; };
$('#tl').oninput = e => { if (rig){ T = parseFloat(e.target.value) * rig.Tmax; playing = false; } };
$('#bPaint').onclick = () => setPaintMode(!P.paintMode);
$('#bErase').onclick = e => { P.erase = !P.erase; e.currentTarget.classList.toggle('on', P.erase); };
$('#bFill').onclick = () => { if (!headPaint) return; headPaint.fill(1); refreshHeadColors(); updatePaintWeights(); markDirty(); };
$('#bClear').onclick = () => { if (!headPaint) return; headPaint.fill(0); refreshHeadColors(); updatePaintWeights(); markDirty(); if (!P.paintMode) setPaintMode(true); };
$('#bRegrow').onclick = () => { clearTimeout(tmr); pend = 0; $('#busy').style.display = 'block'; setTimeout(() => { makeSeeds(); rebuild({ regrow: true }); markDirty(false); }, 20); };
$('#fAdd').onclick = () => { setFieldsOn(true); addField({ pos: new V3((Math.random() - 0.5) * 0.8, 0.9 + Math.random() * 0.5, (Math.random() - 0.2) * 0.8) }); };
$('#fDel').onclick = removeField;
function setFieldsOn(on){ P.fieldsOn = on; $('#fOn').checked = on; fieldsGroup.visible = on && $('#fShow').checked; if (!fieldsGroup.visible) transform.detach(); else if (selField) transform.attach(selField.obj); schedule(1); }
$('#fOn').onchange = e => setFieldsOn(e.target.checked);
$('#fShow').onchange = e => { fieldsGroup.visible = e.target.checked && P.fieldsOn; if (!fieldsGroup.visible) transform.detach(); else if (selField) transform.attach(selField.obj); };
$('#fMode').onchange = e => { if (selField){ setFieldMode(selField, e.target.value); schedule(1); } };
$('#fStr').oninput = e => { if (selField){ selField.strength = parseFloat(e.target.value); $('#fStrV').textContent = selField.strength.toFixed(2); schedule(1); } };
$('#fRad').oninput = e => { if (selField){ selField.obj.scale.setScalar(parseFloat(e.target.value)); $('#fRadV').textContent = selField.obj.scale.x.toFixed(2); schedule(1); } };
const modes = { translate: '#tmT', rotate: '#tmR', scale: '#tmS' };
function setMode(m){ transform.setMode(m); for (const k in modes) $(modes[k]).classList.toggle('on', k === m); }
$('#tmT').onclick = () => setMode('translate'); $('#tmR').onclick = () => setMode('rotate'); $('#tmS').onclick = () => setMode('scale');
$('#fileHead').onchange = e => {
const file = e.target.files[0]; if (!file) return;
file.arrayBuffer().then(buf => new GLTFLoader().parse(buf, '', g => setHead(g.scene), () => { $('#stat').textContent = 'GLB konnte nicht gelesen werden.'; }));
};
$('#fileNrm').onchange = e => {
const file = e.target.files[0]; if (!file) return;
new THREE.TextureLoader().load(URL.createObjectURL(file), tex => applyNormal(tex));
};
function applyNormal(tex){
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
coralMat.normalMap = tex; coralMat.normalScale.set(0.6, 0.6); coralMat.needsUpdate = true;
}
new THREE.TextureLoader().load('normal_map.jpg', applyNormal, undefined, () => {});
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', e => {
e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return;
if (/\.(glb|gltf)$/i.test(f.name)) f.arrayBuffer().then(b => new GLTFLoader().parse(b, '', g => setHead(g.scene)));
});
/* ------------------------------------------------------------ Starres Modell (UI) */
let lastCorals = null, staticMode = false, staticMesh = null, staticBusy = false, staticCancel = false;
function progUI(on, f = 0, txt = ''){
$('#prog').classList.toggle('on', on);
$('#progBar').style.width = Math.round(f * 100) + '%'; $('#progTxt').textContent = txt;
}
function setStatic(on){
staticMode = on;
if (rig){ rig.mesh.visible = !on; rig.lines.visible = !on && P.rig; }
if (staticMesh) staticMesh.visible = on;
$('#bDownload').disabled = !on; $('#bBack').disabled = !on;
}
function exitStatic(){
if (staticMesh){ scene.remove(staticMesh); staticMesh.geometry.dispose(); staticMesh = null; }
setStatic(false);
}
async function renderStatic(){
if (!lastCorals || staticBusy) return;
staticBusy = true; staticCancel = false;
$('#bStatic').disabled = true; $('#bCancel').disabled = false;
progUI(true, 0, 'Starte …'); await tick();
const t0 = performance.now();
let res = null;
try { res = await buildStatic(lastCorals, (f, txt) => progUI(true, f, txt), () => staticCancel); }
catch (err){ $('#stat').textContent = 'Starres Modell fehlgeschlagen (Speicher?). Weniger Detail wählen.'; console.error(err); }
staticBusy = false; $('#bStatic').disabled = false; $('#bCancel').disabled = true;
if (!res){ progUI(false); return; }
progUI(true, 0.99, 'Normalen …'); await tick();
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(res.pos, 3));
g.setIndex(new THREE.BufferAttribute(res.ix, 1));
g.computeVertexNormals();
if (staticMesh){ scene.remove(staticMesh); staticMesh.geometry.dispose(); }
staticMesh = new THREE.Mesh(g, coralMat); staticMesh.frustumCulled = false; scene.add(staticMesh);
setStatic(true);
progUI(false);
$('#stat').textContent = `Starres Modell · ${(res.verts / 1e6).toFixed(2)} Mio. Vertices · ${(res.ix.length / 3e6).toFixed(2)} Mio. Dreiecke · Voxel ${res.vs.toFixed(4)} · ${((performance.now() - t0) / 1000).toFixed(1)} s`;
}
function downloadStatic(){
if (!staticMesh) return;
progUI(true, 0.5, 'Exportiere GLB …');
setTimeout(() => {
const grp = new THREE.Group();
const m = new THREE.Mesh(staticMesh.geometry, new THREE.MeshStandardMaterial({ color: P.coral, roughness: P.rough }));
m.name = 'coral'; grp.add(m);
if ($('#expHead').checked && headMesh){
const hg = headMesh.geometry.clone(); hg.deleteAttribute('color');
const hm = new THREE.Mesh(hg, new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.95 })); hm.name = 'head'; grp.add(hm);
}
new GLTFExporter().parse(grp, res => {
const blob = new Blob([res], { type: 'model/gltf-binary' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'coral_static.glb';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
progUI(false);
}, err => { progUI(false); $('#stat').textContent = 'Export fehlgeschlagen.'; console.error(err); }, { binary: true });
}, 40);
}
$('#bStatic').onclick = renderStatic;
$('#bCancel').onclick = () => { staticCancel = true; };
$('#bBack').onclick = exitStatic;
$('#bDownload').onclick = downloadStatic;
/* ------------------------------------------------------------ Gizmo + Interaktion */
const transform = new TransformControls(camera, canvas);
transform.setSize(0.8);
transform.addEventListener('dragging-changed', e => { controls.enabled = !e.value; });
transform.addEventListener('objectChange', () => { syncFieldUI(); schedule(1); });
scene.add(transform);
let downXY = null, inside = false;
canvas.addEventListener('pointermove', e => { ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); inside = true; });
canvas.addEventListener('pointerleave', () => { inside = false; mouse.down = false; painting = false; brushRing.visible = false; });
canvas.addEventListener('pointerdown', e => {
downXY = [e.clientX, e.clientY];
if (e.button !== 0 || transform.axis !== null || transform.dragging) return;
if (P.paintMode){ painting = true; if (e.shiftKey) P.erase = true; } else mouse.down = true;
});
addEventListener('pointerup', e => {
mouse.down = false;
if (painting){ painting = false; updatePaintWeights(); markDirty(); return; }
if (e.button !== 0 || !downXY || e.target !== canvas) return;
const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
if (moved > 5 || !fieldsGroup.visible || transform.dragging || P.paintMode) return;
ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
ray.setFromCamera(ndc, camera);
const hit = ray.intersectObjects(fields.map(f => f.pick), false)[0];
if (hit) selectField(hit.object.userData.field);
});
addEventListener('keydown', e => {
const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'select') return;
const k = e.key.toLowerCase();
if (k === 'g') setMode('translate'); else if (k === 'r') setMode('rotate'); else if (k === 's') setMode('scale');
else if (k === 'p') setPaintMode(!P.paintMode);
else if (k === 'delete' || k === 'backspace') removeField();
});
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
});
/* ------------------------------------------------------------ Loop */
function updateRigLines(){
if (!rig || !P.rig) return;
const a = rig.lines.geometry.attributes.position.array, bones = rig.bones, par = rig.parent;
for (let i = 0; i < rig.N; i++){
const e = bones[i].matrixWorld.elements, p = par[i] < 0 ? e : bones[par[i]].matrixWorld.elements;
a[i * 6] = p[12]; a[i * 6 + 1] = p[13]; a[i * 6 + 2] = p[14]; a[i * 6 + 3] = e[12]; a[i * 6 + 4] = e[13]; a[i * 6 + 5] = e[14];
}
rig.lines.geometry.attributes.position.needsUpdate = true;
}
let last = performance.now();
function frame(now){
const dt = Math.min(0.05, (now - last) / 1000); last = now;
// Pinsel
if (P.paintMode && inside && headMesh){
ray.setFromCamera(ndc, camera);
const hit = ray.intersectObject(headMesh, false)[0];
if (hit){
brushHit = hit.point;
brushRing.visible = true; brushRing.scale.setScalar(P.brush);
const nrm = hit.face.normal.clone();
brushRing.position.copy(hit.point).addScaledVector(nrm, 0.01); brushRing.lookAt(hit.point.clone().add(nrm));
if (painting) paintAt(hit.point, dt);
} else brushRing.visible = false;
}
if (rig && !staticMode){
if (playing){ T += dt * P.speed; if (T >= rig.Tmax){ T = rig.Tmax; playing = false; } }
mouse.active = inside && !P.paintMode && !painting;
if (mouse.active){
ray.setFromCamera(ndc, camera);
mouse.ox = ray.ray.origin.x; mouse.oy = ray.ray.origin.y; mouse.oz = ray.ray.origin.z;
mouse.dx = ray.ray.direction.x; mouse.dy = ray.ray.direction.y; mouse.dz = ray.ray.direction.z;
}
updateRig(now / 1000, dt);
$('#tl').value = T / rig.Tmax; $('#tlv').textContent = (T / rig.Tmax * 100).toFixed(0) + ' %';
}
controls.update();
renderer.render(scene, camera);
updateRigLines();
requestAnimationFrame(frame);
}
fieldsGroup.visible = false;
$('#preset').value = P.preset;
$('#rules').textContent = rulesText();
loadHeadURL('HEAD_ONLY.glb');
requestAnimationFrame(frame);
</script>
</body>
</html>