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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user