diff --git a/app/components/Player/Fullscreen/Background/Wash.vue b/app/components/Player/Fullscreen/Background/Wash.vue index b86e80f..56c3c1e 100644 --- a/app/components/Player/Fullscreen/Background/Wash.vue +++ b/app/components/Player/Fullscreen/Background/Wash.vue @@ -1,23 +1,30 @@ diff --git a/app/components/Player/Fullscreen/Background/renderers/cpu.ts b/app/components/Player/Fullscreen/Background/renderers/cpu.ts new file mode 100644 index 0000000..633b9d1 --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/cpu.ts @@ -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() {}, + } +} diff --git a/app/components/Player/Fullscreen/Background/renderers/shader.wgsl b/app/components/Player/Fullscreen/Background/renderers/shader.wgsl new file mode 100644 index 0000000..04f5ca2 --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/shader.wgsl @@ -0,0 +1,133 @@ +struct Params { + resolutionTimeBeat: vec4f, + randomAndJitter: vec4f, + flowParams: vec4f, + dominant: vec4f, + accent: vec4f, +} + +@group(0) @binding(0) var 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(-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); +} diff --git a/app/components/Player/Fullscreen/Background/renderers/types.ts b/app/components/Player/Fullscreen/Background/renderers/types.ts new file mode 100644 index 0000000..9fd489a --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/types.ts @@ -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 +} diff --git a/app/components/Player/Fullscreen/Background/renderers/webgpu.ts b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts new file mode 100644 index 0000000..b057fda --- /dev/null +++ b/app/components/Player/Fullscreen/Background/renderers/webgpu.ts @@ -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 +} + +interface GpuApi { + requestAdapter(): Promise + 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 { + 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 + } +}