Files
BRETT/app/composables/useCardShader.ts
T
2026-10-03 20:07:49 +02:00

195 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { value: unknown }>
/** 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<string, number>
}
/**
* 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<HTMLElement | null>, 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
})
}