Move fullscreen wash to WebGPU
This commit is contained in:
1 parent
ebd6fedd89
commit
b696da1854
5 files changed
+579
-134
No files matched your search
@@ -1,23 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
extractPalette,
|
||||
fromOkLab,
|
||||
toOkLab,
|
||||
type AlbumPalette,
|
||||
} from "./palette"
|
||||
import { createCpuRenderer } from "./renderers/cpu"
|
||||
import { createWebGpuRenderer } from "./renderers/webgpu"
|
||||
import type { BackgroundFrame, BackgroundRenderer } from "./renderers/types"
|
||||
import { shapeBeatPulse } from "~/utils/beat"
|
||||
|
||||
const props = defineProps<{ src: string; active: boolean }>()
|
||||
const ANIMATION_SPEED = 0.3
|
||||
const { beatEnergy } = usePlayer()
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const cpuCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const webGpuCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const palette = ref<AlbumPalette | null>(null)
|
||||
const backend = ref<"cpu" | "webgpu">("cpu")
|
||||
let frame = 0
|
||||
let startedAt = 0
|
||||
let lastFrameAt = 0
|
||||
let random = [0, 0]
|
||||
let random: [number, number] = [0, 0]
|
||||
let angleJitter = 0
|
||||
let flowParams: [number, number, number, number] = [5, 25, 0.75, -0.08]
|
||||
let cpuRenderer: BackgroundRenderer | null = null
|
||||
let webGpuRenderer: BackgroundRenderer | null = null
|
||||
let activeRenderer: BackgroundRenderer | null = null
|
||||
let disposed = false
|
||||
|
||||
const fallback: AlbumPalette = { dominant: [30, 35, 55], accent: [105, 65, 45] }
|
||||
|
||||
@@ -26,23 +33,19 @@ function loadArtwork() {
|
||||
image.crossOrigin = "anonymous"
|
||||
image.onload = () => {
|
||||
try {
|
||||
setPalette(extractPalette(image))
|
||||
palette.value = extractPalette(image)
|
||||
} catch {
|
||||
setPalette(fallback)
|
||||
palette.value = fallback
|
||||
}
|
||||
}
|
||||
image.onerror = () => {
|
||||
setPalette(fallback)
|
||||
palette.value = fallback
|
||||
}
|
||||
image.src = props.src
|
||||
}
|
||||
|
||||
function setPalette(next: AlbumPalette) {
|
||||
palette.value = next
|
||||
}
|
||||
|
||||
function randomizeMotion() {
|
||||
random = Array.from({ length: 2 }, () => Math.random() * Math.PI * 2)
|
||||
random = [Math.random() * Math.PI * 2, Math.random() * Math.PI * 2]
|
||||
angleJitter = (Math.random() - 0.5) * 0.3
|
||||
flowParams = [
|
||||
4.5 + Math.random(),
|
||||
@@ -52,6 +55,101 @@ function randomizeMotion() {
|
||||
]
|
||||
}
|
||||
|
||||
function useCpuFallback() {
|
||||
if (!cpuRenderer) return
|
||||
activeRenderer = cpuRenderer
|
||||
backend.value = "cpu"
|
||||
}
|
||||
|
||||
async function initializeRenderers() {
|
||||
const cpuElement = cpuCanvas.value
|
||||
const gpuElement = webGpuCanvas.value
|
||||
if (!cpuElement || !gpuElement) return
|
||||
cpuRenderer = createCpuRenderer(cpuElement)
|
||||
activeRenderer = cpuRenderer
|
||||
if (!cpuRenderer) {
|
||||
console.warn("[background renderer] 2D canvas is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
let rendererLost = false
|
||||
const renderer = await createWebGpuRenderer(gpuElement, () => {
|
||||
rendererLost = true
|
||||
webGpuRenderer = null
|
||||
console.warn("[background renderer] WebGPU device lost; using CPU")
|
||||
useCpuFallback()
|
||||
})
|
||||
if (!renderer) return
|
||||
if (disposed || rendererLost) {
|
||||
renderer.dispose()
|
||||
return
|
||||
}
|
||||
webGpuRenderer = renderer
|
||||
activeRenderer = renderer
|
||||
backend.value = "webgpu"
|
||||
console.info("[background renderer] using WebGPU")
|
||||
}
|
||||
|
||||
function makeFrame(now: number, canvas: HTMLCanvasElement): BackgroundFrame {
|
||||
if (!startedAt) startedAt = now
|
||||
const width = 224
|
||||
const height = Math.max(
|
||||
72,
|
||||
Math.round(
|
||||
(width * canvas.clientHeight) / Math.max(1, canvas.clientWidth),
|
||||
),
|
||||
)
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
time: ((now - startedAt) / 1000) * ANIMATION_SPEED,
|
||||
beat: shapeBeatPulse(beatEnergy.value),
|
||||
random,
|
||||
angleJitter,
|
||||
flowParams,
|
||||
palette: palette.value ?? fallback,
|
||||
}
|
||||
}
|
||||
|
||||
function draw(now: number) {
|
||||
if (!props.active || disposed) return
|
||||
if (now - lastFrameAt < 1000 / 24) {
|
||||
frame = requestAnimationFrame(draw)
|
||||
return
|
||||
}
|
||||
lastFrameAt = now
|
||||
const canvas = backend.value === "webgpu" ? webGpuCanvas.value : cpuCanvas.value
|
||||
if (canvas && activeRenderer) {
|
||||
const renderFrame = makeFrame(now, canvas)
|
||||
canvas.style.transform = `scale(${1.08 + renderFrame.beat * 0.003})`
|
||||
try {
|
||||
activeRenderer.render(renderFrame)
|
||||
} catch (error) {
|
||||
if (backend.value === "webgpu") {
|
||||
console.warn(
|
||||
"[background renderer] WebGPU render failed; using CPU",
|
||||
error,
|
||||
)
|
||||
webGpuRenderer?.dispose()
|
||||
webGpuRenderer = null
|
||||
useCpuFallback()
|
||||
cpuCanvas.value?.style.setProperty(
|
||||
"transform",
|
||||
`scale(${1.08 + renderFrame.beat * 0.003})`,
|
||||
)
|
||||
try {
|
||||
cpuRenderer?.render(renderFrame)
|
||||
} catch (fallbackError) {
|
||||
console.warn("[background renderer] CPU fallback failed", fallbackError)
|
||||
}
|
||||
} else {
|
||||
console.warn("[background renderer] CPU render failed", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
cancelAnimationFrame(frame)
|
||||
startedAt = 0
|
||||
@@ -60,147 +158,42 @@ function startAnimation() {
|
||||
frame = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
function gradientHash(x: number, y: number): [number, number] {
|
||||
const hash = (a: number, b: number, xScale: number, yScale: number) => {
|
||||
const value = Math.sin(a * xScale + b * yScale) * 43758.5453
|
||||
return (value - Math.floor(value)) * 2 - 1
|
||||
}
|
||||
return [hash(x, y, 127.1, 311.7), hash(x, y, 269.5, 183.3)]
|
||||
}
|
||||
|
||||
function gradientNoise(x: number, y: number) {
|
||||
const cellX = Math.floor(x)
|
||||
const cellY = Math.floor(y)
|
||||
const offsetX = x - cellX
|
||||
const offsetY = y - cellY
|
||||
const easeX = offsetX * offsetX * (3 - 2 * offsetX)
|
||||
const easeY = offsetY * offsetY * (3 - 2 * offsetY)
|
||||
const dot = (cx: number, cy: number, ox: number, oy: number) => {
|
||||
const [gx, gy] = gradientHash(cx, cy)
|
||||
return gx * ox + gy * oy
|
||||
}
|
||||
const lower =
|
||||
dot(cellX, cellY, offsetX, offsetY) * (1 - easeX) +
|
||||
dot(cellX + 1, cellY, offsetX - 1, offsetY) * easeX
|
||||
const upper =
|
||||
dot(cellX, cellY + 1, offsetX, offsetY - 1) * (1 - easeX) +
|
||||
dot(cellX + 1, cellY + 1, offsetX - 1, offsetY - 1) * easeX
|
||||
return 0.5 + 0.5 * (lower * (1 - easeY) + upper * easeY)
|
||||
}
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, value: number) {
|
||||
const amount = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
|
||||
return amount * amount * (3 - 2 * amount)
|
||||
}
|
||||
|
||||
function ditherNoise(x: number, y: number, channel: number) {
|
||||
const value = Math.sin(
|
||||
(x + channel * 17) * 127.1 + (y + channel * 59) * 311.7,
|
||||
) * 43758.5453
|
||||
return value - Math.floor(value) - 0.5
|
||||
}
|
||||
|
||||
function flowPoint(x: number, y: number, time: number): [number, number] {
|
||||
const degree = gradientNoise(
|
||||
time * 0.1 + random[0] * 0.07,
|
||||
x * y + random[1] * 0.07,
|
||||
)
|
||||
const angle = (((degree - 0.5) * 720 + 180) * Math.PI) / 180 + angleJitter
|
||||
const sine = Math.sin(angle)
|
||||
const cosine = Math.cos(angle)
|
||||
let flowX = x * cosine - y * sine
|
||||
let flowY = x * sine + y * cosine
|
||||
const speed = time * flowParams[2]
|
||||
flowX += Math.sin(flowY * flowParams[0] + speed) / flowParams[1]
|
||||
flowY +=
|
||||
Math.sin(flowX * flowParams[0] * 1.5 + speed) / (flowParams[1] * 0.5)
|
||||
const tiltSine = Math.sin(flowParams[3])
|
||||
const tiltCosine = Math.cos(flowParams[3])
|
||||
return [
|
||||
flowX * tiltCosine - flowY * tiltSine,
|
||||
flowX * tiltSine + flowY * tiltCosine,
|
||||
]
|
||||
}
|
||||
|
||||
function draw(now: number) {
|
||||
const element = canvas.value
|
||||
const context = element?.getContext("2d")
|
||||
if (!element || !context) return
|
||||
if (!startedAt) startedAt = now
|
||||
if (now - lastFrameAt < 1000 / 24) {
|
||||
frame = requestAnimationFrame(draw)
|
||||
return
|
||||
}
|
||||
lastFrameAt = now
|
||||
const time = (now - startedAt) / 1000
|
||||
const width = 224
|
||||
const height = Math.max(
|
||||
72,
|
||||
Math.round(
|
||||
(width * element.clientHeight) / Math.max(1, element.clientWidth),
|
||||
),
|
||||
)
|
||||
if (element.width !== width || element.height !== height) {
|
||||
element.width = width
|
||||
element.height = height
|
||||
}
|
||||
const image = context.createImageData(width, height)
|
||||
const colors = palette.value ?? fallback
|
||||
const dominant = toOkLab(colors.dominant)
|
||||
const accent = toOkLab(colors.accent)
|
||||
const maxPaletteLightness = Math.max(dominant[0], accent[0])
|
||||
const motionTime = time * ANIMATION_SPEED
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const px = x / (width - 1)
|
||||
const py = y / (height - 1)
|
||||
const point = flowPoint(px - 0.5, py - 0.5, motionTime)
|
||||
const blend = smoothstep(-0.34, 0.16, point[0])
|
||||
const transition = 1 - Math.abs(blend * 2 - 1)
|
||||
const lab = dominant.map(
|
||||
(value, channel) =>
|
||||
value * (1 - blend) + accent[channel] * blend,
|
||||
) as [number, number, number]
|
||||
const beat = shapeBeatPulse(beatEnergy.value)
|
||||
lab[0] = Math.min(maxPaletteLightness, lab[0] + beat * 0.01)
|
||||
lab[1] *= 1 + beat * 0.02
|
||||
lab[2] *= 1 + beat * 0.02
|
||||
const color = fromOkLab(lab)
|
||||
const offset = (y * width + x) * 4
|
||||
image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3
|
||||
image.data[offset + 1] = color[1] + ditherNoise(x, y, 1) * transition * 3
|
||||
image.data[offset + 2] = color[2] + ditherNoise(x, y, 2) * transition * 3
|
||||
image.data[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
element.style.transform = `scale(${1.08 + shapeBeatPulse(beatEnergy.value) * 0.003})`
|
||||
context.putImageData(image, 0, 0)
|
||||
if (props.active) frame = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
watch(() => props.src, loadArtwork)
|
||||
watch(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
if (active) startAnimation()
|
||||
else {
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
else cancelAnimationFrame(frame)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadArtwork()
|
||||
void initializeRenderers()
|
||||
if (props.active) startAnimation()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
cancelAnimationFrame(frame)
|
||||
webGpuRenderer?.dispose()
|
||||
cpuRenderer?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="background-wash" aria-hidden="true" />
|
||||
<canvas
|
||||
ref="webGpuCanvas"
|
||||
class="background-wash"
|
||||
:class="{ 'background-wash--active': backend === 'webgpu' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<canvas
|
||||
ref="cpuCanvas"
|
||||
class="background-wash"
|
||||
:class="{ 'background-wash--active': backend === 'cpu' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -211,5 +204,10 @@ onBeforeUnmount(() => {
|
||||
height: 50%;
|
||||
filter: blur(6px) saturate(1.08);
|
||||
transform: scale(1.08);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.background-wash--active {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user