mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-08-26 22:37:45 +10:00
515 lines
15 KiB
TypeScript
515 lines
15 KiB
TypeScript
// MARKER: Oil Backdrop
|
|
// cover art -> palette -> procedural oil waves <3
|
|
//
|
|
// The shader below is ported verbatim from Terax's website:
|
|
// https://github.com/crynta/Terax-website - components/line-waves.tsx
|
|
// Copyright Crynta, Apache-2.0
|
|
//
|
|
// Fully procedural, art only feeds the colours.
|
|
|
|
import { Mesh, Program, Renderer, Triangle } from "ogl";
|
|
import { type Rgb, samplePalette } from "./backdrop";
|
|
import { settings } from "./Settings";
|
|
|
|
const getDpr = (): number =>
|
|
settings.performanceMode ? 1 : Math.min(window.devicePixelRatio || 1, 2);
|
|
|
|
// Same coast as kawarp
|
|
const RAMP_SECONDS = 1.8;
|
|
// Values below are what terax.app actually renders with
|
|
// (background-waves.tsx, not the component defaults)
|
|
const REFERENCE_WAVE_SPEED = 0.35;
|
|
const REFERENCE_WARP = 0.3;
|
|
// Slider defaults land on the reference
|
|
const REFERENCE_SPEED_AT_SETTING = 175;
|
|
const REFERENCE_WARP_AT_SETTING = 100;
|
|
// Brightness is its own setting (theirs assumes full opacity)
|
|
// Shimmer has its own clock (sharing dragged the whole pattern)
|
|
const DRIFT_SPEED_AT_SETTING = 100;
|
|
// Glide, don't snap
|
|
const POINTER_LERP = 0.04;
|
|
|
|
// Palette crossfade rate
|
|
const COLOUR_LERP = 0.05;
|
|
// Site's values for the rest
|
|
const INNER_LINES = 40;
|
|
const OUTER_LINES = 15;
|
|
const ROTATION_DEG = -38;
|
|
const EDGE_FADE_WIDTH = 0;
|
|
// Site turns this off
|
|
const COLOUR_CYCLE_SPEED = 0;
|
|
const DRIFT_INFLUENCE = 1.6;
|
|
|
|
const vertexShader = `
|
|
attribute vec2 uv;
|
|
attribute vec2 position;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = vec4(position, 0, 1);
|
|
}
|
|
`;
|
|
|
|
const fragmentShader = `
|
|
precision highp float;
|
|
|
|
uniform float uTime;
|
|
uniform vec3 uResolution;
|
|
uniform float uSpeed;
|
|
uniform float uInnerLines;
|
|
uniform float uOuterLines;
|
|
uniform float uWarpIntensity;
|
|
uniform float uRotation;
|
|
uniform float uEdgeFadeWidth;
|
|
uniform float uColorCycleSpeed;
|
|
uniform float uBrightness;
|
|
uniform vec3 uColor1;
|
|
uniform vec3 uColor2;
|
|
uniform vec3 uColor3;
|
|
uniform vec2 uMouse;
|
|
uniform float uMouseInfluence;
|
|
uniform bool uEnableMouse;
|
|
|
|
#define HALF_PI 1.5707963
|
|
|
|
float hashF(float n) {
|
|
return fract(sin(n * 127.1) * 43758.5453123);
|
|
}
|
|
|
|
float smoothNoise(float x) {
|
|
float i = floor(x);
|
|
float f = fract(x);
|
|
float u = f * f * (3.0 - 2.0 * f);
|
|
return mix(hashF(i), hashF(i + 1.0), u);
|
|
}
|
|
|
|
float displaceA(float coord, float t) {
|
|
float result = sin(coord * 2.123) * 0.2;
|
|
result += sin(coord * 3.234 + t * 4.345) * 0.1;
|
|
result += sin(coord * 0.589 + t * 0.934) * 0.5;
|
|
return result;
|
|
}
|
|
|
|
float displaceB(float coord, float t) {
|
|
float result = sin(coord * 1.345) * 0.3;
|
|
result += sin(coord * 2.734 + t * 3.345) * 0.2;
|
|
result += sin(coord * 0.189 + t * 0.934) * 0.3;
|
|
return result;
|
|
}
|
|
|
|
vec2 rotate2D(vec2 p, float angle) {
|
|
float c = cos(angle);
|
|
float s = sin(angle);
|
|
return vec2(p.x * c - p.y * s, p.x * s + p.y * c);
|
|
}
|
|
|
|
void main() {
|
|
vec2 coords = gl_FragCoord.xy / uResolution.xy;
|
|
coords = coords * 2.0 - 1.0;
|
|
coords = rotate2D(coords, uRotation);
|
|
|
|
float halfT = uTime * uSpeed * 0.5;
|
|
float fullT = uTime * uSpeed;
|
|
|
|
float mouseWarp = 0.0;
|
|
if (uEnableMouse) {
|
|
vec2 mPos = rotate2D(uMouse * 2.0 - 1.0, uRotation);
|
|
float mDist = length(coords - mPos);
|
|
mouseWarp = uMouseInfluence * exp(-mDist * mDist * 4.0);
|
|
}
|
|
|
|
float warpAx = coords.x + displaceA(coords.y, halfT) * uWarpIntensity + mouseWarp;
|
|
float warpAy = coords.y - displaceA(coords.x * cos(fullT) * 1.235, halfT) * uWarpIntensity;
|
|
float warpBx = coords.x + displaceB(coords.y, halfT) * uWarpIntensity + mouseWarp;
|
|
float warpBy = coords.y - displaceB(coords.x * sin(fullT) * 1.235, halfT) * uWarpIntensity;
|
|
|
|
vec2 fieldA = vec2(warpAx, warpAy);
|
|
vec2 fieldB = vec2(warpBx, warpBy);
|
|
vec2 blended = mix(fieldA, fieldB, mix(fieldA, fieldB, 0.5));
|
|
|
|
float fadeTop = smoothstep(uEdgeFadeWidth, uEdgeFadeWidth + 0.4, blended.y);
|
|
float fadeBottom = smoothstep(-uEdgeFadeWidth, -(uEdgeFadeWidth + 0.4), blended.y);
|
|
float vMask = 1.0 - max(fadeTop, fadeBottom);
|
|
|
|
float tileCount = mix(uOuterLines, uInnerLines, vMask);
|
|
float scaledY = blended.y * tileCount;
|
|
float nY = smoothNoise(abs(scaledY));
|
|
|
|
float ridge = pow(
|
|
step(abs(nY - blended.x) * 2.0, HALF_PI) * cos(2.0 * (nY - blended.x)),
|
|
5.0
|
|
);
|
|
|
|
float lines = 0.0;
|
|
for (float i = 1.0; i < 3.0; i += 1.0) {
|
|
lines += pow(max(fract(scaledY), fract(-scaledY)), i * 2.0);
|
|
}
|
|
|
|
float pattern = vMask * lines;
|
|
|
|
float cycleT = fullT * uColorCycleSpeed;
|
|
float lum = (pattern + lines * ridge) * (cos(blended.y + cycleT * 0.5) * 0.5 + 1.0);
|
|
|
|
vec3 baseColor = (uColor1 + uColor2 + uColor3) / 3.0;
|
|
vec3 col = lum * baseColor * uBrightness;
|
|
float alpha = clamp(length(col), 0.0, 1.0);
|
|
|
|
gl_FragColor = vec4(col, alpha);
|
|
}
|
|
`;
|
|
|
|
/** Drifting warp point (odd periods so it barely repeats) */
|
|
const driftPoint = (t: number): [number, number] => [
|
|
0.5 + 0.35 * Math.sin(t * 0.7) * Math.cos(t * 0.31),
|
|
0.5 + 0.35 * Math.cos(t * 0.53) * Math.sin(t * 0.23),
|
|
];
|
|
|
|
export class OilLayer {
|
|
private host: HTMLElement;
|
|
private canvas: HTMLCanvasElement;
|
|
private renderer: Renderer | null = null;
|
|
private program: Program | null = null;
|
|
private mesh: Mesh | null = null;
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
private currentSrc: string | null = null;
|
|
private loadToken = 0;
|
|
private running = false;
|
|
private failed = false;
|
|
private playing = true;
|
|
private rafId: number | null = null;
|
|
private shaderTime = 0;
|
|
private driftTime = 0;
|
|
private lastFrame = 0;
|
|
// Smoothed position + raw target
|
|
private pointer: [number, number] = [0.5, 0.5];
|
|
private cursorTarget: [number, number] = [0.5, 0.5];
|
|
private cursorBound = false;
|
|
// Eased 0-1
|
|
private speedFactor = 1;
|
|
private targetColours: [Rgb, Rgb, Rgb] = [
|
|
[1, 1, 1],
|
|
[1, 1, 1],
|
|
[1, 1, 1],
|
|
];
|
|
|
|
constructor(host: HTMLElement, id: string, zIndex: string) {
|
|
this.host = host;
|
|
this.canvas = document.createElement("canvas");
|
|
this.canvas.id = `rl-oil-${id}`;
|
|
// kawarp's class, CSS already positions it
|
|
this.canvas.className = "rl-kawarp-canvas";
|
|
this.canvas.style.zIndex = zIndex;
|
|
|
|
this.canvas.addEventListener(
|
|
"webglcontextlost",
|
|
this.onContextLost as EventListener,
|
|
false,
|
|
);
|
|
this.canvas.addEventListener(
|
|
"webglcontextrestored",
|
|
this.onContextRestored as EventListener,
|
|
false,
|
|
);
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.syncSize();
|
|
});
|
|
this.resizeObserver.observe(host);
|
|
}
|
|
}
|
|
|
|
private onCursorMove = (event: MouseEvent): void => {
|
|
const r = this.canvas.getBoundingClientRect();
|
|
if (!r.width || !r.height) return;
|
|
this.cursorTarget = [
|
|
(event.clientX - r.left) / r.width,
|
|
// Shader is y-up
|
|
1 - (event.clientY - r.top) / r.height,
|
|
];
|
|
};
|
|
|
|
private bindCursor(want: boolean): void {
|
|
if (want === this.cursorBound) return;
|
|
this.cursorBound = want;
|
|
// On window so it tracks behind content
|
|
if (want) window.addEventListener("mousemove", this.onCursorMove);
|
|
else window.removeEventListener("mousemove", this.onCursorMove);
|
|
}
|
|
|
|
private onContextLost = (event: Event): void => {
|
|
// Needed or no restore fires
|
|
event.preventDefault();
|
|
this.running = false;
|
|
this.renderer = null;
|
|
this.program = null;
|
|
this.mesh = null;
|
|
};
|
|
|
|
private onContextRestored = (): void => {
|
|
this.renderer = null;
|
|
this.program = null;
|
|
this.mesh = null;
|
|
this.failed = false;
|
|
const src = this.currentSrc;
|
|
this.currentSrc = null;
|
|
this.apply(src);
|
|
};
|
|
|
|
private ensureEngine(): Program | null {
|
|
if (this.program || this.failed) return this.program;
|
|
try {
|
|
// Hand ogl our canvas (keeps CSS + cleanup the same)
|
|
const renderer = new Renderer({
|
|
canvas: this.canvas,
|
|
alpha: true,
|
|
premultipliedAlpha: false,
|
|
});
|
|
const gl = renderer.gl;
|
|
gl.clearColor(0, 0, 0, 0);
|
|
|
|
const program = new Program(gl, {
|
|
vertex: vertexShader,
|
|
fragment: fragmentShader,
|
|
uniforms: {
|
|
uTime: { value: 0 },
|
|
uResolution: { value: [1, 1, 1] },
|
|
uSpeed: { value: 1 },
|
|
uInnerLines: { value: INNER_LINES },
|
|
uOuterLines: { value: OUTER_LINES },
|
|
uWarpIntensity: { value: 1 },
|
|
uRotation: { value: (ROTATION_DEG * Math.PI) / 180 },
|
|
uEdgeFadeWidth: { value: EDGE_FADE_WIDTH },
|
|
uColorCycleSpeed: { value: COLOUR_CYCLE_SPEED },
|
|
uBrightness: { value: 0.2 },
|
|
uColor1: { value: [1, 1, 1] },
|
|
uColor2: { value: [1, 1, 1] },
|
|
uColor3: { value: [1, 1, 1] },
|
|
uMouse: { value: new Float32Array([0.5, 0.5]) },
|
|
uMouseInfluence: { value: DRIFT_INFLUENCE },
|
|
// On, we just drive it ourselves
|
|
uEnableMouse: { value: true },
|
|
},
|
|
});
|
|
|
|
this.renderer = renderer;
|
|
this.program = program;
|
|
this.mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
|
|
this.host.appendChild(this.canvas);
|
|
this.syncSize();
|
|
} catch (_err) {
|
|
// No WebGL/context refused
|
|
this.failed = true;
|
|
this.renderer = null;
|
|
this.program = null;
|
|
}
|
|
return this.program;
|
|
}
|
|
|
|
private syncSize(): void {
|
|
if (!this.renderer || !this.program) return;
|
|
const dpr = getDpr();
|
|
const rect = this.host.getBoundingClientRect();
|
|
const width = Math.max(1, Math.round(rect.width));
|
|
const height = Math.max(1, Math.round(rect.height));
|
|
this.renderer.dpr = dpr;
|
|
this.renderer.setSize(width, height);
|
|
const gl = this.renderer.gl;
|
|
this.program.uniforms.uResolution.value = [
|
|
gl.canvas.width,
|
|
gl.canvas.height,
|
|
gl.canvas.width / gl.canvas.height,
|
|
];
|
|
}
|
|
|
|
/** False once Tidal has rebuilt the container */
|
|
isMountedIn(container: HTMLElement): boolean {
|
|
return this.canvas.parentElement === container;
|
|
}
|
|
|
|
isVisible(): boolean {
|
|
if (!this.canvas.isConnected) return false;
|
|
if (typeof this.canvas.checkVisibility === "function") {
|
|
return this.canvas.checkVisibility({ checkVisibilityCSS: true });
|
|
}
|
|
return getComputedStyle(this.canvas).visibility !== "hidden";
|
|
}
|
|
|
|
/** Push current settings + cover art into the shader. */
|
|
apply(src: string | null): boolean {
|
|
const program = this.ensureEngine();
|
|
if (!program) return false;
|
|
|
|
const opacity = String(settings.backdropOilOpacity / 100);
|
|
if (this.canvas.style.opacity !== opacity)
|
|
this.canvas.style.opacity = opacity;
|
|
|
|
program.uniforms.uWarpIntensity.value =
|
|
REFERENCE_WARP * (settings.backdropOilWarp / REFERENCE_WARP_AT_SETTING);
|
|
program.uniforms.uSpeed.value =
|
|
REFERENCE_WAVE_SPEED *
|
|
(settings.backdropOilSpeed / REFERENCE_SPEED_AT_SETTING);
|
|
// Own dial, no art here to wash out lyrics
|
|
program.uniforms.uBrightness.value = settings.backdropOilBrightness / 100;
|
|
|
|
this.bindCursor(settings.backdropOilFollowCursor);
|
|
|
|
// No uniforms for these, so ride the canvas
|
|
const grade: string[] = [];
|
|
if (settings.backdropOilContrast !== 100)
|
|
grade.push(`contrast(${settings.backdropOilContrast}%)`);
|
|
if (settings.backdropOilSaturation !== 100)
|
|
grade.push(`saturate(${settings.backdropOilSaturation}%)`);
|
|
const filter = grade.join(" ");
|
|
if (this.canvas.style.filter !== filter) this.canvas.style.filter = filter;
|
|
|
|
this.syncSize();
|
|
|
|
if (src && src !== this.currentSrc) {
|
|
this.currentSrc = src;
|
|
void this.loadPalette(src);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Tidal sends no CORS headers, so fetch + decode ourselves */
|
|
private async loadPalette(src: string): Promise<void> {
|
|
const token = ++this.loadToken;
|
|
try {
|
|
const res = await fetch(src);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const blob = await res.blob();
|
|
if (token !== this.loadToken) return;
|
|
const bitmap = await createImageBitmap(blob);
|
|
try {
|
|
if (token !== this.loadToken) return;
|
|
this.targetColours = samplePalette(bitmap);
|
|
this.ensureLoop();
|
|
} finally {
|
|
bitmap.close();
|
|
}
|
|
} catch {
|
|
// Keep the old palette, retry later
|
|
if (token === this.loadToken) this.currentSrc = null;
|
|
}
|
|
}
|
|
|
|
/** Track play/pause (ramp render loop) */
|
|
setPlaying(playing: boolean): void {
|
|
if (this.playing === playing) return;
|
|
this.playing = playing;
|
|
this.ensureLoop();
|
|
}
|
|
|
|
/** Where the speed multiplier is heading (1 = playing 0 = paused) */
|
|
private get targetFactor(): number {
|
|
if (!settings.backdropPlaybackReactive) return 1;
|
|
return this.playing ? 1 : 0;
|
|
}
|
|
|
|
private tick = (): void => {
|
|
this.rafId = null;
|
|
const program = this.program;
|
|
const renderer = this.renderer;
|
|
const mesh = this.mesh;
|
|
if (!program || !renderer || !mesh || !this.running) return;
|
|
|
|
const now = performance.now();
|
|
// freeze so backgrounded tab doesn't jump shader
|
|
const dt = Math.min((now - this.lastFrame) / 1000, 0.1);
|
|
this.lastFrame = now;
|
|
|
|
const target = this.targetFactor;
|
|
const step = dt / RAMP_SECONDS;
|
|
if (this.speedFactor < target) {
|
|
this.speedFactor = Math.min(target, this.speedFactor + step);
|
|
} else if (this.speedFactor > target) {
|
|
this.speedFactor = Math.max(target, this.speedFactor - step);
|
|
}
|
|
|
|
// Own clock in real seconds, speed lives in uSpeed
|
|
this.shaderTime += dt * this.speedFactor;
|
|
program.uniforms.uTime.value = this.shaderTime;
|
|
|
|
// Not the speed ramp's target
|
|
let shimmer: [number, number];
|
|
if (settings.backdropOilFollowCursor) {
|
|
shimmer = this.cursorTarget;
|
|
} else {
|
|
this.driftTime +=
|
|
dt *
|
|
(settings.backdropOilShimmerSpeed / DRIFT_SPEED_AT_SETTING) *
|
|
this.speedFactor;
|
|
shimmer = driftPoint(this.driftTime);
|
|
}
|
|
// Glide when the source changes
|
|
this.pointer[0] += (shimmer[0] - this.pointer[0]) * POINTER_LERP;
|
|
this.pointer[1] += (shimmer[1] - this.pointer[1]) * POINTER_LERP;
|
|
program.uniforms.uMouse.value[0] = this.pointer[0];
|
|
program.uniforms.uMouse.value[1] = this.pointer[1];
|
|
|
|
// Ease into a new track's palette rather than cutting
|
|
for (const [i, key] of (["uColor1", "uColor2", "uColor3"] as const).entries()) {
|
|
const current = program.uniforms[key].value as number[];
|
|
const wanted = this.targetColours[i];
|
|
for (let c = 0; c < 3; c++) {
|
|
current[c] += (wanted[c] - current[c]) * COLOUR_LERP;
|
|
}
|
|
}
|
|
|
|
renderer.render({ scene: mesh });
|
|
|
|
// Stopped, park the loop
|
|
if (this.speedFactor === 0 && target === 0) return;
|
|
this.rafId = requestAnimationFrame(this.tick);
|
|
};
|
|
|
|
private ensureLoop(): void {
|
|
if (this.rafId !== null || !this.running || !this.program) return;
|
|
this.lastFrame = performance.now();
|
|
this.rafId = requestAnimationFrame(this.tick);
|
|
}
|
|
|
|
start(): void {
|
|
if (this.running || !this.program) return;
|
|
this.running = true;
|
|
this.ensureLoop();
|
|
}
|
|
|
|
stop(): void {
|
|
if (!this.running) return;
|
|
this.running = false;
|
|
if (this.rafId !== null) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.rafId = null;
|
|
}
|
|
}
|
|
|
|
setPaused(paused: boolean): void {
|
|
if (paused) this.stop();
|
|
else this.start();
|
|
}
|
|
|
|
dispose(): void {
|
|
this.stop();
|
|
this.bindCursor(false);
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = null;
|
|
this.canvas.removeEventListener(
|
|
"webglcontextlost",
|
|
this.onContextLost as EventListener,
|
|
);
|
|
this.canvas.removeEventListener(
|
|
"webglcontextrestored",
|
|
this.onContextRestored as EventListener,
|
|
);
|
|
// ogl has no disposer
|
|
this.renderer?.gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
this.renderer = null;
|
|
this.program = null;
|
|
this.mesh = null;
|
|
this.canvas.remove();
|
|
this.currentSrc = null;
|
|
}
|
|
}
|