import type { Ref } from 'vue' import { Mesh, Program, Renderer, Triangle } from 'ogl' import { hexToRgbTuple, shadeHex } from '@/lib/color' export interface CardShaderMouse { /** 0..1, links → rechts */ x: number /** 0..1, unten → oben (GL-Konvention) */ y: number /** 0..1 weich eingedämpft — 1 solange der Zeiger über der Karte ist */ active: number } interface CardShaderOptions { vertex: string fragment: string uniforms: Record /** Wird pro Frame vor dem Rendern aufgerufen — Uniforms hier aktualisieren */ frame?: (ctx: { t: number, program: Program, mouse: CardShaderMouse }) => void /** Zeiger-Tracking aktivieren (mousemove am window, relativ zur Karte) */ mouse?: boolean /** Renderbuffer relativ zur CSS-Größe (0.1–1) — Perf-Hebel für teure Shader */ renderScale?: number /** Getter für die Kartenfarbe — bei Änderung werden die colorUniforms aktualisiert */ color?: () => string /** uniform-Name → Shade-Offset (0 = Basisfarbe, <0 dunkler, >0 heller) */ colorUniforms?: Record } /** * Gemeinsames Setup für Vue-Bits-Shader-Karteneffekte: * WebGL-Renderer → 2D-Canvas-Blit (Waterfox zeichnet WebGL-Layer nach Scrollen * teils versetzt und ignoriert CSS-Clips), ResizeObserver, Pause wenn die Karte * nicht sichtbar ist, plus uCardRes/uRadius-Uniforms für die Rounded-Rect-Maske. * Erwartet premultiplied Output: `fragColor = vec4(col * a, a)` — die Maske * wird bereits in den Shadern auf a angewendet. */ export function useCardShader(container: Ref, options: CardShaderOptions) { let teardown: (() => void) | null = null let programRef: Program | null = null // Farbwechsel live in die Uniforms übernehmen — sonst zeigt die // Settings-Vorschau die neue Farbe erst nach Remount if (options.color && options.colorUniforms) { watch(options.color, (c) => { if (!programRef) return for (const [name, shade] of Object.entries(options.colorUniforms!)) { const u = programRef.uniforms[name] if (u) u.value = new Float32Array(hexToRgbTuple(shadeHex(c, shade))) } }) } onMounted(() => { const ctn = container.value if (!ctn) return const renderer = new Renderer({ webgl: 2, alpha: true, premultipliedAlpha: true, antialias: false, dpr: Math.min(window.devicePixelRatio || 1, 2), }) const gl = renderer.gl gl.clearColor(0, 0, 0, 0) gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) const outCanvas = document.createElement('canvas') outCanvas.className = 'absolute inset-0 size-full rounded-[inherit]' const outCtx = outCanvas.getContext('2d') ctn.appendChild(outCanvas) const geometry = new Triangle(gl) // OGL legt ein uv-Attribut an — entfernen, wenn der Vertex-Shader es nicht nutzt if (!options.vertex.includes('uv') && geometry.attributes.uv) { delete geometry.attributes.uv } const program = new Program(gl, { vertex: options.vertex, fragment: options.fragment, uniforms: { uCardRes: { value: new Float32Array([1, 1]) }, uRadius: { value: 0 }, ...options.uniforms, }, }) const mesh = new Mesh(gl, { geometry, program }) programRef = program const mouse: CardShaderMouse = { x: 0.5, y: 0.5, active: 0 } const mouseTarget = { x: 0.5, y: 0.5, active: 0 } const onPointerMove = (e: PointerEvent) => { const rect = ctn.getBoundingClientRect() if (!rect.width || !rect.height) return const x = (e.clientX - rect.left) / rect.width const y = 1 - (e.clientY - rect.top) / rect.height const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1 mouseTarget.x = Math.min(1, Math.max(0, x)) mouseTarget.y = Math.min(1, Math.max(0, y)) mouseTarget.active = inside ? 1 : 0 } if (options.mouse) { window.addEventListener('pointermove', onPointerMove, { passive: true }) } const scale = Math.min(1, Math.max(0.1, options.renderScale ?? 1)) const setSize = () => { const rect = ctn.getBoundingClientRect() renderer.setSize( Math.max(1, Math.floor(rect.width * scale)), Math.max(1, Math.floor(rect.height * scale)), ) const res = program.uniforms.uCardRes!.value as Float32Array res[0] = gl.drawingBufferWidth res[1] = gl.drawingBufferHeight const cssRadius = Number.parseFloat(getComputedStyle(ctn).borderTopLeftRadius) || 0 program.uniforms.uRadius!.value = rect.width > 0 ? cssRadius * (gl.drawingBufferWidth / rect.width) : 0 outCanvas.width = gl.drawingBufferWidth outCanvas.height = gl.drawingBufferHeight } const ro = new ResizeObserver(setSize) ro.observe(ctn) setSize() let raf = 0 let visible = true let pageVisible = document.visibilityState !== 'hidden' const loop = (t: number) => { if (!visible || !pageVisible) { raf = 0 return } raf = requestAnimationFrame(loop) mouse.x += 0.05 * (mouseTarget.x - mouse.x) mouse.y += 0.05 * (mouseTarget.y - mouse.y) mouse.active += 0.05 * (mouseTarget.active - mouse.active) options.frame?.({ t, program, mouse }) renderer.render({ scene: mesh }) outCtx?.clearRect(0, 0, outCanvas.width, outCanvas.height) outCtx?.drawImage(gl.canvas, 0, 0, outCanvas.width, outCanvas.height) } const start = () => { if (!raf && visible && pageVisible) raf = requestAnimationFrame(loop) } const stop = () => { if (raf) { cancelAnimationFrame(raf) raf = 0 } } const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true if (visible) start() else stop() }) io.observe(ctn) const onVisibility = () => { pageVisible = document.visibilityState !== 'hidden' if (pageVisible) start() else stop() } document.addEventListener('visibilitychange', onVisibility) start() teardown = () => { stop() ro.disconnect() io.disconnect() document.removeEventListener('visibilitychange', onVisibility) if (options.mouse) window.removeEventListener('pointermove', onPointerMove) if (outCanvas.parentNode === ctn) ctn.removeChild(outCanvas) gl.getExtension('WEBGL_lose_context')?.loseContext() } }) onUnmounted(() => { teardown?.() teardown = null }) }