forked from BRETT/BRETT
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
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
|
||
})
|
||
}
|