index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SWARM MOTION MEMBRANE</title>
<style>
:root{--ink:#000;--text:#333;--mute:#8c8c8c;--line:#e7e7e7}
*{box-sizing:border-box}
[hidden]{display:none!important}
html,body{margin:0;height:100%;background:#fff;color:var(--text);font:11px/1.4 ui-sans-serif,system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}
canvas{position:fixed;inset:0;display:block;outline:none}
#panel{position:fixed;top:12px;left:12px;width:260px;max-height:calc(100% - 84px);overflow-y:auto;background:#fff;border:1px solid var(--line);padding:12px 14px 14px;z-index:5}
#panel::-webkit-scrollbar{width:6px}
#panel::-webkit-scrollbar-thumb{background:#ddd}
.title{font-size:13px;font-weight:600;color:var(--ink)}
.sub{display:block;color:var(--mute);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
h2{margin:16px 0 6px;padding-top:10px;border-top:1px solid var(--line);font-size:11px;font-weight:600;color:var(--ink)}
h3{margin:12px 0 2px;font-size:11px;font-weight:400;color:var(--mute)}
.row{margin:6px 0}
.row label{display:flex;justify-content:space-between}
.row output{color:var(--ink);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;height:14px;margin:2px 0 0;background:transparent;-webkit-appearance:none;appearance:none}
input[type=range]::-webkit-slider-runnable-track{height:1px;background:#cfcfcf}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:#000;margin-top:-4px;border:0}
input[type=range]::-moz-range-track{height:1px;background:#cfcfcf}
input[type=range]::-moz-range-thumb{width:9px;height:9px;border-radius:50%;background:#000;border:0}
input[type=range]:focus-visible{outline:1px solid #000;outline-offset:3px}
select,button{font:inherit;color:var(--ink);background:#fff;border:1px solid #d6d6d6;border-radius:0;padding:5px 8px}
select{width:100%}
input[type=color]{width:100%;height:22px;padding:0;border:1px solid #d6d6d6;background:#fff;cursor:pointer;margin-top:2px}
button{cursor:pointer}
button:hover,select:hover{border-color:#000}
button:focus-visible,select:focus-visible{outline:1px solid #000;outline-offset:2px}
.btns{display:flex;gap:6px;margin-top:8px}
.btns button{flex:1}
.chk{display:flex;align-items:center;gap:7px;margin:5px 0;cursor:pointer}
.chk input{accent-color:#000;margin:0}
.info{margin-top:6px;color:var(--mute);font-variant-numeric:tabular-nums}
.err{color:#b00020;margin-top:6px}
#bar{position:fixed;left:12px;right:12px;bottom:12px;height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#fff;border:1px solid var(--line);z-index:5}
#bar button{width:30px;height:30px;padding:0;display:grid;place-items:center;border:0}
#bar button:hover{background:#f3f3f3}
#bar svg{fill:#000}
#scrub{flex:1}
#bar .t{font-variant-numeric:tabular-nums;color:var(--mute);min-width:34px}
#bar .chk{margin:0}
#drop{position:fixed;inset:0;display:grid;place-items:center;z-index:4;pointer-events:none}
#drop .box{pointer-events:auto;text-align:center;padding:28px 34px;border:1px dashed #cfcfcf;background:#fff}
#drop.drag .box{border-color:#000}
#drop .big{font-size:13px;color:var(--ink);margin-bottom:4px}
#drop .small{color:var(--mute);margin-bottom:14px}
</style>
</head>
<body>
<div id="drop">
<div class="box">
<div class="big">FBX mit Animation hierher ziehen</div>
<div class="small">Körper und Bewegung, ein File</div>
<button id="pickBig">Datei wählen</button>
<div class="err" id="err"></div>
</div>
</div>
<div id="panel" hidden>
<div class="title">SWARM MOTION MEMBRANE</div>
<span class="sub" id="subtitle">–</span>
<div class="btns"><button id="pick">Andere FBX laden</button></div>
<input type="file" id="file" accept=".fbx" hidden>
<h2>Animation</h2>
<div id="slAnim"></div>
<label class="chk"><input type="checkbox" id="zup"> Z-up-Quelle</label>
<label class="chk"><input type="checkbox" id="follow" checked> Kamera folgt dem Körper</label>
<h2>Körper</h2>
<select id="bodyMode">
<option value="wire">Wireframe</option>
<option value="solid">Solid</option>
<option value="off">Ausgeblendet</option>
</select>
<label class="chk"><input type="checkbox" id="skel"> Skelett anzeigen</label>
<div class="info" id="bodyInfo"></div>
<h2>Drohnen</h2>
<div id="slPlace"></div>
<label class="chk"><input type="checkbox" id="flipF"> Forward umkehren</label>
<label class="chk"><input type="checkbox" id="membrane"> Membran: nur außerhalb des Körpers</label>
<div id="slMemb"></div>
<h3>Ankerpunkte</h3>
<select id="anchorMode">
<option value="hybrid" selected>Bones + Mesh-Richtung (Mittelweg)</option>
<option value="mesh">Mesh-Vertex (skinned)</option>
<option value="bones">Nur Bones (Hülle)</option>
</select>
<h3>Verteilung</h3>
<select id="distMode">
<option value="random" selected>Zufällig</option>
<option value="mirror">Achsensymmetrisch (links/rechts)</option>
</select>
<div class="btns">
<button id="redis">Neu verteilen</button>
<button id="resetSwarm">Positionen zurücksetzen</button>
</div>
<h3>Schwarmverhalten</h3>
<select id="behavior">
<option value="hover">Hover (ruhig)</option>
<option value="boids" selected>Schwarm (Boids)</option>
<option value="orbit">Orbit</option>
<option value="nervous">Nervös</option>
<option value="cloud">Wolke</option>
<option value="burst">Streuen</option>
<option value="custom">Eigene Werte</option>
</select>
<div id="slSwarm"></div>
<h3>Flow-Field (Perlin, 4D)</h3>
<label class="chk"><input type="checkbox" id="curl" checked> Curl (wirbelnd)</label>
<div id="slFlow"></div>
<h3>Darstellung</h3>
<label class="chk"><input type="checkbox" id="arrows" checked> Richtungspfeile</label>
<div id="slView"></div>
<h2>Mesh aus Drohnen</h2>
<label class="chk"><input type="checkbox" id="meshOn" checked> Mesh anzeigen</label>
<label class="chk"><input type="checkbox" id="smoothTri"> Smoothe Triangulation (wachsen/schrumpfen)</label>
<select id="shadeMode">
<option value="smooth">Glatt (smooth Normals)</option>
<option value="flat">Flat, matt (Licht + Schatten)</option>
<option value="gloss">Flat, glänzend</option>
</select>
<div class="row"><label><span>Grundfarbe</span></label><input type="color" id="meshColor" value="#d8d8d8"></div>
<label class="chk"><input type="checkbox" id="delaunay" checked> Delaunay-Filter (lokal)</label>
<label class="chk"><input type="checkbox" id="meshWire"> Wireframe</label>
<div id="slMesh"></div>
<div class="info" id="meshInfo">–</div>
<h2>Material</h2>
<div id="slMat"></div>
<label class="chk"><input type="checkbox" id="nmOn" disabled> Normal Map verwenden</label>
<label class="chk"><input type="checkbox" id="nmFlip"> Y invertieren</label>
<div class="btns"><button id="nmPick">Normal Map wählen…</button></div>
<input type="file" id="nmFile" accept="image/*" hidden>
<div class="info" id="nmInfo">–</div>
<h2>Licht und Schatten</h2>
<label class="chk"><input type="checkbox" id="shadowOn" checked> Schatten</label>
<div id="slLight"></div>
</div>
<div id="bar" hidden>
<button id="play" title="Play / Pause (Leertaste)"><svg viewBox="0 0 24 24" width="16" height="16"><path id="playIcon" d="M8 5v14l11-7z"/></svg></button>
<input type="range" id="scrub" min="0" max="1000" value="0">
<span class="t" id="tCur">0.00</span><span class="t" style="min-width:0">/</span><span class="t" id="tTot">0.00</span>
<label class="chk"><input type="checkbox" id="loop" checked> Loop</label>
</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 { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const MAXN = 600; // max. Drohnen (darüber ruckelt es)
const KMAX = 20; // max. Nachbarn pro Vertex für das Mesh
const LM = KMAX * 2; // symmetrisierte Nachbarliste
const MAXTRI = 30000; // Kapazität Dreiecke
const UP = new THREE.Vector3(0, 1, 0);
const el = id => document.getElementById(id);
/* ============================== Szene ============================== */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
scene.add(new THREE.HemisphereLight(0xffffff, 0xe6e6e6, 0.35));
// Key-Light von schräg oben, wirft weiche Schatten (Körper, Drohnen-Mesh, Boden)
const dl = new THREE.DirectionalLight(0xffffff, 1.0);
dl.castShadow = true;
dl.shadow.mapSize.set(2048, 2048);
dl.shadow.radius = 3;
scene.add(dl); scene.add(dl.target);
const shadowPlane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShadowMaterial({ opacity: 0.18 }));
shadowPlane.rotation.x = -Math.PI / 2; shadowPlane.receiveShadow = true; scene.add(shadowPlane);
const _ld = new THREE.Vector3();
function updateLightRig(resize) {
if (resize) {
const sc = dl.shadow.camera, r = H * 2.2;
sc.left = -r; sc.right = r; sc.top = r; sc.bottom = -r; sc.near = H * 3; sc.far = H * 14; sc.updateProjectionMatrix();
dl.shadow.bias = -0.0004; dl.shadow.normalBias = H * 0.004;
shadowPlane.scale.setScalar(H * 40);
}
const az = P.lightAz * Math.PI / 180, ev = P.lightEl * Math.PI / 180;
_ld.set(Math.sin(az) * Math.cos(ev), Math.sin(ev), Math.cos(az) * Math.cos(ev));
let cx = 0, cz = 0;
if (model && hips) { const e = hips.matrixWorld.elements; cx = e[12]; cz = e[14]; }
dl.target.position.set(cx, H * 0.5, cz); dl.target.updateMatrixWorld();
dl.position.set(cx, H * 0.5, cz).addScaledVector(_ld, H * 8);
dl.intensity = P.lightInt;
dl.castShadow = el('shadowOn').checked;
shadowPlane.visible = dl.castShadow;
shadowPlane.material.opacity = P.shadowOp;
shadowPlane.position.set(cx, H * 0.002, cz);
}
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000);
camera.position.set(0, 1.2, 3.5);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0.9, 0);
function onResize() {
const w = window.innerWidth, h = window.innerHeight;
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize); onResize();
let grid = null;
function rebuildGrid() {
if (grid) { scene.remove(grid); grid.geometry.dispose(); grid.material.dispose(); }
grid = new THREE.GridHelper(H * 12, 24, 0xd2d2d2, 0xebebeb);
scene.add(grid);
}
/* ============================== Parameter / UI ============================== */
const P = {};
const behSel = el('behavior');
const PRESETS = {
hover: { vmax: 0.6, sep: 2, align: 0.5, coh: 0.2, pull: 14, roam: 0.03, noise: 0.3, swirl: 0, damp: 3 },
boids: { vmax: 1.2, sep: 3, align: 2, coh: 1.5, pull: 10, roam: 0.12, noise: 0.6, swirl: 0, damp: 0.8 },
orbit: { vmax: 1.4, sep: 2, align: 1, coh: 0.3, pull: 10, roam: 0.15, noise: 0.3, swirl: 3, damp: 0.6 },
nervous: { vmax: 2.5, sep: 3, align: 0.3, coh: 0.3, pull: 12, roam: 0.08, noise: 4, swirl: 0, damp: 0.4 },
cloud: { vmax: 1.2, sep: 2, align: 2, coh: 3, pull: 3, roam: 0.4, noise: 0.8, swirl: 0.5, damp: 0.6 },
burst: { vmax: 2.2, sep: 6, align: 0, coh: 0, pull: 1.5,roam: 0.8, noise: 1.5, swirl: 0, damp: 0.2 },
};
const ctrl = {};
function addSlider(parent, key, label, min, max, step, val, opt = {}) {
const dec = (String(step).split('.')[1] || '').length;
const row = document.createElement('div'); row.className = 'row';
row.innerHTML = `<label><span>${label}</span><output></output></label><input type="range" min="${min}" max="${max}" step="${step}" value="${val}">`;
const inp = row.querySelector('input'), out = row.querySelector('output');
const set = v => { P[key] = +v; out.textContent = (+v).toFixed(dec); };
inp.addEventListener('input', () => { set(inp.value); if (opt.preset) behSel.value = 'custom'; if (opt.onInput) opt.onInput(); });
set(val); parent.appendChild(row);
ctrl[key] = { setValue: v => { inp.value = v; set(v); } };
}
// Animation
addSlider(el('slAnim'), 'animSpeed', 'Geschwindigkeit', 0.1, 2, 0.05, 1);
// Platzierung (Längen in Anteilen der Körperhöhe)
addSlider(el('slPlace'), 'count', 'Anzahl', 1, 600, 1, 100);
addSlider(el('slPlace'), 'offN', 'Offset Normale', -0.2, 0.3, 0.005, 0.03);
addSlider(el('slPlace'), 'offF', 'Offset Forward', -0.3, 0.3, 0.005, 0);
addSlider(el('slMemb'), 'membT', 'Membrandicke', 0.005, 0.2, 0.005, 0.03);
// Verhalten (werden von den Presets gesetzt)
const SW = el('slSwarm');
addSlider(SW, 'vmax', 'Max. Tempo', 0.1, 6, 0.05, PRESETS.boids.vmax, { preset: 1 });
addSlider(SW, 'sep', 'Separation', 0, 8, 0.1, PRESETS.boids.sep, { preset: 1 });
addSlider(SW, 'align', 'Alignment', 0, 5, 0.1, PRESETS.boids.align, { preset: 1 });
addSlider(SW, 'coh', 'Cohesion', 0, 5, 0.1, PRESETS.boids.coh, { preset: 1 });
addSlider(SW, 'pull', 'Bindung an Anker', 0, 30, 0.5, PRESETS.boids.pull, { preset: 1 });
addSlider(SW, 'roam', 'Freiraum um Anker',0, 1, 0.01, PRESETS.boids.roam, { preset: 1 });
addSlider(SW, 'noise', 'Zufall', 0, 6, 0.1, PRESETS.boids.noise, { preset: 1 });
addSlider(SW, 'swirl', 'Orbit-Drall', 0, 6, 0.1, PRESETS.boids.swirl, { preset: 1 });
addSlider(SW, 'damp', 'Dämpfung', 0, 10, 0.1, PRESETS.boids.damp, { preset: 1 });
addSlider(SW, 'sepR', 'Separationsradius',0.01,0.3, 0.005, 0.06);
addSlider(SW, 'percR', 'Nachbarradius', 0.02,0.6, 0.01, 0.2);
addSlider(SW, 'leash', 'Leine über Freiraum',0.02,1, 0.01, 0.2);
addSlider(el('slFlow'), 'flowStr', 'Stärke', 0, 10, 0.05, 0.25);
addSlider(el('slFlow'), 'flowScale', 'Skalierung', 0.1, 8, 0.05, 1.5);
addSlider(el('slFlow'), 'flowSpeed', 'Zeitlicher Wandel', 0, 2, 0.05, 0.3);
// Darstellung
addSlider(el('slView'), 'dotSize', 'Punktgröße', 0.002, 0.03, 0.001, 0.010);
addSlider(el('slView'), 'arrowSize', 'Pfeillänge', 0.01, 0.12, 0.002, 0.04);
// Mesh
addSlider(el('slMesh'), 'maxD', 'Max. Abstand', 0.03, 1, 0.005, 0.4);
addSlider(el('slMesh'), 'maxK', 'Max. Nachbarn', 3, 20, 1, 16);
addSlider(el('slMesh'), 'meshOp', 'Deckkraft', 0.1, 1, 0.05, 0.95);
addSlider(el('slMesh'), 'morphT', 'Übergangsdauer Dreiecke (s)', 0, 1.5, 0.05, 0.4);
addSlider(el('slMesh'), 'hyst', 'Hysterese Abstand', 1, 1.4, 0.01, 1.15);
addSlider(el('slMesh'), 'nPass', 'Normalen räumlich glätten', 0, 6, 1, 2);
addSlider(el('slMesh'), 'nSmooth','Normalen zeitlich glätten', 0, 0.98, 0.01, 0.85);
// Material
addSlider(el('slMat'), 'matSmooth', 'Glätte (Smoothness)', 0, 1, 0.01, 0.75, { onInput: () => applyMaterial() });
addSlider(el('slMat'), 'matMetal', 'Metallisch', 0, 1, 0.01, 0.2, { onInput: () => applyMaterial() });
addSlider(el('slMat'), 'nmStr', 'Normal-Stärke', 0, 3, 0.05, 1, { onInput: () => applyMaterial() });
addSlider(el('slMat'), 'tileU', 'Tiling U', 0.1, 40, 0.1, 6, { onInput: () => applyMaterial() });
addSlider(el('slMat'), 'tileV', 'Tiling V', 0.1, 40, 0.1, 6, { onInput: () => applyMaterial() });
// Licht
addSlider(el('slLight'), 'lightInt','Intensität', 0, 8, 0.1, 3.5);
addSlider(el('slLight'), 'lightAz', 'Richtung', -180, 180, 1, 35);
addSlider(el('slLight'), 'lightEl', 'Höhe', 10, 85, 1, 55);
addSlider(el('slLight'), 'shadowOp','Schattenstärke', 0, 1, 0.02, 0.18);
behSel.addEventListener('change', () => {
const pr = PRESETS[behSel.value]; if (!pr) return;
for (const k in pr) ctrl[k].setValue(pr[k]);
});
/* ============================== Zustand ============================== */
let model = null, mixer = null, clip = null, duration = 0, t = 0;
let playing = false, scrubbing = false;
let bones = [], boneIdx = new Map(), boneW = new Float32Array(0);
let meshes = [], skinMeshes = [];
let drawnPairs = [], segPairs = [], segCum = [], segTotal = 0, hips = null;
let H = 1; // Körperhöhe in Weltunits (alle Längen skalieren damit)
let fLocal = new THREE.Vector3(0, 0, 1);
let srcs = [], srcTotal = 0; // Mesh-Vertexquellen
let anchors = [];
const clock = new THREE.Clock();
/* ============================== Drohnen-Daten ============================== */
const pos = new Float32Array(MAXN * 3);
const vel = new Float32Array(MAXN * 3);
const acc = new Float32Array(MAXN * 3);
const dirA = new Float32Array(MAXN * 3);
const tgt = new Float32Array(MAXN * 3);
const tvel = new Float32Array(MAXN * 3);
const nrm = new Float32Array(MAXN * 3); // Ankernormale (Außenrichtung)
const ancP = new Float32Array(MAXN * 3); // Ankerpunkt auf der Körperoberfläche
const wand = new Float32Array(MAXN * 3);
const inited = new Uint8Array(MAXN);
/* ---------- Render-Objekte des Schwarms ---------- */
function discTexture() {
const c = document.createElement('canvas'); c.width = c.height = 64;
const g = c.getContext('2d'); g.fillStyle = '#000';
g.beginPath(); g.arc(32, 32, 30, 0, Math.PI * 2); g.fill();
const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; return tx;
}
const posAttr = new THREE.BufferAttribute(pos, 3); posAttr.setUsage(THREE.DynamicDrawUsage);
const pointsGeo = new THREE.BufferGeometry(); pointsGeo.setAttribute('position', posAttr);
const pointsMat = new THREE.PointsMaterial({ color: 0x000000, size: 0.02, sizeAttenuation: true, map: discTexture(), alphaTest: 0.5 });
const points = new THREE.Points(pointsGeo, pointsMat); points.frustumCulled = false; scene.add(points);
const shaftPos = new Float32Array(MAXN * 6);
const shaftAttr = new THREE.BufferAttribute(shaftPos, 3); shaftAttr.setUsage(THREE.DynamicDrawUsage);
const shaftGeo = new THREE.BufferGeometry(); shaftGeo.setAttribute('position', shaftAttr);
const shafts = new THREE.LineSegments(shaftGeo, new THREE.LineBasicMaterial({ color: 0x000000 }));
shafts.frustumCulled = false; scene.add(shafts);
const coneGeo = new THREE.ConeGeometry(0.16, 0.45, 6); coneGeo.translate(0, 0.225, 0);
const heads = new THREE.InstancedMesh(coneGeo, new THREE.MeshBasicMaterial({ color: 0x000000 }), MAXN);
heads.instanceMatrix.setUsage(THREE.DynamicDrawUsage); heads.frustumCulled = false; heads.count = 0; scene.add(heads);
const triIdx = new Uint32Array(MAXTRI * 3);
const vNrm = new Float32Array(MAXN * 3);
const rPos = new Float32Array(MAXTRI * 9), rNrm = new Float32Array(MAXTRI * 9); // Render-Buffer (nicht indiziert, pro Dreieck)
const rPosAttr = new THREE.BufferAttribute(rPos, 3); rPosAttr.setUsage(THREE.DynamicDrawUsage);
const dUV = new Float32Array(MAXN * 2); // UV pro Drohne (zylindrisch um die Körperachse, körperfest)
const rUv = new Float32Array(MAXTRI * 6);
const rUvAttr = new THREE.BufferAttribute(rUv, 2); rUvAttr.setUsage(THREE.DynamicDrawUsage);
const vNrmS = new Float32Array(MAXN * 3); // räumlich + zeitlich geglättet → Renderer
const rNrmAttr = new THREE.BufferAttribute(rNrm, 3); rNrmAttr.setUsage(THREE.DynamicDrawUsage);
const meshGeo = new THREE.BufferGeometry();
meshGeo.setAttribute('position', rPosAttr); meshGeo.setAttribute('normal', rNrmAttr); meshGeo.setAttribute('uv', rUvAttr);
meshGeo.setDrawRange(0, 0);
const swarmMat = new THREE.MeshStandardMaterial({ color: 0xd8d8d8, roughness: 0.38, metalness: 0.15, side: THREE.DoubleSide, transparent: true, opacity: 0.95 });
const flatMat = new THREE.MeshLambertMaterial({ color: 0xd8d8d8, flatShading: true, side: THREE.DoubleSide, transparent: true, opacity: 0.95 });
const glossMat = new THREE.MeshStandardMaterial({ color: 0xd8d8d8, flatShading: true, roughness: 0.15, metalness: 0.35, side: THREE.DoubleSide, transparent: true, opacity: 0.95 });
const swarmMats = [swarmMat, flatMat, glossMat];
const swarmMesh = new THREE.Mesh(meshGeo, swarmMat); swarmMesh.castShadow = true; swarmMesh.receiveShadow = true; swarmMesh.frustumCulled = false; scene.add(swarmMesh);
const skelPos = new Float32Array(3);
const skelGeo = new THREE.BufferGeometry();
const skelLines = new THREE.LineSegments(skelGeo, new THREE.LineBasicMaterial({ color: 0xb0b0b0 }));
skelLines.frustumCulled = false; skelLines.visible = false; scene.add(skelLines);
/* ============================== Triangulation ============================== */
// <<TRI
const cand = new Int16Array(MAXN * KMAX), candD = new Float32Array(MAXN * KMAX), candN = new Uint8Array(MAXN);
const Ls = new Int16Array(MAXN * LM), LN = new Uint8Array(MAXN);
const adj = new Uint8Array(MAXN * MAXN);
function insertCand(i, j, d2, K) {
const b = i * KMAX; let c = candN[i];
if (c < K) { candN[i] = ++c; } else if (d2 >= candD[b + c - 1]) return;
let s = c - 1;
while (s > 0 && candD[b + s - 1] > d2) { candD[b + s] = candD[b + s - 1]; cand[b + s] = cand[b + s - 1]; s--; }
candD[b + s] = d2; cand[b + s] = j;
}
// Lokaler Delaunay-Test: Umkreis des Dreiecks (in seiner Ebene) darf keinen anderen
// Punkt in einer Platte um die Ebene enthalten. Zusätzlich Umkreisradius <= maxD (Alpha-Shape-artig).
function delaunayOK(pos, i, a, b, maxD) {
const Ax = pos[i*3], Ay = pos[i*3+1], Az = pos[i*3+2];
const ux = pos[a*3]-Ax, uy = pos[a*3+1]-Ay, uz = pos[a*3+2]-Az;
const wx = pos[b*3]-Ax, wy = pos[b*3+1]-Ay, wz = pos[b*3+2]-Az;
const cx = uy*wz-uz*wy, cy = uz*wx-ux*wz, cz = ux*wy-uy*wx;
const c2 = cx*cx+cy*cy+cz*cz;
if (c2 < 1e-24) return false;
const uu = ux*ux+uy*uy+uz*uz, ww = wx*wx+wy*wy+wz*wz;
const tx = uu*wx-ww*ux, ty = uu*wy-ww*uy, tz = uu*wz-ww*uz; // uu*w - ww*u
const k = 1 / (2*c2);
const ox = (ty*cz-tz*cy)*k, oy = (tz*cx-tx*cz)*k, oz = (tx*cy-ty*cx)*k; // circumcenter offset
const R2 = ox*ox+oy*oy+oz*oz;
if (R2 > maxD*maxD) return false;
const cl = Math.sqrt(c2), ncx = cx/cl, ncy = cy/cl, ncz = cz/cl;
const slab = 0.5*Math.sqrt(R2), R2s = R2*0.96;
for (let s = 0; s < 3; s++) {
const v = s === 0 ? i : s === 1 ? a : b;
const lb = v*LM, ln = LN[v];
for (let x = 0; x < ln; x++) {
const m = Ls[lb+x];
if (m === i || m === a || m === b) continue;
const dx = pos[m*3]-Ax, dy = pos[m*3+1]-Ay, dz = pos[m*3+2]-Az;
const h = dx*ncx+dy*ncy+dz*ncz;
if (h > slab || h < -slab) continue;
const qx = dx-h*ncx-ox, qy = dy-h*ncy-oy, qz = dz-h*ncz-oz;
if (qx*qx+qy*qy+qz*qz < R2s) return false;
}
}
return true;
}
// pos/anr: Float32Array (xyz), n: Anzahl. Schreibt triIdx + vNrm, gibt Dreiecksanzahl zurück.
// Persistente Dreiecke: jedes Dreieck (Schlüssel = sortiertes Vertex-Tripel) hat ein Gewicht 0..1,
// das langsam auf 1 wächst solange es gültig ist und auf 0 schrumpft, sobald es ungültig wird.
const SLOTS = MAXTRI;
const sKey = new Float64Array(SLOTS), sA = new Int16Array(SLOTS), sB = new Int16Array(SLOTS), sC = new Int16Array(SLOTS);
const sW = new Float32Array(SLOTS), sSeen = new Uint8Array(SLOTS);
const slotMap = new Map(); let sN = 0;
function resetTriSlots() { slotMap.clear(); sN = 0; }
function triangulate(pos, anr, n, maxD, K, filter, hyst) {
const maxDh = maxD * (hyst || 1), md2 = maxDh*maxDh;
candN.fill(0, 0, n); LN.fill(0, 0, n); adj.fill(0);
for (let i = 0; i < n; i++) {
const ix = pos[i*3], iy = pos[i*3+1], iz = pos[i*3+2];
for (let j = i+1; j < n; j++) {
const dx = pos[j*3]-ix, dy = pos[j*3+1]-iy, dz = pos[j*3+2]-iz;
const d2 = dx*dx+dy*dy+dz*dz;
if (d2 >= md2) continue;
insertCand(i, j, d2, K); insertCand(j, i, d2, K);
}
}
for (let i = 0; i < n; i++) {
for (let x = 0; x < candN[i]; x++) {
const j = cand[i*KMAX+x];
if (adj[i*MAXN+j]) continue;
if (LN[i] >= LM || LN[j] >= LM) continue;
adj[i*MAXN+j] = 1; adj[j*MAXN+i] = 1;
Ls[i*LM + LN[i]++] = j; Ls[j*LM + LN[j]++] = i;
}
}
vNrm.fill(0, 0, n*3);
let tc = 0;
for (let i = 0; i < n && tc < MAXTRI; i++) {
const li = LN[i], bi = i*LM;
for (let x = 0; x < li && tc < MAXTRI; x++) {
const a = Ls[bi+x]; if (a <= i) continue;
for (let y = x+1; y < li; y++) {
const b = Ls[bi+y]; if (b <= i) continue;
if (!adj[a*MAXN+b]) continue;
// bestehende Dreiecke dürfen etwas größer sein als neue (Hysterese gegen Flackern)
const key = (i*MAXN + (a < b ? a : b))*MAXN + (a < b ? b : a);
const lim = slotMap.has(key) ? maxDh : maxD, lim2 = lim*lim;
{
const e1x = pos[a*3]-pos[i*3], e1y = pos[a*3+1]-pos[i*3+1], e1z = pos[a*3+2]-pos[i*3+2];
const e2x = pos[b*3]-pos[i*3], e2y = pos[b*3+1]-pos[i*3+1], e2z = pos[b*3+2]-pos[i*3+2];
const e3x = pos[b*3]-pos[a*3], e3y = pos[b*3+1]-pos[a*3+1], e3z = pos[b*3+2]-pos[a*3+2];
if (e1x*e1x+e1y*e1y+e1z*e1z > lim2 || e2x*e2x+e2y*e2y+e2z*e2z > lim2 || e3x*e3x+e3y*e3y+e3z*e3z > lim2) continue;
}
if (filter && !delaunayOK(pos, i, a, b, lim)) continue;
const ax = pos[a*3]-pos[i*3], ay = pos[a*3+1]-pos[i*3+1], az = pos[a*3+2]-pos[i*3+2];
const bx = pos[b*3]-pos[i*3], by = pos[b*3+1]-pos[i*3+1], bz = pos[b*3+2]-pos[i*3+2];
let nx = ay*bz-az*by, ny = az*bx-ax*bz, nz = ax*by-ay*bx;
if (nx*nx+ny*ny+nz*nz < 1e-30) continue;
const d = nx*(anr[i*3]+anr[a*3]+anr[b*3]) + ny*(anr[i*3+1]+anr[a*3+1]+anr[b*3+1]) + nz*(anr[i*3+2]+anr[a*3+2]+anr[b*3+2]);
let v1 = a, v2 = b;
if (d < 0) { v1 = b; v2 = a; nx = -nx; ny = -ny; nz = -nz; }
const o = tc*3;
triIdx[o] = i; triIdx[o+1] = v1; triIdx[o+2] = v2; tc++;
vNrm[i*3] += nx; vNrm[i*3+1] += ny; vNrm[i*3+2] += nz;
vNrm[a*3] += nx; vNrm[a*3+1] += ny; vNrm[a*3+2] += nz;
vNrm[b*3] += nx; vNrm[b*3+1] += ny; vNrm[b*3+2] += nz;
if (tc >= MAXTRI) break;
}
}
}
for (let i = 0; i < n; i++) {
const o = i*3; let l = Math.hypot(vNrm[o], vNrm[o+1], vNrm[o+2]);
if (l > 1e-20) { vNrm[o] /= l; vNrm[o+1] /= l; vNrm[o+2] /= l; }
else { vNrm[o] = anr[o]; vNrm[o+1] = anr[o+1]; vNrm[o+2] = anr[o+2]; }
}
return tc;
}
// Normalen: erst über die Nachbarschaft (ganzes Mesh) diffundieren, dann zeitlich glätten.
const vTmp = new Float32Array(MAXN * 3), nInit = new Uint8Array(MAXN);
function smoothNormals(n, passes, keep) {
let src = vNrm, dst = vTmp;
for (let p = 0; p < passes; p++) {
for (let i = 0; i < n; i++) {
const o = i * 3; let x = src[o], y = src[o+1], z = src[o+2];
const lb = i * LM, ln = LN[i];
for (let k = 0; k < ln; k++) { const m = Ls[lb+k] * 3; x += src[m]; y += src[m+1]; z += src[m+2]; }
const l = Math.hypot(x, y, z);
if (l > 1e-12) { dst[o] = x/l; dst[o+1] = y/l; dst[o+2] = z/l; } else { dst[o] = src[o]; dst[o+1] = src[o+1]; dst[o+2] = src[o+2]; }
}
const t = src; src = dst; dst = t;
}
for (let i = n; i < MAXN; i++) nInit[i] = 0;
for (let i = 0; i < n; i++) {
const o = i * 3;
let x = src[o], y = src[o+1], z = src[o+2];
if (nInit[i]) { x = vNrmS[o]*keep + x*(1-keep); y = vNrmS[o+1]*keep + y*(1-keep); z = vNrmS[o+2]*keep + z*(1-keep); }
const l = Math.hypot(x, y, z);
if (l > 1e-12) { vNrmS[o] = x/l; vNrmS[o+1] = y/l; vNrmS[o+2] = z/l; } else { vNrmS[o] = src[o]; vNrmS[o+1] = src[o+1]; vNrmS[o+2] = src[o+2]; }
nInit[i] = 1;
}
}
// Gewichte fortschreiben und die wachsenden/schrumpfenden Dreiecke in den Render-Buffer schreiben.
// Ein Dreieck bleibt mit seiner kürzesten Kante am Mesh verankert; nur der gegenüberliegende Eckpunkt
// gleitet von der Kantenmitte an seine Position. So bleibt das Mesh geschlossen (kein Skalieren um den Schwerpunkt).
const sG = new Uint8Array(SLOTS); // wachsender Eckpunkt (0..2)
const _tmp = new Float64Array(9), _uu = new Float64Array(3);
function growVertex(pos, a, b, c) {
const d2 = (i, j) => { const dx = pos[i*3]-pos[j*3], dy = pos[i*3+1]-pos[j*3+1], dz = pos[i*3+2]-pos[j*3+2]; return dx*dx+dy*dy+dz*dz; };
const ab = d2(a, b), bc = d2(b, c), ca = d2(c, a);
if (ab <= bc && ab <= ca) return 2; // kürzeste Kante ab → c wächst
if (bc <= ca) return 0; // kürzeste Kante bc → a wächst
return 1; // kürzeste Kante ca → b wächst
}
function blendTriangles(tc, n, dt, dur, pos) {
sSeen.fill(0, 0, sN);
for (let t = 0; t < tc; t++) {
const a = triIdx[t*3], b = triIdx[t*3+1], c = triIdx[t*3+2];
let lo = a, mid = b, hi = c, tmp;
if (lo > mid) { tmp = lo; lo = mid; mid = tmp; }
if (mid > hi) { tmp = mid; mid = hi; hi = tmp; }
if (lo > mid) { tmp = lo; lo = mid; mid = tmp; }
const key = (lo*MAXN + mid)*MAXN + hi;
let idx = slotMap.get(key);
if (idx === undefined) {
if (sN >= SLOTS) continue;
idx = sN++; slotMap.set(key, idx); sKey[idx] = key; sW[idx] = dur > 0 ? 0 : 1;
sA[idx] = a; sB[idx] = b; sC[idx] = c;
sG[idx] = dur > 0 ? growVertex(pos, a, b, c) : 0;
} else { sA[idx] = a; sB[idx] = b; sC[idx] = c; }
sSeen[idx] = 1;
}
const step = dur > 0 ? dt / dur : 1, shrink = dur > 0 ? step * 0.8 : 1; // Schrumpfen etwas langsamer als Wachsen → Mesh bleibt geschlossen
let out = 0;
for (let idx = 0; idx < sN;) {
let w = sW[idx];
w = sSeen[idx] ? Math.min(1, w + step) : w - shrink;
if (w <= 0 || sA[idx] >= n || sB[idx] >= n || sC[idx] >= n) {
slotMap.delete(sKey[idx]);
const last = --sN;
if (idx !== last) {
sKey[idx] = sKey[last]; sA[idx] = sA[last]; sB[idx] = sB[last]; sC[idx] = sC[last];
sW[idx] = sW[last]; sSeen[idx] = sSeen[last]; sG[idx] = sG[last];
slotMap.set(sKey[idx], idx);
}
continue;
}
sW[idx] = w;
_uu[0] = sA[idx]; _uu[1] = sB[idx]; _uu[2] = sC[idx];
for (let k = 0; k < 3; k++) { const v = _uu[k]*3; _tmp[k*3] = pos[v]; _tmp[k*3+1] = pos[v+1]; _tmp[k*3+2] = pos[v+2]; }
const s = w*w*(3-2*w);
if (s < 1) {
const g = sG[idx]*3, o1 = ((sG[idx]+1)%3)*3, o2 = ((sG[idx]+2)%3)*3;
for (let q = 0; q < 3; q++) { const m = (_tmp[o1+q] + _tmp[o2+q]) * 0.5; _tmp[g+q] = m + (_tmp[g+q] - m) * s; }
}
const o = out*9;
for (let q = 0; q < 9; q++) rPos[o+q] = _tmp[q];
// Normalen + UV (mit Naht-Korrektur, da die Dreiecke nicht indiziert sind)
let u0 = dUV[_uu[0]*2], u1 = dUV[_uu[1]*2], u2 = dUV[_uu[2]*2];
const umax = Math.max(u0, u1, u2), umin = Math.min(u0, u1, u2);
if (umax - umin > 0.5) { if (u0 < 0.5) u0 += 1; if (u1 < 0.5) u1 += 1; if (u2 < 0.5) u2 += 1; }
const uo = out*6;
rUv[uo] = u0; rUv[uo+1] = dUV[_uu[0]*2+1]; rUv[uo+2] = u1; rUv[uo+3] = dUV[_uu[1]*2+1]; rUv[uo+4] = u2; rUv[uo+5] = dUV[_uu[2]*2+1];
for (let k = 0; k < 3; k++) { const v = _uu[k]*3; rNrm[o+k*3] = vNrmS[v]; rNrm[o+k*3+1] = vNrmS[v+1]; rNrm[o+k*3+2] = vNrmS[v+2]; }
out++; idx++;
}
return out;
}
// TRI>>
/* ============================== FBX laden ============================== */
const drop = el('drop'), errBox = el('err'), panel = el('panel'), bar = el('bar');
const scrub = el('scrub'), tCur = el('tCur'), tTot = el('tTot');
const fail = m => { errBox.textContent = m; };
['pick', 'pickBig'].forEach(id => el(id).onclick = () => el('file').click());
el('file').onchange = e => { const f = e.target.files[0]; if (f) readFile(f); e.target.value = ''; };
['dragenter', 'dragover'].forEach(ev => window.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('drag'); }));
['dragleave', 'drop'].forEach(ev => window.addEventListener(ev, e => { e.preventDefault(); if (ev === 'drop' || e.relatedTarget === null) drop.classList.remove('drag'); }));
window.addEventListener('drop', e => {
const f = e.dataTransfer.files[0];
if (f && /\.fbx$/i.test(f.name)) readFile(f); else if (f) fail('Das ist keine FBX-Datei.');
});
function readFile(file) {
fail('');
const r = new FileReader();
r.onload = () => { try { loadBuffer(r.result, file.name); } catch (err) { fail('FBX konnte nicht gelesen werden: ' + err.message); console.error(err); } };
r.onerror = () => fail('Datei konnte nicht gelesen werden.');
r.readAsArrayBuffer(file);
}
// liegt eine unknown.fbx neben der HTML, wird sie automatisch geladen
fetch('unknown.fbx').then(r => { if (r.ok) return r.arrayBuffer(); throw 0; }).then(b => loadBuffer(b, 'unknown.fbx')).catch(() => {});
// FBX-Geometrie ist meist nicht indiziert → computeVertexNormals liefert Flat-Normalen.
// Hier werden Normalen an identischen Positionen gemittelt (glatte Oberflächennormalen für Anker & Membran).
function weldedNormals(geo) {
geo.computeVertexNormals();
geo.computeBoundingBox();
const p = geo.attributes.position, nr = geo.attributes.normal, cnt = p.count;
const q = 1e5 / Math.max(geo.boundingBox.getSize(new THREE.Vector3()).length(), 1e-9);
const keys = new Array(cnt), sum = new Map();
for (let i = 0; i < cnt; i++) {
const k = Math.round(p.getX(i)*q) + ',' + Math.round(p.getY(i)*q) + ',' + Math.round(p.getZ(i)*q);
keys[i] = k;
const a = sum.get(k) || [0, 0, 0];
a[0] += nr.getX(i); a[1] += nr.getY(i); a[2] += nr.getZ(i); sum.set(k, a);
}
for (let i = 0; i < cnt; i++) {
const a = sum.get(keys[i]), l = Math.hypot(a[0], a[1], a[2]) || 1;
nr.setXYZ(i, a[0]/l, a[1]/l, a[2]/l);
}
nr.needsUpdate = true;
}
function disposeModel() {
if (!model) return;
scene.remove(model);
model.traverse(o => { if (o.geometry) o.geometry.dispose(); });
model = null; mixer = null;
}
function loadBuffer(buffer, name) {
disposeModel();
const fbx = new FBXLoader().parse(buffer, '');
model = fbx; scene.add(model);
bones = []; meshes = []; skinMeshes = [];
model.traverse(o => {
if (o.isBone) bones.push(o);
if (o.isMesh) {
meshes.push(o); o.frustumCulled = false;
if (!o.geometry.attributes.normal) weldedNormals(o.geometry);
if (o.isSkinnedMesh && o.geometry.attributes.skinWeight) { o.normalizeSkinWeights(); skinMeshes.push(o); }
}
});
if (!bones.length) skinMeshes.forEach(m => m.skeleton.bones.forEach(b => bones.push(b)));
boneIdx = new Map(bones.map((b, i) => [b, i]));
boneW = new Float32Array(bones.length * 3);
computeDrawn();
clip = (model.animations && model.animations[0]) || null;
mixer = new THREE.AnimationMixer(model); duration = 0;
if (clip) { mixer.clipAction(clip).play(); duration = clip.duration; }
t = 0;
applyBodyMode();
applyUp(true);
buildSources();
redistribute();
resetSwarm();
const totalV = srcTotal;
el('bodyInfo').textContent = srcTotal
? `${meshes.length} Mesh${meshes.length > 1 ? 'es' : ''} · ${totalV.toLocaleString('de-DE')} Vertices als Ankerpool`
: 'Kein Mesh im FBX – Drohnen sitzen auf einer Hülle um das Skelett';
el('subtitle').textContent = `${name} · ${bones.length} Bones · ${duration.toFixed(2)} s`;
tTot.textContent = duration.toFixed(2);
drop.hidden = true; panel.hidden = false; bar.hidden = false;
setPlaying(duration > 0);
}
// Offset-/Reference-Bones oberhalb der eigentlichen Körperwurzel abschälen
function computeDrawn() {
const set = new Set(bones);
const kids = b => b.children.filter(c => set.has(c));
const tops = bones.filter(b => !(b.parent && set.has(b.parent)));
drawnPairs = []; hips = null;
for (const top of tops) {
let r = top; while (kids(r).length === 1) r = kids(r)[0];
if (!hips || kids(r).length > kids(hips).length) hips = r;
const st = [r];
while (st.length) { const b = st.pop(); for (const c of kids(b)) { drawnPairs.push([b, c]); st.push(c); } }
}
if (!hips) hips = model;
const arr = new Float32Array(drawnPairs.length * 6);
skelGeo.setAttribute('position', new THREE.BufferAttribute(arr, 3));
}
/* ============================== Pose / Boden / Forward ============================== */
function poseAt(tt) {
const tc = duration > 0 ? Math.min(Math.max(tt, 0), duration - 1e-4) : 0;
mixer.setTime(tc);
model.updateMatrixWorld(true);
for (const m of skinMeshes) m.skeleton.update();
for (let i = 0; i < bones.length; i++) {
const e = bones[i].matrixWorld.elements;
boneW[i*3] = e[12]; boneW[i*3+1] = e[13]; boneW[i*3+2] = e[14];
}
}
function applyUp(first) {
model.rotation.set(el('zup').checked ? -Math.PI / 2 : 0, 0, 0);
model.position.set(0, 0, 0);
let minY = Infinity, maxY = -Infinity;
if (bones.length) {
for (let k = 0; k < 24; k++) {
poseAt(duration * k / 24);
for (let i = 0; i < bones.length; i++) minY = Math.min(minY, boneW[i*3+1]);
}
model.position.y = -minY;
poseAt(0);
let lo = Infinity;
for (let i = 0; i < bones.length; i++) { lo = Math.min(lo, boneW[i*3+1]); maxY = Math.max(maxY, boneW[i*3+1]); }
H = Math.max((maxY - lo) * 1.1, 1e-3);
} else {
poseAt(0);
const bb = new THREE.Box3().setFromObject(model);
model.position.y = -bb.min.y; poseAt(0);
H = Math.max(bb.max.y - bb.min.y, 1e-3);
}
detectForward();
rebuildGrid();
camera.near = H * 0.01; camera.far = H * 300; camera.updateProjectionMatrix();
pointsMat.size = P.dotSize * H;
updateLightRig(true);
if (first || true) {
controls.target.set(0, H * 0.5, 0);
camera.position.set(H * 0.9, H * 0.75, H * 2.3);
}
controls.update();
}
const _q = new THREE.Quaternion(), _f = new THREE.Vector3();
const _sideW = new THREE.Vector3(1, 0, 0), _fwdW = new THREE.Vector3(0, 0, 1);
let sLocal = new THREE.Vector3(1, 0, 0);
function findLegs() {
const names = bones.map(b => b.name.toLowerCase());
const leg = k => names.findIndex(n => n.includes(k) && /(upleg|upperleg|thigh)/.test(n));
return [leg('left'), leg('right')];
}
function detectForward() {
const [li, ri] = findLegs();
hips.getWorldQuaternion(_q);
fLocal.set(0, 0, 1); sLocal.set(1, 0, 0); // Fallback: lokale Z (vorne) / X (links) der Hüfte
if (li >= 0 && ri >= 0) {
const d = new THREE.Vector3(boneW[li*3] - boneW[ri*3], 0, boneW[li*3+2] - boneW[ri*3+2]);
if (d.lengthSq() > 1e-12) {
d.normalize();
const qi = _q.clone().invert();
sLocal.copy(d).applyQuaternion(qi); // links
fLocal.copy(d).cross(UP).applyQuaternion(qi); // links × oben = vorne
}
}
}
// Forward- und Seitenachse in Weltkoordinaten für den aktuellen Frame
function refreshSide() {
hips.getWorldQuaternion(_q);
_sideW.copy(sLocal).applyQuaternion(_q);
_fwdW.copy(fLocal).applyQuaternion(_q);
if (el('flipF').checked) _fwdW.multiplyScalar(-1);
}
/* ============================== Körperdarstellung ============================== */
const wireMat = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true, transparent: true, opacity: 0.12, depthWrite: false });
const solidMat = new THREE.MeshStandardMaterial({ color: 0xf0f0f0, roughness: 0.85, metalness: 0, side: THREE.DoubleSide, transparent: true, opacity: 0.6 });
function applyBodyMode() {
const mode = el('bodyMode').value;
for (const m of meshes) {
m.visible = mode !== 'off';
m.material = mode === 'solid' ? solidMat : wireMat;
m.castShadow = mode === 'solid';
}
}
el('bodyMode').onchange = applyBodyMode;
el('zup').onchange = () => { if (model) applyUp(false); };
el('skel').onchange = () => { skelLines.visible = el('skel').checked; };
el('anchorMode').onchange = el('distMode').onchange = () => { if (model) redistribute(); };
el('meshWire').onchange = () => { swarmMats.forEach(m => m.wireframe = el('meshWire').checked); };
el('meshColor').oninput = () => { swarmMats.forEach(m => m.color.set(el('meshColor').value)); };
let nmTex = null;
function applyMaterial() {
const useNm = !!nmTex && el('nmOn').checked, sgn = el('nmFlip').checked ? -1 : 1;
for (const m of swarmMats) {
if (m.isMeshStandardMaterial) { m.roughness = 1 - P.matSmooth; m.metalness = P.matMetal; }
m.normalMap = useNm ? nmTex : null;
if (m.normalScale) m.normalScale.set(P.nmStr, P.nmStr * sgn);
if (m._nm !== useNm) { m._nm = useNm; m.needsUpdate = true; }
}
if (nmTex) nmTex.repeat.set(P.tileU, P.tileV);
}
function loadNormalMap(url, label, silent) {
new THREE.TextureLoader().load(url, tex => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.colorSpace = THREE.NoColorSpace;
tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
nmTex = tex; el('nmOn').disabled = false; el('nmOn').checked = true;
el('nmInfo').textContent = label + ' aktiv'; applyMaterial();
}, undefined, () => {
el('nmInfo').textContent = silent ? 'Keine normal_map.jpg neben der HTML gefunden' : 'Bild konnte nicht geladen werden';
});
}
el('nmPick').onclick = () => el('nmFile').click();
el('nmFile').onchange = e => { const f = e.target.files[0]; if (f) loadNormalMap(URL.createObjectURL(f), f.name, false); e.target.value = ''; };
el('nmOn').onchange = el('nmFlip').onchange = applyMaterial;
loadNormalMap('normal_map.jpg', 'normal_map.jpg', true);
applyMaterial();
function applyShade() { const v = el('shadeMode').value; swarmMesh.material = v === 'flat' ? flatMat : v === 'gloss' ? glossMat : swarmMat; }
el('shadeMode').onchange = applyShade;
/* ============================== Anker ============================== */
// Ankertypen: 1 = skinned Mesh-Vertex 2 = starres Mesh-Vertex 3 = Hülle um Knochensegment
// 4 = Notfall (Punkt) 6 = Bones + Mesh-Richtung (Mittelweg)
const symC = new THREE.Vector3(), symS = new THREE.Vector3(1, 0, 0), symUp = new THREE.Vector3(0, 1, 0);
let bindAll = new Float32Array(0), mirrorBone = [], anchorMode = 'hybrid';
const _v1 = new THREE.Vector3(), _dv2 = new THREE.Vector3();
function swapLR(name) {
for (const [a, b] of [['Left', 'Right'], ['left', 'right'], ['LEFT', 'RIGHT']]) {
if (name.includes(a)) return name.replace(a, b);
if (name.includes(b)) return name.replace(b, a);
}
let m = name.replace(/([._:\-])L(?=[._:\-]|$)/, '$1R'); if (m !== name) return m;
m = name.replace(/([._:\-])R(?=[._:\-]|$)/, '$1L'); return m;
}
function buildMirrorBones() {
const byName = new Map(bones.map((b, i) => [b.name, i]));
mirrorBone = bones.map((b, i) => { const j = byName.get(swapLR(b.name)); return j === undefined ? i : j; });
}
function buildSources() {
srcs = []; srcTotal = 0;
for (const m of meshes) {
const count = m.geometry.attributes.position.count;
const S = { mesh: m, geom: m.geometry, count, start: srcTotal, cum: srcTotal + count, skinned: !!(m.isSkinnedMesh && m.geometry.attributes.skinIndex) };
if (S.skinned) {
S.bindWorld = m.skeleton.boneInverses.map(inv => inv.clone().invert());
S.bmap = new Map(m.skeleton.bones.map((b, i) => [b, i]));
}
srcTotal += count; srcs.push(S);
}
// Bindpose-Positionen aller Vertices (für Spiegelung, UV und die Bone-Hülle)
bindAll = new Float32Array(srcTotal * 3);
const v = new THREE.Vector3();
for (const S of srcs) {
const mat = S.skinned ? S.mesh.bindMatrix : S.mesh.matrixWorld, pa = S.geom.attributes.position;
for (let i = 0; i < S.count; i++) {
v.fromBufferAttribute(pa, i).applyMatrix4(mat);
const o = (S.start + i) * 3; bindAll[o] = v.x; bindAll[o+1] = v.y; bindAll[o+2] = v.z;
}
}
// Skelett-Hülle: Segmente nach Länge gewichtet
segPairs = []; segCum = []; segTotal = 0;
for (const [a, b] of drawnPairs) {
const ia = boneIdx.get(a), ib = boneIdx.get(b);
const l = Math.hypot(boneW[ib*3]-boneW[ia*3], boneW[ib*3+1]-boneW[ia*3+1], boneW[ib*3+2]-boneW[ia*3+2]);
segTotal += l; segPairs.push([ia, ib]); segCum.push(segTotal);
}
buildMirrorBones();
}
// Symmetrieebene (links/rechts) und vertikale Achse – im Bind-Raum bei Mesh-Ankern, sonst im Weltraum
function computeSymmetryFrame(useBind) {
const [li, ri] = findLegs();
const S = useBind ? srcs.find(x => x.skinned) : null;
symUp.copy(UP); if (S) symUp.applyQuaternion(model.quaternion.clone().invert());
let Lp = null, Rp = null;
if (li >= 0 && ri >= 0) {
if (S) {
const a = S.bmap.get(bones[li]), b = S.bmap.get(bones[ri]);
if (a !== undefined && b !== undefined) {
Lp = new THREE.Vector3().setFromMatrixPosition(S.bindWorld[a]); Rp = new THREE.Vector3().setFromMatrixPosition(S.bindWorld[b]);
}
} else {
Lp = new THREE.Vector3(boneW[li*3], boneW[li*3+1], boneW[li*3+2]); Rp = new THREE.Vector3(boneW[ri*3], boneW[ri*3+1], boneW[ri*3+2]);
}
}
const side = new THREE.Vector3();
if (Lp && Rp) { side.subVectors(Lp, Rp); side.addScaledVector(symUp, -side.dot(symUp)); symC.copy(Lp).add(Rp).multiplyScalar(0.5); }
if (!Lp || side.lengthSq() < 1e-12) {
side.set(1, 0, 0).addScaledVector(symUp, -symUp.x); if (side.lengthSq() < 1e-12) side.set(0, 0, 1);
symC.set(0, 0, 0);
if (S && srcTotal) { for (let i = 0; i < srcTotal; i++) symC.x += bindAll[i*3], symC.y += bindAll[i*3+1], symC.z += bindAll[i*3+2]; symC.multiplyScalar(1 / srcTotal); }
else if (hips) symC.set(boneW[boneIdx.get(hips) * 3] || 0, boneW[boneIdx.get(hips) * 3 + 1] || 0, boneW[boneIdx.get(hips) * 3 + 2] || 0);
}
symS.copy(side).normalize();
}
// Mesh-Vertex als Anker; im Mittelweg-Modus wird daraus ein Bone-Anker mit Radius und Richtung vom Mesh
function vertexAnchor(gi) {
let s = 0; while (s < srcs.length - 1 && gi >= srcs[s].cum) s++;
const S = srcs[s], idx = gi - S.start;
const pa = S.geom.attributes.position, na = S.geom.attributes.normal;
let a = {
type: S.skinned ? 1 : 2, mesh: S.mesh,
p: new THREE.Vector3().fromBufferAttribute(pa, idx),
n: new THREE.Vector3().fromBufferAttribute(na, idx).normalize(),
si: [0, 0, 0, 0], w: [0, 0, 0, 0]
};
if (S.skinned) {
const si = S.geom.attributes.skinIndex, sw = S.geom.attributes.skinWeight;
a.si = [si.getX(idx), si.getY(idx), si.getZ(idx), si.getW(idx)];
a.w = [sw.getX(idx), sw.getY(idx), sw.getZ(idx), sw.getW(idx)];
}
const rest = new THREE.Vector3(bindAll[gi*3], bindAll[gi*3+1], bindAll[gi*3+2]);
if (S.skinned && anchorMode === 'hybrid') { const h = boneMeshAnchor(S, a, rest); if (h) a = h; }
a.rest = rest; a.gi = gi;
return a;
}
function boneMeshAnchor(S, base, Vb) {
let best = 0; for (let k = 1; k < 4; k++) if (base.w[k] > base.w[best]) best = k;
if (!(base.w[best] > 0)) return null;
const B = S.mesh.skeleton.bones[base.si[best]];
if (!B || !boneIdx.has(B)) return null;
const set = new Set(bones);
const bp = b => { const i = S.bmap.get(b); return i === undefined ? null : new THREE.Vector3().setFromMatrixPosition(S.bindWorld[i]); };
const Nb = base.n.clone().transformDirection(S.mesh.bindMatrix);
let seg = null, bestD = Infinity;
const consider = (b0, b1) => {
const p0 = bp(b0), p1 = bp(b1); if (!p0 || !p1) return;
const d = p1.clone().sub(p0), L2 = d.lengthSq(); if (L2 < 1e-12) return;
const tt = THREE.MathUtils.clamp(Vb.clone().sub(p0).dot(d) / L2, 0, 1);
const q = p0.clone().addScaledVector(d, tt), dist = Vb.distanceTo(q);
if (dist < bestD) { bestD = dist; seg = { b0, b1, t: tt, q, d: d.normalize() }; }
};
for (const c of B.children) if (set.has(c)) consider(B, c);
if (B.parent && set.has(B.parent)) consider(B.parent, B);
if (!seg) return null;
const rad = Vb.clone().sub(seg.q), r = rad.length();
let dir = Nb.clone().addScaledVector(seg.d, -Nb.dot(seg.d)); // Mesh-Normale senkrecht zur Knochenachse
if (dir.lengthSq() < 1e-6) dir = r > 1e-9 ? rad.clone() : Nb.clone();
dir.normalize();
if (r > 1e-9 && dir.dot(rad) / r < 0.2) dir.copy(rad).normalize(); // Mesh-Normale widerspricht der Radialrichtung
const ldir = dir.clone().transformDirection(S.mesh.skeleton.boneInverses[base.si[best]]); // → lokal zum Bone
return { type: 6, ia: boneIdx.get(seg.b0), ic: boneIdx.get(seg.b1), t: seg.t, radius: Math.min(r, 0.2 * H), ldir, rb: B };
}
function capsuleAnchor() {
const r = Math.random() * segTotal;
let s = 0; while (s < segPairs.length - 1 && r >= segCum[s]) s++;
return { type: 3, ia: segPairs[s][0], ib: segPairs[s][1], t: Math.random(), ang: Math.random() * Math.PI * 2 };
}
function randomAnchor() {
if (srcTotal > 0 && anchorMode !== 'bones') return vertexAnchor(Math.floor(Math.random() * srcTotal));
if (segPairs.length) return capsuleAnchor();
return { type: 4 };
}
// Spiegelpartner an der Körper-Mittelebene (nächster Vertex zur gespiegelten Bindpose-Position)
function mirrorOf(a) {
if (a.rest && srcTotal > 0) {
const m = _v1.copy(a.rest), d = m.clone().sub(symC).dot(symS);
m.addScaledVector(symS, -2 * d);
let bi = 0, bd = Infinity;
for (let i = 0; i < srcTotal; i++) {
const dx = bindAll[i*3]-m.x, dy = bindAll[i*3+1]-m.y, dz = bindAll[i*3+2]-m.z, d2 = dx*dx + dy*dy + dz*dz;
if (d2 < bd) { bd = d2; bi = i; }
}
return vertexAnchor(bi);
}
if (a.type === 3) return { type: 3, ia: mirrorBone[a.ia], ib: mirrorBone[a.ib], t: a.t, ang: Math.PI - a.ang };
return a.type === 4 ? { type: 4 } : randomAnchor();
}
function redistribute() {
anchorMode = el('anchorMode').value;
const useBind = srcTotal > 0 && anchorMode !== 'bones';
computeSymmetryFrame(useBind);
refreshSide();
const mirror = el('distMode').value === 'mirror';
anchors = [];
for (let i = 0; i < MAXN; i++) anchors.push(mirror && (i & 1) ? mirrorOf(anchors[i - 1]) : randomAnchor());
computeUVs();
}
// Körperfeste UV: zylindrisch um die vertikale Körperachse (Winkel, Höhe)
function computeUVs() {
const h2 = new THREE.Vector3().crossVectors(symUp, symS);
for (let i = 0; i < MAXN; i++) {
const a = anchors[i];
if (!a.rest) { computeAnchorObj(a); a.rest = _ap.clone(); }
const d = _v1.subVectors(a.rest, symC);
dUV[i*2] = Math.atan2(d.dot(symS), d.dot(h2)) / (2 * Math.PI) + 0.5;
dUV[i*2+1] = a.rest.dot(symUp) / H;
}
}
const _sk = new THREE.Matrix4(), _mm = new THREE.Matrix4();
const _ap = new THREE.Vector3(), _an = new THREE.Vector3();
function computeAnchor(i) { computeAnchorObj(anchors[i]); }
function computeAnchorObj(a) {
if (a.type === 1) { // skinned Vertex
const bm = a.mesh.skeleton.boneMatrices, e = _sk.elements;
e.fill(0);
for (let k = 0; k < 4; k++) {
const w = a.w[k]; if (!w) continue;
const o = a.si[k] * 16;
for (let j = 0; j < 16; j++) e[j] += bm[o+j] * w;
}
_mm.copy(a.mesh.bindMatrixInverse).multiply(_sk).multiply(a.mesh.bindMatrix);
_ap.copy(a.p).applyMatrix4(_mm).applyMatrix4(a.mesh.matrixWorld);
_an.copy(a.n).transformDirection(_mm).transformDirection(a.mesh.matrixWorld);
} else if (a.type === 2) { // starres Mesh
_ap.copy(a.p).applyMatrix4(a.mesh.matrixWorld);
_an.copy(a.n).transformDirection(a.mesh.matrixWorld);
} else if (a.type === 6) { // Bone-Achse als Platzierung, Mesh liefert Richtung + Radius
const A = a.ia * 3, C = a.ic * 3;
_dv2.copy(a.ldir).transformDirection(a.rb.matrixWorld);
_ap.set(boneW[A] + (boneW[C] - boneW[A]) * a.t + _dv2.x * a.radius,
boneW[A+1] + (boneW[C+1] - boneW[A+1]) * a.t + _dv2.y * a.radius,
boneW[A+2] + (boneW[C+2] - boneW[A+2]) * a.t + _dv2.z * a.radius);
_an.copy(_dv2);
} else if (a.type === 3) { // Hülle um Knochensegment, Winkel relativ zur Körper-Seitenachse
const A = a.ia * 3, B = a.ib * 3;
const dx = boneW[B]-boneW[A], dy = boneW[B+1]-boneW[A+1], dz = boneW[B+2]-boneW[A+2];
const l = Math.hypot(dx, dy, dz);
let ux = 0, uy = 1, uz = 0;
if (l > 1e-9) { ux = dx/l; uy = dy/l; uz = dz/l; }
let sx = _sideW.x, sy = _sideW.y, sz = _sideW.z;
const dd = ux*sx + uy*sy + uz*sz; sx -= ux*dd; sy -= uy*dd; sz -= uz*dd;
let sl = Math.hypot(sx, sy, sz);
if (sl < 1e-6) { sx = 0; sy = 1; sz = 0; const d2 = uy; sx -= ux*d2; sy -= uy*d2; sz -= uz*d2; sl = Math.hypot(sx, sy, sz) || 1; }
sx /= sl; sy /= sl; sz /= sl;
const vx = uy*sz-uz*sy, vy = uz*sx-ux*sz, vz = ux*sy-uy*sx;
const c = Math.cos(a.ang), sn = Math.sin(a.ang);
_an.set(sx*c+vx*sn, sy*c+vy*sn, sz*c+vz*sn);
const R = 0.045 * H;
_ap.set(boneW[A]+dx*a.t + _an.x*R, boneW[A+1]+dy*a.t + _an.y*R, boneW[A+2]+dz*a.t + _an.z*R);
} else { _ap.set(0, H * 0.5, 0); _an.set(0, 1, 0); }
}
/* ============================== 4D-Perlin-Noise (x, y, z, Zeit) ============================== */
const perm = new Uint8Array(512);
(function () {
const p = new Uint8Array(256); for (let i = 0; i < 256; i++) p[i] = i;
let seed = 1337; const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
for (let i = 255; i > 0; i--) { const j = Math.floor(rnd() * (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 G4 = new Float32Array(32 * 4);
(function () { // 32 Gradienten: genau eine Null, sonst ±1
let n = 0;
for (let z = 0; z < 4; z++) for (let sg = 0; sg < 8; sg++) {
let b = 0; for (let k = 0; k < 4; k++) G4[n*4+k] = k === z ? 0 : ((sg >> b++) & 1 ? -1 : 1);
n++;
}
})();
const fade = t => t*t*t*(t*(t*6-15)+10);
const L = (a, b, t) => a + (b - a) * t;
function gd(X, Y, Z, W, fx, fy, fz, fw) {
const h = (perm[perm[perm[perm[X]+Y]+Z]+W] & 31) * 4;
return G4[h]*fx + G4[h+1]*fy + G4[h+2]*fz + G4[h+3]*fw;
}
function noise4(x, y, z, w) {
const x0 = Math.floor(x), y0 = Math.floor(y), z0 = Math.floor(z), w0 = Math.floor(w);
const fx = x - x0, fy = y - y0, fz = z - z0, fw = w - w0;
const X = x0 & 255, Y = y0 & 255, Z = z0 & 255, W = w0 & 255;
const X1 = (X+1) & 255, Y1 = (Y+1) & 255, Z1 = (Z+1) & 255, W1 = (W+1) & 255;
const u = fade(fx), v = fade(fy), s = fade(fz), r = fade(fw);
const gx = fx-1, gy = fy-1, gz = fz-1, gw = fw-1;
return L(
L(L(L(gd(X,Y,Z,W,fx,fy,fz,fw), gd(X1,Y,Z,W,gx,fy,fz,fw), u), L(gd(X,Y1,Z,W,fx,gy,fz,fw), gd(X1,Y1,Z,W,gx,gy,fz,fw), u), v),
L(L(gd(X,Y,Z1,W,fx,fy,gz,fw), gd(X1,Y,Z1,W,gx,fy,gz,fw), u), L(gd(X,Y1,Z1,W,fx,gy,gz,fw), gd(X1,Y1,Z1,W,gx,gy,gz,fw), u), v), s),
L(L(L(gd(X,Y,Z,W1,fx,fy,fz,gw), gd(X1,Y,Z,W1,gx,fy,fz,gw), u), L(gd(X,Y1,Z,W1,fx,gy,fz,gw), gd(X1,Y1,Z,W1,gx,gy,fz,gw), u), v),
L(L(gd(X,Y,Z1,W1,fx,fy,gz,gw), gd(X1,Y,Z1,W1,gx,fy,gz,gw), u), L(gd(X,Y1,Z1,W1,fx,gy,gz,gw), gd(X1,Y1,Z1,W1,gx,gy,gz,gw), u), v), s),
r);
}
// Flow-Vektor am Raumpunkt: entweder drei versetzte Noise-Kanäle oder deren Curl (divergenzfrei, wirbelnd)
const flowOut = new Float32Array(3);
const nA2 = (a, b, c, t) => noise4(a + 17.3, b + 31.7, c + 5.1, t);
const nA3 = (a, b, c, t) => noise4(a - 9.2, b + 2.4, c + 43.9, t);
function flowAt(x, y, z, t, curl) {
if (!curl) {
flowOut[0] = noise4(x, y, z, t);
flowOut[1] = noise4(x + 17.3, y + 31.7, z + 5.1, t);
flowOut[2] = noise4(x - 9.2, y + 2.4, z + 43.9, t);
return;
}
const e = 0.06, k = 1 / (2 * e);
flowOut[0] = (nA3(x, y+e, z, t) - nA3(x, y-e, z, t) - nA2(x, y, z+e, t) + nA2(x, y, z-e, t)) * k;
flowOut[1] = (noise4(x, y, z+e, t) - noise4(x, y, z-e, t) - nA3(x+e, y, z, t) + nA3(x-e, y, z, t)) * k;
flowOut[2] = (nA2(x+e, y, z, t) - nA2(x-e, y, z, t) - noise4(x, y+e, z, t) + noise4(x, y-e, z, t)) * k;
}
let flowTime = 0;
/* ============================== Schwarm-Update ============================== */
const _dv = new THREE.Vector3(), _qq = new THREE.Quaternion(), _pp = new THREE.Vector3(), _ss = new THREE.Vector3(), _mx = new THREE.Matrix4();
function resetSwarm() {
inited.fill(0);
}
function updateSwarm(dt) {
const t0 = performance.now();
const n = Math.min(MAXN, Math.max(1, P.count | 0));
for (let i = n; i < MAXN; i++) inited[i] = 0;
// Forward-Richtung des Körpers (aus der Hüfte)
let fx = 0, fy = 0, fz = 0;
refreshSide();
if (P.offF !== 0) { fx = _fwdW.x * P.offF * H; fy = _fwdW.y * P.offF * H; fz = _fwdW.z * P.offF * H; }
const membrane = el('membrane').checked, gap = 0.006 * H;
const offN = membrane ? Math.max(P.offN * H, gap) : P.offN * H, inv = 1 / Math.max(dt, 1e-4);
flowTime += dt * P.flowSpeed;
const jumpV2 = (8 * H) * (8 * H);
const roamH = P.roam * H;
// 1) Ankerziele
for (let i = 0; i < n; i++) {
computeAnchor(i);
const o = i * 3;
nrm[o] = _an.x; nrm[o+1] = _an.y; nrm[o+2] = _an.z;
ancP[o] = _ap.x; ancP[o+1] = _ap.y; ancP[o+2] = _ap.z;
const tx = _ap.x + _an.x * offN + fx, ty = _ap.y + _an.y * offN + fy, tz = _ap.z + _an.z * offN + fz;
if (!inited[i]) {
const rr = Math.max(roamH, 0.02 * H) * 0.5;
pos[o] = tx + (Math.random()-0.5)*rr; pos[o+1] = ty + (Math.random()-0.5)*rr; pos[o+2] = tz + (Math.random()-0.5)*rr;
vel[o] = vel[o+1] = vel[o+2] = 0; tvel[o] = tvel[o+1] = tvel[o+2] = 0;
let wx = Math.random()-0.5, wy = Math.random()-0.5, wz = Math.random()-0.5; const wl = Math.hypot(wx, wy, wz) || 1;
wand[o] = wx/wl; wand[o+1] = wy/wl; wand[o+2] = wz/wl;
dirA[o] = wand[o]; dirA[o+1] = wand[o+1]; dirA[o+2] = wand[o+2];
inited[i] = 1;
} else {
let vx = (tx - tgt[o]) * inv, vy = (ty - tgt[o+1]) * inv, vz = (tz - tgt[o+2]) * inv;
if (vx*vx + vy*vy + vz*vz > jumpV2) { vx = vy = vz = 0; }
tvel[o] += (vx - tvel[o]) * 0.35; tvel[o+1] += (vy - tvel[o+1]) * 0.35; tvel[o+2] += (vz - tvel[o+2]) * 0.35;
const tl = Math.hypot(tvel[o], tvel[o+1], tvel[o+2]), tlMax = 3 * H; // Ausreißer der Ankergeschwindigkeit kappen
if (tl > tlMax) { const sc = tlMax / tl; tvel[o] *= sc; tvel[o+1] *= sc; tvel[o+2] *= sc; }
}
tgt[o] = tx; tgt[o+1] = ty; tgt[o+2] = tz;
}
// 2) Kräfte (Nachbarschaft: Separation / Alignment / Cohesion)
const Rr = Math.max(P.percR, P.sepR) * H, R2 = Rr * Rr, S = P.sepR * H, S2 = S * S;
const useNb = P.sep > 0 || P.align > 0 || P.coh > 0;
const sepW = P.sep * H, alignW = P.align, cohW = P.coh, pullW = P.pull, damp = P.damp;
const noiseW = P.noise * H, swirlW = P.swirl * H;
const flowW = P.flowStr * H, flowK = P.flowScale / H, curl = el('curl').checked;
for (let i = 0; i < n; i++) {
const o = i * 3;
const px = pos[o], py = pos[o+1], pz = pos[o+2];
let ax = 0, ay = 0, az = 0;
if (useNb) {
let cnt = 0, avx = 0, avy = 0, avz = 0, cx = 0, cy = 0, cz = 0, sx = 0, sy = 0, sz = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const q = j * 3;
const dx = pos[q]-px, dy = pos[q+1]-py, dz = pos[q+2]-pz;
const d2 = dx*dx + dy*dy + dz*dz;
if (d2 >= R2) continue;
cnt++; avx += vel[q]; avy += vel[q+1]; avz += vel[q+2]; cx += pos[q]; cy += pos[q+1]; cz += pos[q+2];
if (d2 < S2 && d2 > 1e-14) { const d = Math.sqrt(d2), w = (1 - d / S) / d; sx -= dx*w; sy -= dy*w; sz -= dz*w; }
}
ax += sx * sepW; ay += sy * sepW; az += sz * sepW;
if (cnt > 0) {
ax += (avx/cnt - vel[o]) * alignW + (cx/cnt - px) * cohW;
ay += (avy/cnt - vel[o+1]) * alignW + (cy/cnt - py) * cohW;
az += (avz/cnt - vel[o+2]) * alignW + (cz/cnt - pz) * cohW;
}
}
// Bindung an den Anker (Feder außerhalb des Freiraums)
const ex = tgt[o]-px, ey = tgt[o+1]-py, ez = tgt[o+2]-pz;
const dist = Math.hypot(ex, ey, ez);
if (dist > roamH && dist > 1e-9) { const k = (dist - roamH) * pullW / dist; ax += ex*k; ay += ey*k; az += ez*k; }
// Dämpfung relativ zur Ankerbewegung
ax += (tvel[o]-vel[o]) * damp; ay += (tvel[o+1]-vel[o+1]) * damp; az += (tvel[o+2]-vel[o+2]) * damp;
// Zufall (wanderndes Ziel)
let wx = wand[o] + (Math.random()-0.5) * dt * 8, wy = wand[o+1] + (Math.random()-0.5) * dt * 8, wz = wand[o+2] + (Math.random()-0.5) * dt * 8;
const wl = Math.hypot(wx, wy, wz) || 1; wx /= wl; wy /= wl; wz /= wl;
wand[o] = wx; wand[o+1] = wy; wand[o+2] = wz;
ax += wx * noiseW; ay += wy * noiseW; az += wz * noiseW;
// Perlin-Flowfield im Raum (Weltkoordinaten), zeitlich veränderlich
if (flowW > 0) {
flowAt(px * flowK, py * flowK, pz * flowK, flowTime, curl);
ax += flowOut[0] * flowW; ay += flowOut[1] * flowW; az += flowOut[2] * flowW;
}
// Drall um die Vertikale durch den Anker
if (swirlW > 0) {
const rx = px - tgt[o], rz = pz - tgt[o+2], rl = Math.hypot(rx, rz);
if (rl > 1e-9) { ax += (rz / rl) * swirlW; az += (-rx / rl) * swirlW; }
}
acc[o] = ax; acc[o+1] = ay; acc[o+2] = az;
}
// 3) Integration
const vmax = P.vmax * H;
for (let i = 0; i < n; i++) {
const o = i * 3;
let vx = vel[o] + acc[o]*dt, vy = vel[o+1] + acc[o+1]*dt, vz = vel[o+2] + acc[o+2]*dt;
const rx = vx - tvel[o], ry = vy - tvel[o+1], rz = vz - tvel[o+2];
const rl = Math.hypot(rx, ry, rz);
if (rl > vmax) { const s = vmax / rl; vx = tvel[o] + rx*s; vy = tvel[o+1] + ry*s; vz = tvel[o+2] + rz*s; }
if (!isFinite(vx + vy + vz)) { vx = vy = vz = 0; pos[o] = tgt[o]; pos[o+1] = tgt[o+1]; pos[o+2] = tgt[o+2]; }
vel[o] = vx; vel[o+1] = vy; vel[o+2] = vz;
pos[o] += vx*dt; pos[o+1] += vy*dt; pos[o+2] += vz*dt;
}
// 3a) Leine: Drohnen dürfen sich nur begrenzt weit vom Ankerziel entfernen (verhindert das Ausreißen)
{
const leashD = roamH + P.leash * H;
for (let i = 0; i < n; i++) {
const o = i * 3, dx = pos[o]-tgt[o], dy = pos[o+1]-tgt[o+1], dz = pos[o+2]-tgt[o+2], d = Math.hypot(dx, dy, dz);
if (d > leashD) {
const ux = dx/d, uy = dy/d, uz = dz/d;
pos[o] = tgt[o] + ux*leashD; pos[o+1] = tgt[o+1] + uy*leashD; pos[o+2] = tgt[o+2] + uz*leashD;
const rv = (vel[o]-tvel[o])*ux + (vel[o+1]-tvel[o+1])*uy + (vel[o+2]-tvel[o+2])*uz;
if (rv > 0) { vel[o] -= ux*rv; vel[o+1] -= uy*rv; vel[o+2] -= uz*rv; }
}
}
}
// 3b) Membran: Position entlang der Ankernormale auf ein Band außerhalb der Oberfläche begrenzen
if (membrane) {
const th = P.membT * H, dMin = Math.max(gap, offN - th), dMax = offN + th;
for (let i = 0; i < n; i++) {
const o = i * 3, nx = nrm[o], ny = nrm[o+1], nz = nrm[o+2];
const d = (pos[o]-ancP[o])*nx + (pos[o+1]-ancP[o+1])*ny + (pos[o+2]-ancP[o+2])*nz;
let corr = 0;
if (d < dMin) corr = dMin - d; else if (d > dMax) corr = dMax - d;
if (corr !== 0) {
pos[o] += nx*corr; pos[o+1] += ny*corr; pos[o+2] += nz*corr;
const vn = vel[o]*nx + vel[o+1]*ny + vel[o+2]*nz;
if ((corr > 0 && vn < 0) || (corr < 0 && vn > 0)) { vel[o] -= nx*vn; vel[o+1] -= ny*vn; vel[o+2] -= nz*vn; }
}
}
}
// 4) Darstellung: Punkte, Pfeile
const showArrows = el('arrows').checked, L = P.arrowSize * H;
const k = Math.min(1, dt * 12);
for (let i = 0; i < n; i++) {
const o = i * 3;
const vl = Math.hypot(vel[o], vel[o+1], vel[o+2]);
if (vl > 1e-6 * H) {
dirA[o] += (vel[o]/vl - dirA[o]) * k;
dirA[o+1] += (vel[o+1]/vl - dirA[o+1]) * k;
dirA[o+2] += (vel[o+2]/vl - dirA[o+2]) * k;
}
const dl2 = Math.hypot(dirA[o], dirA[o+1], dirA[o+2]) || 1;
dirA[o] /= dl2; dirA[o+1] /= dl2; dirA[o+2] /= dl2;
if (showArrows) {
const dx = dirA[o], dy = dirA[o+1], dz = dirA[o+2];
const so = i * 6;
shaftPos[so] = pos[o]; shaftPos[so+1] = pos[o+1]; shaftPos[so+2] = pos[o+2];
shaftPos[so+3] = pos[o] + dx*L*0.55; shaftPos[so+4] = pos[o+1] + dy*L*0.55; shaftPos[so+5] = pos[o+2] + dz*L*0.55;
_dv.set(dx, dy, dz); _qq.setFromUnitVectors(UP, _dv);
_pp.set(shaftPos[so+3], shaftPos[so+4], shaftPos[so+5]); _ss.set(L, L, L);
_mx.compose(_pp, _qq, _ss); heads.setMatrixAt(i, _mx);
}
}
points.geometry.setDrawRange(0, n);
pointsMat.size = P.dotSize * H;
posAttr.needsUpdate = true;
shafts.visible = heads.visible = showArrows;
if (showArrows) {
shaftGeo.setDrawRange(0, n * 2); shaftAttr.needsUpdate = true;
heads.count = n; heads.instanceMatrix.needsUpdate = true;
}
const tSwarm = performance.now() - t0;
// 5) Mesh aus den Drohnen
let msg = `${n} Drohnen · Schwarm ${tSwarm.toFixed(1)} ms`;
if (el('meshOn').checked && n >= 3) {
const t1 = performance.now();
const smoothTri = el('smoothTri').checked;
const tc = triangulate(pos, nrm, n, P.maxD * H, P.maxK | 0, el('delaunay').checked, smoothTri ? P.hyst : 1);
smoothNormals(n, P.nPass | 0, Math.pow(P.nSmooth, dt * 60));
const vis = blendTriangles(tc, n, dt, smoothTri ? P.morphT : 0, pos);
meshGeo.setDrawRange(0, vis * 3);
rPosAttr.needsUpdate = true; rNrmAttr.needsUpdate = true; rUvAttr.needsUpdate = true;
swarmMesh.visible = true;
swarmMats.forEach(m => { m.opacity = P.meshOp; m.transparent = P.meshOp < 1; });
msg += ` · Mesh ${(performance.now() - t1).toFixed(1)} ms · ${vis.toLocaleString('de-DE')} Dreiecke (${tc.toLocaleString('de-DE')} gültig)`;
} else { swarmMesh.visible = false; resetTriSlots(); }
el('meshInfo').textContent = msg;
}
el('redis').onclick = redistribute;
el('resetSwarm').onclick = resetSwarm;
/* ============================== Transport ============================== */
const ICON_PLAY = 'M8 5v14l11-7z', ICON_PAUSE = 'M6 5h4v14H6zM14 5h4v14h-4z';
function setPlaying(b) { playing = b; el('playIcon').setAttribute('d', b ? ICON_PAUSE : ICON_PLAY); }
el('play').onclick = () => {
if (!playing && !el('loop').checked && duration > 0 && t >= duration - 1e-3) t = 0;
setPlaying(!playing);
};
window.addEventListener('keydown', e => {
if (e.code === 'Space' && model && !/select|input|button/i.test(e.target.tagName)) { e.preventDefault(); el('play').click(); }
});
scrub.addEventListener('pointerdown', () => { scrubbing = true; });
window.addEventListener('pointerup', () => { scrubbing = false; });
scrub.addEventListener('input', () => { t = scrub.value / 1000 * duration; });
/* ============================== Loop ============================== */
const _hp = new THREE.Vector3(), _d = new THREE.Vector3();
function tick() {
requestAnimationFrame(tick);
const raw = clock.getDelta(), dt = Math.min(raw, 1 / 30);
if (model) {
if (playing && duration > 0 && !scrubbing) {
t += raw * P.animSpeed;
if (t >= duration) { if (el('loop').checked) t %= duration; else { t = duration; setPlaying(false); } }
}
poseAt(t);
if (!scrubbing) scrub.value = duration > 0 ? t / duration * 1000 : 0;
tCur.textContent = t.toFixed(2);
if (skelLines.visible) {
const arr = skelGeo.attributes.position.array;
for (let i = 0; i < drawnPairs.length; i++) {
const a = boneIdx.get(drawnPairs[i][0]) * 3, b = boneIdx.get(drawnPairs[i][1]) * 3, o = i * 6;
arr[o] = boneW[a]; arr[o+1] = boneW[a+1]; arr[o+2] = boneW[a+2];
arr[o+3] = boneW[b]; arr[o+4] = boneW[b+1]; arr[o+5] = boneW[b+2];
}
skelGeo.attributes.position.needsUpdate = true;
}
updateSwarm(dt);
updateLightRig(false);
if (el('follow').checked) {
const e = hips.matrixWorld.elements;
_hp.set(e[12], H * 0.5, e[14]);
_d.copy(_hp).sub(controls.target).multiplyScalar(Math.min(1, dt * 4));
controls.target.add(_d); camera.position.add(_d);
}
}
controls.update();
renderer.render(scene, camera);
}
tick();
</script>
</body>
</html>