import { Scene, OrthographicCamera, WebGLRenderer, PlaneGeometry, Mesh, ShaderMaterial, Vector3, Vector2, } from 'three' const vertexShader = ` precision highp float; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` const fragmentShader = ` precision mediump float; uniform float iTime; uniform vec3 iResolution; uniform float animationSpeed; uniform bool enableTop; uniform bool enableMiddle; uniform bool enableBottom; uniform int topLineCount; uniform int middleLineCount; uniform int bottomLineCount; uniform float topLineDistance; uniform float bottomLineDistance; uniform vec3 topWavePosition; uniform vec3 bottomWavePosition; uniform int numPoints; uniform float pointSpacingX; uniform float pointsOffsetX; uniform float pointY[8]; uniform float lineSpread; uniform float fanSpread; uniform float lineSharpness; uniform float waveFrequency; uniform float bezierCurvature; uniform float circleRadiusPx; uniform float circleGlowSize; uniform float circleGlowStrength; uniform vec2 iMouse; uniform bool interactive; uniform float bendRadius; uniform float bendStrength; uniform float bendInfluence; uniform int horizonMode; // 0=off 1=fog 2=split 3=glow uniform float horizonOpacity; // Nebel + Glow: Helligkeit/Dichte uniform float horizonBlend; // Trennung: 0=scharf, 1=weicher Übergang uniform bool parallax; uniform float parallaxStrength; uniform vec2 parallaxOffset; uniform float lineBrightness; uniform vec3 lineGradient[8]; uniform int lineGradientCount; uniform vec3 bgColorCenter; uniform vec3 bgColorEdge; mat2 rotate(float r) { return mat2(cos(r), sin(r), -sin(r), cos(r)); } vec3 getLineColor(float t, vec3 baseColor) { if (lineGradientCount <= 0) { return baseColor; } vec3 gradientColor; if (lineGradientCount == 1) { gradientColor = lineGradient[0]; } else { float clampedT = clamp(t, 0.0, 0.9999); float scaled = clampedT * float(lineGradientCount - 1); int idx = int(floor(scaled)); float f = fract(scaled); int idx2 = min(idx + 1, lineGradientCount - 1); vec3 c1 = lineGradient[idx]; vec3 c2 = lineGradient[idx2]; gradientColor = mix(c1, c2, f); } return gradientColor; } vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) { float d = length(uv - center); // Glow: Größe und Stärke per Uniform steuerbar float glowW = circleGlowSize / iResolution.y * 2.0; float glow = exp(-pow(max(d - r, 0.0) / glowW, 2.0)) * circleGlowStrength; float fog = 0.008 / max(d * d * 3.0 + 0.016, 0.001); // Weißer Kreis: harte Kante, 1px Antialiasing float aa = 1.5 / iResolution.y; float core = 1.0 - smoothstep(r - aa, r + aa, d); // Glow nur außerhalb des weißen Kreises vec3 result = color * (glow + fog) * (1.0 - core); result += vec3(core); return result; } // Nächsten t-Parameter auf quadratischer Bézier (Newton + Coarse-Search) float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) { // Grobe Suche über 8 Samples für guten Startwert float bestT = 0.0; float bestD = 1e9; for (int k = 0; k <= 8; ++k) { float t = float(k) / 8.0; float mt = 1.0 - t; vec2 b = mt*mt*p0 + 2.0*mt*t*pc + t*t*p1; float d = dot(q - b, q - b); if (d < bestD) { bestD = d; bestT = t; } } // Newton-Verfahren: minimiert |B(t)-q|² // f(t) = a·t³ + b·t² + c·t + d, f'(t) = 3a·t² + 2b·t + c vec2 A = pc - p0; vec2 B = p0 - 2.0*pc + p1; vec2 D = p0 - q; float a = 2.0*dot(B,B); float bco = 6.0*dot(A,B); float c = 4.0*dot(A,A) + 2.0*dot(D,B); float dco = 2.0*dot(D,A); // Etwas breiterer Bereich erlaubt leichten Überlauf in benachbarte Segmente float t = clamp(bestT, 0.001, 0.999); for (int k = 0; k < 4; ++k) { float f = a*t*t*t + bco*t*t + c*t + dco; float fp = 3.0*a*t*t + 2.0*bco*t + c; if (abs(fp) > 1e-8) t -= f / fp; t = clamp(t, -0.08, 1.08); } return t; } // Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) { float s = dot(uv - bPos, bNorm); float time = iTime * animationSpeed; float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5; float envelope = sin(t * 3.14159265359); float linePos = (normalizedI - 0.5) * fanSpread * envelope; float amp = lineSpread * 0.3 * envelope; float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp * sin(fi * 0.9 + time * 0.18); float dist = s - linePos - waveDisp; float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t); return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003); } float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) { float time = iTime * animationSpeed; float x_offset = offset; float x_movement = time * 0.1; float amp = sin(offset + time * 0.2) * 0.3; float y = sin(uv.x + x_offset + x_movement) * amp; if (shouldBend) { vec2 d = screenUv - mouseUv; float influence = exp(-dot(d, d) * bendRadius); float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence; y += bendOffset; } float m = uv.y - y; return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01; } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; baseUv.y *= -1.0; if (parallax) { baseUv += parallaxOffset; } vec3 col = vec3(0.0); vec3 b = bgColorCenter; vec2 mouseUv = vec2(0.0); if (interactive) { mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y; mouseUv.y *= -1.0; } if (enableBottom) { for (int i = 0; i < bottomLineCount; ++i) { float fi = float(i); float t = fi / max(float(bottomLineCount - 1), 1.0); vec3 lineCol = getLineColor(t, b); float angle = bottomWavePosition.z * log(length(baseUv) + 1.0); vec2 ruv = baseUv * rotate(angle); col += lineCol * wave( ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y), 1.5 + 0.2 * fi, baseUv, mouseUv, interactive ) * 0.2; } } if (enableMiddle) { const int MAX_PTS = 8; const int MAX_SEGS = 7; float r = circleRadiusPx / iResolution.y * 2.0; float tScale = numPoints > 1 ? 1.0 / float(numPoints - 1) : 1.0; // Segmente: Punkt s → Punkt s+1 for (int s = 0; s < MAX_SEGS; ++s) { if (s >= numPoints - 1) break; float x0 = pointsOffsetX + (float(s) - float(numPoints - 1) * 0.5) * pointSpacingX; float x1 = pointsOffsetX + (float(s + 1) - float(numPoints - 1) * 0.5) * pointSpacingX; vec2 sp = vec2(x0, pointY[s]); vec2 ep = vec2(x1, pointY[s + 1]); // Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt) vec2 seg = ep - sp; float segL = length(seg); vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0); vec2 sPerp = vec2(-segDir.y, segDir.x); vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature; // Gradient float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0); float t_global = (float(s) + t_seg) * tScale; vec3 lineCol = getLineColor(t_global, b); // Bézier einmal pro Segment — geteilt von Nebel + allen Linien float bt = bezierClosestT(baseUv, sp, pc, ep); float bmt = 1.0 - bt; vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep; vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc)); vec2 bNorm = vec2(-bTang.y, bTang.x); // Weicher Nebel entlang der Kurve float bDist = length(baseUv - bPos); float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt); float fogEnv = sin(bt * 3.14159265359); float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001); col += lineCol * segFog; for (int i = 0; i < middleLineCount; ++i) { col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm); } } // Kreise an jedem Punkt for (int p = 0; p < MAX_PTS; ++p) { if (p >= numPoints) break; float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX; float t_pt = numPoints > 1 ? float(p) * tScale : 0.0; vec3 circCol = getLineColor(t_pt, b) * 1.5; col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol); } } if (enableTop) { for (int i = 0; i < topLineCount; ++i) { float fi = float(i); float t = fi / max(float(topLineCount - 1), 1.0); vec3 lineCol = getLineColor(t, b); float angle = topWavePosition.z * log(length(baseUv) + 1.0); vec2 ruv = baseUv * rotate(angle); ruv.x *= -1.0; col += lineCol * wave( ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y), 1.0 + 0.2 * fi, baseUv, mouseUv, interactive ) * 0.1; } } col *= lineBrightness; // Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand) float dist = length(baseUv) / 1.8; vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0)); if (horizonMode == 1) { // Nebel: breites weiches Gaussband in Gradient-Mittelfarbe float band = exp(-baseUv.y * baseUv.y * 5.0); vec3 fogColor = getLineColor(0.5, bg) * 2.0; col += fogColor * band * horizonOpacity; } else if (horizonMode == 2) { // Farbtrennung: vertikaler Split an Y=0 // bgColorCenter → oben (positives UV-Y), bgColorEdge → unten // horizonBlend: 0=harter Schnitt, 1=sehr weicher Übergang float blendW = max(horizonBlend * 0.7, 0.001); float t = smoothstep(-blendW, blendW, baseUv.y); bg = mix(bgColorEdge, bgColorCenter, t); } else if (horizonMode == 3) { // Glow: konzentriertes Leuchten in Gradient-Mittelfarbe float d2 = baseUv.y * baseUv.y; float softGlow = exp(-d2 * 10.0); float coreGlow = exp(-d2 * 70.0) * 0.7; vec3 glowColor = getLineColor(0.5, bg) * 3.0; col += glowColor * (softGlow + coreGlow) * horizonOpacity; } fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0); } void main() { vec4 color = vec4(0.0); mainImage(color, gl_FragCoord.xy); gl_FragColor = color; } ` const MAX_GRADIENT_STOPS = 8 function hexToVec3(hex) { let value = hex.trim() if (value.startsWith('#')) { value = value.slice(1) } let r = 255 let g = 255 let b = 255 if (value.length === 3) { r = parseInt(value[0] + value[0], 16) g = parseInt(value[1] + value[1], 16) b = parseInt(value[2] + value[2], 16) } else if (value.length === 6) { r = parseInt(value.slice(0, 2), 16) g = parseInt(value.slice(2, 4), 16) b = parseInt(value.slice(4, 6), 16) } return new Vector3(r / 255, g / 255, b / 255) } export default class FloatingLines { constructor( container, { linesGradient, enabledWaves = ['top', 'middle', 'bottom'], lineCount = [6], lineDistance = [5], topWavePosition, bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 }, numPoints = 4, pointSpacingX = 0.8, pointsOffsetX = 0.0, pointYValues = [0.75, -0.5, 0.3, -0.75, 0.6, -0.4, 0.5, -0.6], lineSpread = 0.6, fanSpread = 0.25, lineSharpness = 1.0, waveFrequency = 8.0, bezierCurvature = 0.3, circleRadiusPx = 50, animationSpeed = 1, lineBrightness = 1.0, interactive = true, bendRadius = 5.0, bendStrength = -0.5, mouseDamping = 0.05, parallax = true, parallaxStrength = 0.2, circleGlowSize = 18.0, circleGlowStrength = 1.5, horizonMode = 'off', horizonOpacity = 0.5, horizonBlend = 0.2, bgColorCenter = '#0a0514', bgColorEdge = '#000000', mixBlendMode = 'screen', } = {}, ) { this.container = container this.interactive = interactive this.parallax = parallax this.mouseDamping = mouseDamping this.parallaxStrength = parallaxStrength this.targetMouse = new Vector2(-1000, -1000) this.currentMouse = new Vector2(-1000, -1000) this.targetInfluence = 0 this.currentInfluence = 0 this.targetParallax = new Vector2(0, 0) this.currentParallax = new Vector2(0, 0) const getLineCount = (waveType) => { if (typeof lineCount === 'number') return lineCount if (!enabledWaves.includes(waveType)) return 0 const index = enabledWaves.indexOf(waveType) return lineCount[index] ?? 6 } const getLineDistance = (waveType) => { if (typeof lineDistance === 'number') return lineDistance if (!enabledWaves.includes(waveType)) return 0.1 const index = enabledWaves.indexOf(waveType) return lineDistance[index] ?? 0.1 } const topLineCount = getLineCount('top') const middleLineCount = getLineCount('middle') const bottomLineCount = getLineCount('bottom') const topLineDistance = getLineDistance('top') * 0.01 const bottomLineDistance = getLineDistance('bottom') * 0.01 this.scene = new Scene() this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1) this.camera.position.z = 1 this.renderer = new WebGLRenderer({ antialias: true, alpha: false }) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) this.renderer.domElement.style.width = '100%' this.renderer.domElement.style.height = '100%' this.renderer.domElement.style.mixBlendMode = mixBlendMode container.appendChild(this.renderer.domElement) this.uniforms = { iTime: { value: 0 }, iResolution: { value: new Vector3(1, 1, 1) }, animationSpeed: { value: animationSpeed }, lineBrightness: { value: lineBrightness }, enableTop: { value: enabledWaves.includes('top') }, enableMiddle: { value: enabledWaves.includes('middle') }, enableBottom: { value: enabledWaves.includes('bottom') }, topLineCount: { value: topLineCount }, middleLineCount: { value: middleLineCount }, bottomLineCount: { value: bottomLineCount }, topLineDistance: { value: topLineDistance }, bottomLineDistance: { value: bottomLineDistance }, topWavePosition: { value: new Vector3( topWavePosition?.x ?? 10.0, topWavePosition?.y ?? 0.5, topWavePosition?.rotate ?? -0.4, ), }, bottomWavePosition: { value: new Vector3( bottomWavePosition?.x ?? 2.0, bottomWavePosition?.y ?? -0.7, bottomWavePosition?.rotate ?? 0.4, ), }, numPoints: { value: numPoints }, pointSpacingX: { value: pointSpacingX }, pointsOffsetX: { value: pointsOffsetX }, pointY: { value: [...pointYValues].slice(0, 8).concat(Array(8).fill(0)).slice(0, 8) }, lineSpread: { value: lineSpread }, fanSpread: { value: fanSpread }, lineSharpness: { value: lineSharpness }, waveFrequency: { value: waveFrequency }, bezierCurvature: { value: bezierCurvature }, circleRadiusPx: { value: circleRadiusPx }, circleGlowSize: { value: circleGlowSize }, circleGlowStrength: { value: circleGlowStrength }, iMouse: { value: new Vector2(-1000, -1000) }, interactive: { value: interactive }, bendRadius: { value: bendRadius }, bendStrength: { value: bendStrength }, bendInfluence: { value: 0 }, horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 }, horizonOpacity: { value: horizonOpacity }, horizonBlend: { value: horizonBlend }, parallax: { value: parallax }, parallaxStrength: { value: parallaxStrength }, parallaxOffset: { value: new Vector2(0, 0) }, lineGradient: { value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)), }, lineGradientCount: { value: 0 }, bgColorCenter: { value: new Vector3(0, 0, 0) }, bgColorEdge: { value: new Vector3(0, 0, 0) }, } if (linesGradient && linesGradient.length > 0) { const stops = linesGradient.slice(0, MAX_GRADIENT_STOPS) this.uniforms.lineGradientCount.value = stops.length stops.forEach((hex, i) => { const color = hexToVec3(hex) this.uniforms.lineGradient.value[i].set(color.x, color.y, color.z) }) } const center = hexToVec3(bgColorCenter) this.uniforms.bgColorCenter.value.set(center.x, center.y, center.z) const edge = hexToVec3(bgColorEdge) this.uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z) const material = new ShaderMaterial({ uniforms: this.uniforms, vertexShader, fragmentShader, }) const geometry = new PlaneGeometry(2, 2) this.mesh = new Mesh(geometry, material) this.scene.add(this.mesh) this.geometry = geometry this.material = material this._startTime = performance.now() this._setSize = () => { const width = container.clientWidth || 1 const height = container.clientHeight || 1 this.renderer.setSize(width, height, false) const canvasWidth = this.renderer.domElement.width const canvasHeight = this.renderer.domElement.height this.uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1) } this._setSize() this.ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(this._setSize) : null if (this.ro) this.ro.observe(container) this._handlePointerMove = (event) => { const rect = this.renderer.domElement.getBoundingClientRect() const x = event.clientX - rect.left const y = event.clientY - rect.top const dpr = this.renderer.getPixelRatio() this.targetMouse.set(x * dpr, (rect.height - y) * dpr) this.targetInfluence = 1.0 if (this.parallax) { const centerX = rect.width / 2 const centerY = rect.height / 2 const offsetX = (x - centerX) / rect.width const offsetY = -(y - centerY) / rect.height this.targetParallax.set(offsetX * this.parallaxStrength, offsetY * this.parallaxStrength) } } this._handlePointerLeave = () => { this.targetInfluence = 0.0 } this.renderer.domElement.addEventListener('pointermove', this._handlePointerMove) this.renderer.domElement.addEventListener('pointerleave', this._handlePointerLeave) this.raf = 0 const renderLoop = () => { this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001 if (this.interactive) { this.currentMouse.lerp(this.targetMouse, this.mouseDamping) this.uniforms.iMouse.value.copy(this.currentMouse) this.currentInfluence += (this.targetInfluence - this.currentInfluence) * this.mouseDamping this.uniforms.bendInfluence.value = this.currentInfluence } if (this.parallax) { this.currentParallax.lerp(this.targetParallax, this.mouseDamping) this.uniforms.parallaxOffset.value.copy(this.currentParallax) } this.renderer.render(this.scene, this.camera) this.raf = requestAnimationFrame(renderLoop) } this._handleVisibility = () => { if (document.hidden) { cancelAnimationFrame(this.raf) this.raf = 0 } else if (!this.raf) { renderLoop() } } document.addEventListener('visibilitychange', this._handleVisibility) renderLoop() } destroy() { cancelAnimationFrame(this.raf) if (this.ro) this.ro.disconnect() document.removeEventListener('visibilitychange', this._handleVisibility) this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove) this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave) this.geometry.dispose() this.material.dispose() this.renderer.dispose() if (this.renderer.domElement.parentElement) { this.renderer.domElement.parentElement.removeChild(this.renderer.domElement) } } }