Move fullscreen wash to WebGPU

This commit is contained in:
lucast committed 2026-10-06 02:11:56 +02:00
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>
@@ -0,0 +1,116 @@
import { fromOkLab, toOkLab } from "../palette"
import type { BackgroundFrame, BackgroundRenderer } from "./types"
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,
frame: BackgroundFrame,
): [number, number] {
const { random, angleJitter, flowParams, time } = frame
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,
]
}
export function createCpuRenderer(
canvas: HTMLCanvasElement,
): BackgroundRenderer | null {
const context = canvas.getContext("2d")
if (!context) return null
return {
render(frame) {
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width
canvas.height = frame.height
}
const image = context.createImageData(frame.width, frame.height)
const dominant = toOkLab(frame.palette.dominant)
const accent = toOkLab(frame.palette.accent)
const maxPaletteLightness = Math.max(dominant[0], accent[0])
for (let y = 0; y < frame.height; y++) {
for (let x = 0; x < frame.width; x++) {
const px = x / (frame.width - 1)
const py = y / (frame.height - 1)
const point = flowPoint(px - 0.5, py - 0.5, frame)
const blend = smoothstep(-0.34, 0.16, point[0])
const transition = 1 - Math.abs(blend * 2 - 1)
const lab: [number, number, number] = [
dominant[0] * (1 - blend) + accent[0] * blend,
dominant[1] * (1 - blend) + accent[1] * blend,
dominant[2] * (1 - blend) + accent[2] * blend,
]
lab[0] = Math.min(maxPaletteLightness, lab[0] + frame.beat * 0.01)
lab[1] *= 1 + frame.beat * 0.02
lab[2] *= 1 + frame.beat * 0.02
const color = fromOkLab(lab)
const offset = (y * frame.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
}
}
context.putImageData(image, 0, 0)
},
dispose() {},
}
}
@@ -0,0 +1,133 @@
struct Params {
resolutionTimeBeat: vec4f,
randomAndJitter: vec4f,
flowParams: vec4f,
dominant: vec4f,
accent: vec4f,
}
@group(0) @binding(0) var<uniform> params: Params;
fn hash(a: f32, b: f32, xScale: f32, yScale: f32) -> f32 {
let value = sin(a * xScale + b * yScale) * 43758.5453;
return fract(value) * 2.0 - 1.0;
}
fn gradientHash(cell: vec2f) -> vec2f {
return vec2f(
hash(cell.x, cell.y, 127.1, 311.7),
hash(cell.x, cell.y, 269.5, 183.3),
);
}
fn gradientDot(cell: vec2f, offset: vec2f) -> f32 {
return dot(gradientHash(cell), offset);
}
fn gradientNoise(point: vec2f) -> f32 {
let cell = floor(point);
let offset = fract(point);
let ease = offset * offset * (3.0 - 2.0 * offset);
let lower = mix(
gradientDot(cell, offset),
gradientDot(cell + vec2f(1.0, 0.0), offset - vec2f(1.0, 0.0)),
ease.x,
);
let upper = mix(
gradientDot(cell + vec2f(0.0, 1.0), offset - vec2f(0.0, 1.0)),
gradientDot(cell + vec2f(1.0, 1.0), offset - vec2f(1.0, 1.0)),
ease.x,
);
return 0.5 + 0.5 * mix(lower, upper, ease.y);
}
fn flowPoint(point: vec2f, time: f32) -> vec2f {
let degree = gradientNoise(vec2f(
time * 0.1 + params.randomAndJitter.x * 0.07,
point.x * point.y + params.randomAndJitter.y * 0.07,
));
let angle = ((degree - 0.5) * 720.0 + 180.0) * 0.017453292519943295
+ params.randomAndJitter.z;
let sine = sin(angle);
let cosine = cos(angle);
var flow = vec2f(
point.x * cosine - point.y * sine,
point.x * sine + point.y * cosine,
);
let speed = time * params.flowParams.z;
flow.x += sin(flow.y * params.flowParams.x + speed) / params.flowParams.y;
flow.y += sin(flow.x * params.flowParams.x * 1.5 + speed)
/ (params.flowParams.y * 0.5);
let tiltSine = sin(params.flowParams.w);
let tiltCosine = cos(params.flowParams.w);
return vec2f(
flow.x * tiltCosine - flow.y * tiltSine,
flow.x * tiltSine + flow.y * tiltCosine,
);
}
fn linearToSrgb(value: f32) -> f32 {
if (value <= 0.0031308) {
return 12.92 * value;
}
return 1.055 * pow(max(value, 0.0), 1.0 / 2.4) - 0.055;
}
fn fromOkLab(lab: vec3f) -> vec3f {
let lRoot = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
let mRoot = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
let sRoot = lab.x - 0.0894841775 * lab.y - 1.291485548 * lab.z;
let l = lRoot * lRoot * lRoot;
let m = mRoot * mRoot * mRoot;
let s = sRoot * sRoot * sRoot;
let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
let blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return clamp(vec3f(
linearToSrgb(red),
linearToSrgb(green),
linearToSrgb(blue),
), vec3f(0.0), vec3f(1.0));
}
fn ditherNoise(x: f32, y: f32, channel: f32) -> f32 {
let value = sin(
(x + channel * 17.0) * 127.1 + (y + channel * 59.0) * 311.7,
) * 43758.5453;
return fract(value) - 0.5;
}
@vertex
fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
var positions = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
return vec4f(positions[index], 0.0, 1.0);
}
@fragment
fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f {
let dimensions = params.resolutionTimeBeat.xy;
let time = params.resolutionTimeBeat.z;
let beat = params.resolutionTimeBeat.w;
let uv = position.xy / dimensions;
let point = flowPoint(uv - vec2f(0.5), time);
let blend = smoothstep(-0.34, 0.16, point.x);
let transition = 1.0 - abs(blend * 2.0 - 1.0);
var lab = mix(params.dominant.xyz, params.accent.xyz, blend);
lab.x = min(params.dominant.w, lab.x + beat * 0.01);
lab.y = lab.y * (1.0 + beat * 0.02);
lab.z = lab.z * (1.0 + beat * 0.02);
let color = fromOkLab(lab);
let pixel = floor(position.xy);
let noise = vec3f(
ditherNoise(pixel.x, pixel.y, 0.0),
ditherNoise(pixel.x, pixel.y, 1.0),
ditherNoise(pixel.x, pixel.y, 2.0),
);
let dithered = clamp(color + noise * transition * (3.0 / 255.0),
vec3f(0.0), vec3f(1.0));
return vec4f(dithered, 1.0);
}
@@ -0,0 +1,17 @@
import type { AlbumPalette } from "../palette"
export interface BackgroundFrame {
width: number
height: number
time: number
beat: number
random: [number, number]
angleJitter: number
flowParams: [number, number, number, number]
palette: AlbumPalette
}
export interface BackgroundRenderer {
render(frame: BackgroundFrame): void
dispose(): void
}
@@ -0,0 +1,181 @@
import { toOkLab } from "../palette"
import shader from "./shader.wgsl?raw"
import type { BackgroundFrame, BackgroundRenderer } from "./types"
const uniformUsage = 0x0040 | 0x0008
interface GpuBuffer {
destroy(): void
}
interface GpuPass {
setPipeline(pipeline: unknown): void
setBindGroup(index: number, bindGroup: unknown): void
draw(vertexCount: number, instanceCount?: number): void
end(): void
}
interface GpuDevice {
queue: {
writeBuffer(buffer: GpuBuffer, offset: number, data: Float32Array): void
submit(commands: unknown[]): void
}
lost: Promise<{ message: string }>
createBuffer(descriptor: { size: number; usage: number }): GpuBuffer
createShaderModule(descriptor: { code: string }): {
getCompilationInfo(): Promise<{
messages: Array<{ type: string; message: string }>
}>
}
createRenderPipeline(descriptor: unknown): {
getBindGroupLayout(index: number): unknown
}
createBindGroup(descriptor: unknown): unknown
createCommandEncoder(): {
beginRenderPass(descriptor: unknown): GpuPass
finish(): unknown
}
destroy(): void
}
interface GpuAdapter {
requestDevice(): Promise<GpuDevice>
}
interface GpuApi {
requestAdapter(): Promise<GpuAdapter | null>
getPreferredCanvasFormat(): string
}
interface GpuCanvasContext {
configure(options: {
device: GpuDevice
format: string
alphaMode: "opaque"
}): void
getCurrentTexture(): { createView(): unknown }
unconfigure(): void
}
export async function createWebGpuRenderer(
canvas: HTMLCanvasElement,
onDeviceLost: () => void,
): Promise<BackgroundRenderer | null> {
let device: GpuDevice | null = null
let context: GpuCanvasContext | null = null
let uniformBuffer: GpuBuffer | null = null
try {
const gpu = (navigator as unknown as { gpu?: GpuApi }).gpu
if (!gpu) {
console.info("[background renderer] WebGPU API unavailable; using CPU")
return null
}
const adapter = await gpu.requestAdapter()
if (!adapter) {
console.info("[background renderer] no WebGPU adapter; using CPU")
return null
}
device = await adapter.requestDevice()
const rawContext = (canvas.getContext as (kind: string) => unknown).call(
canvas,
"webgpu",
)
if (!rawContext) {
device.destroy()
return null
}
context = rawContext as GpuCanvasContext
const format = gpu.getPreferredCanvasFormat()
context.configure({ device, format, alphaMode: "opaque" })
const shaderModule = device.createShaderModule({ code: shader })
const compilation = await shaderModule.getCompilationInfo()
const shaderErrors = compilation.messages.filter(
(message) => message.type === "error",
)
if (shaderErrors.length) {
throw new Error(shaderErrors.map((message) => message.message).join("\n"))
}
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shaderModule, entryPoint: "vertexMain" },
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{ format }],
},
primitive: { topology: "triangle-list" },
})
uniformBuffer = device.createBuffer({ size: 80, usage: uniformUsage })
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
})
const activeDevice = device
const activeContext = context
const activeBuffer = uniformBuffer
let disposed = false
void device.lost.then(() => {
if (!disposed) onDeviceLost()
})
return {
render(frame: BackgroundFrame) {
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width
canvas.height = frame.height
}
const dominant = toOkLab(frame.palette.dominant)
const accent = toOkLab(frame.palette.accent)
const uniforms = new Float32Array([
frame.width,
frame.height,
frame.time,
frame.beat,
frame.random[0],
frame.random[1],
frame.angleJitter,
0,
...frame.flowParams,
dominant[0],
dominant[1],
dominant[2],
Math.max(dominant[0], accent[0]),
accent[0],
accent[1],
accent[2],
0,
])
activeDevice.queue.writeBuffer(activeBuffer, 0, uniforms)
const encoder = activeDevice.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: activeContext.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, bindGroup)
pass.draw(3, 1)
pass.end()
activeDevice.queue.submit([encoder.finish()])
},
dispose() {
disposed = true
activeContext.unconfigure()
activeBuffer.destroy()
activeDevice.destroy()
},
}
} catch (error) {
console.warn("[background renderer] WebGPU unavailable; using CPU", error)
uniformBuffer?.destroy()
context?.unconfigure()
device?.destroy()
return null
}
}