Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
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
})
}