Installation
To get started, make sure to save the Shader component to your project.
"use client";
import { useEffect, useRef, forwardRef, useState } from "react";
import * as React from "react";
const vertexShaderSource = `#version 300 es
precision mediump float;
layout(location = 0) in vec4 a_position;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform float u_imageAspectRatio;
uniform float u_originX;
uniform float u_originY;
uniform float u_worldWidth;
uniform float u_worldHeight;
uniform float u_fit;
uniform float u_scale;
uniform float u_rotation;
uniform float u_offsetX;
uniform float u_offsetY;
uniform float u_pxSize;
out vec2 v_objectUV;
out vec2 v_objectBoxSize;
out vec2 v_objectHelperBox;
out vec2 v_responsiveUV;
out vec2 v_responsiveBoxSize;
out vec2 v_responsiveHelperBox;
out vec2 v_responsiveBoxGivenSize;
out vec2 v_patternUV;
out vec2 v_patternBoxSize;
out vec2 v_patternHelperBox;
out vec2 v_imageUV;
// #define ADD_HELPERS
vec3 getBoxSize(float boxRatio, vec2 givenBoxSize, vec2 maxBoxSize) {
vec2 box = vec2(0.);
// fit = none
box.x = boxRatio * min(givenBoxSize.x / boxRatio, givenBoxSize.y);
float noFitBoxWidth = box.x;
if (u_fit == 1.) { // fit = contain
box.x = boxRatio * min(maxBoxSize[0] / boxRatio, maxBoxSize[1]);
} else if (u_fit == 2.) { // fit = cover
box.x = boxRatio * max(maxBoxSize[0] / boxRatio, maxBoxSize[1]);
}
box.y = box.x / boxRatio;
return vec3(box, noFitBoxWidth);
}
void main() {
gl_Position = a_position;
vec2 uv = gl_Position.xy * .5;
vec2 boxOrigin = vec2(.5 - u_originX, u_originY - .5);
vec2 givenBoxSize = vec2(u_worldWidth, u_worldHeight);
givenBoxSize = max(givenBoxSize, vec2(1.)) * u_pixelRatio;
vec2 maxBoxSize = vec2(max(u_resolution.x, givenBoxSize.x), max(u_resolution.y, givenBoxSize.y));
float r = u_rotation * 3.14159265358979323846 / 180.;
mat2 graphicRotation = mat2(cos(r), sin(r), -sin(r), cos(r));
vec2 graphicOffset = vec2(-u_offsetX, u_offsetY);
// ===================================================
// Sizing api for graphic objects with fixed ratio
// (currently supports only ratio = 1)
float fixedRatio = 1.;
vec2 fixedRatioBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
v_objectBoxSize = getBoxSize(fixedRatio, fixedRatioBoxGivenSize, maxBoxSize).xy;
vec2 objectWorldScale = u_resolution.xy / v_objectBoxSize;
#ifdef ADD_HELPERS
v_objectHelperBox = uv;
v_objectHelperBox *= objectWorldScale;
v_objectHelperBox += boxOrigin * (objectWorldScale - 1.);
#endif
v_objectUV = uv;
v_objectUV *= objectWorldScale;
v_objectUV += boxOrigin * (objectWorldScale - 1.);
v_objectUV += graphicOffset;
v_objectUV /= u_scale;
v_objectUV = graphicRotation * v_objectUV;
// ===================================================
// ===================================================
// Sizing api for graphic objects with either givenBoxSize ratio or canvas ratio.
// Full-screen mode available with u_worldWidth = u_worldHeight = 0
v_responsiveBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
float responsiveRatio = v_responsiveBoxGivenSize.x / v_responsiveBoxGivenSize.y;
v_responsiveBoxSize = getBoxSize(responsiveRatio, v_responsiveBoxGivenSize, maxBoxSize).xy;
vec2 responsiveBoxScale = u_resolution.xy / v_responsiveBoxSize;
#ifdef ADD_HELPERS
v_responsiveHelperBox = uv;
v_responsiveHelperBox *= responsiveBoxScale;
v_responsiveHelperBox += boxOrigin * (responsiveBoxScale - 1.);
#endif
v_responsiveUV = uv;
v_responsiveUV *= responsiveBoxScale;
v_responsiveUV += boxOrigin * (responsiveBoxScale - 1.);
v_responsiveUV += graphicOffset;
v_responsiveUV /= u_scale;
v_responsiveUV.x *= responsiveRatio;
v_responsiveUV = graphicRotation * v_responsiveUV;
v_responsiveUV.x /= responsiveRatio;
// ===================================================
// ===================================================
// Sizing api for patterns
// (treating graphics as a image u_worldWidth x u_worldHeight size)
float patternBoxRatio = givenBoxSize.x / givenBoxSize.y;
vec2 patternBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
patternBoxRatio = patternBoxGivenSize.x / patternBoxGivenSize.y;
vec3 boxSizeData = getBoxSize(patternBoxRatio, patternBoxGivenSize, maxBoxSize);
v_patternBoxSize = boxSizeData.xy;
float patternBoxNoFitBoxWidth = boxSizeData.z;
vec2 patternBoxScale = u_resolution.xy / v_patternBoxSize;
#ifdef ADD_HELPERS
v_patternHelperBox = uv;
v_patternHelperBox *= patternBoxScale;
v_patternHelperBox += boxOrigin * (patternBoxScale - 1.);
#endif
v_patternUV = uv;
v_patternUV += graphicOffset / patternBoxScale;
v_patternUV += boxOrigin;
v_patternUV -= boxOrigin / patternBoxScale;
v_patternUV *= u_resolution.xy;
v_patternUV /= u_pixelRatio;
if (u_fit > 0.) {
v_patternUV *= (patternBoxNoFitBoxWidth / v_patternBoxSize.x);
}
v_patternUV /= u_scale;
v_patternUV = graphicRotation * v_patternUV;
v_patternUV += boxOrigin / patternBoxScale;
v_patternUV -= boxOrigin;
// x100 is a default multiplier between vertex and fragmant shaders
// we use it to avoid UV presision issues
v_patternUV *= .01;
// ===================================================
// ===================================================
// Sizing api for images
vec2 imageBoxSize;
if (u_fit == 1.) { // contain
imageBoxSize.x = min(maxBoxSize.x / u_imageAspectRatio, maxBoxSize.y) * u_imageAspectRatio;
} else if (u_fit == 2.) { // cover
imageBoxSize.x = max(maxBoxSize.x / u_imageAspectRatio, maxBoxSize.y) * u_imageAspectRatio;
} else {
imageBoxSize.x = min(10.0, 10.0 / u_imageAspectRatio * u_imageAspectRatio);
}
imageBoxSize.y = imageBoxSize.x / u_imageAspectRatio;
vec2 imageBoxScale = u_resolution.xy / imageBoxSize;
#ifdef ADD_HELPERS
vec2 imageHelperBox = uv;
imageHelperBox *= imageBoxScale;
imageHelperBox += boxOrigin * (imageBoxScale - 1.);
#endif
v_imageUV = uv;
v_imageUV *= imageBoxScale;
v_imageUV += boxOrigin * (imageBoxScale - 1.);
v_imageUV += graphicOffset;
v_imageUV /= u_scale;
v_imageUV.x *= u_imageAspectRatio;
v_imageUV = graphicRotation * v_imageUV;
v_imageUV.x /= u_imageAspectRatio;
v_imageUV += .5;
v_imageUV.y = 1. - v_imageUV.y;
// ===================================================
}`;
/**
* Merges an array of refs into a single memoized callback ref or `null`.
* @see https://floating-ui.com/docs/react-utils#usemergerefs
*/
function useMergeRefs<Instance>(
refs: Array<React.Ref<Instance> | undefined>,
): null | React.Ref<Instance> {
const cleanupRef = React.useRef<void | (() => void)>(undefined);
const refEffect = React.useCallback((instance: Instance | null) => {
const cleanups = refs.map((ref) => {
if (ref == null) {
return;
}
if (typeof ref === "function") {
const refCallback = ref;
const refCleanup: void | (() => void) = refCallback(instance);
return typeof refCleanup === "function"
? refCleanup
: () => {
refCallback(null);
};
}
(ref as React.RefObject<Instance | null>).current = instance;
return () => {
(ref as React.RefObject<Instance | null>).current = null;
};
});
return () => {
cleanups.forEach((refCleanup) => refCleanup?.());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
}, refs);
return React.useMemo(() => {
if (refs.every((ref) => ref == null)) {
return null;
}
return (value) => {
if (cleanupRef.current) {
cleanupRef.current();
(cleanupRef as React.RefObject<void | (() => void)>).current =
undefined;
}
if (value != null) {
(cleanupRef as React.RefObject<void | (() => void)>).current =
refEffect(value);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
}, refs);
}
const DEFAULT_MAX_PIXEL_COUNT: number = 1920 * 1080 * 4;
class ShaderMountVanilla {
public parentElement: PaperShaderElement;
public canvasElement: HTMLCanvasElement;
private gl: WebGL2RenderingContext;
private program: WebGLProgram | null = null;
private uniformLocations: Record<string, WebGLUniformLocation | null> = {};
/** The fragment shader that we are using */
private fragmentShader: string;
/** Stores the RAF for the render loop */
private rafId: number | null = null;
/** Time of the last rendered frame */
private lastRenderTime = 0;
/** Total time that we have played any animation, passed as a uniform to the shader for time-based VFX */
private currentFrame = 0;
/** The current speed that we progress through animation time (multiplies by delta time every update). Allows negatives to play in reverse. If set to 0, rAF will stop entirely so static shaders have no recurring performance costs */
private speed = 0;
/** Uniforms that are provided by the user for the specific shader being mounted (not including uniforms that this Mount adds, like time and resolution) */
private providedUniforms: ShaderMountUniforms;
/** Just a sanity check to make sure frames don't run after we're disposed */
private hasBeenDisposed = false;
/** If the resolution of the canvas has changed since the last render */
private resolutionChanged = true;
/** Store textures that are provided by the user */
private textures: Map<string, WebGLTexture> = new Map();
private minPixelRatio;
private maxPixelCount;
private isSafari = isSafari();
private uniformCache: Record<string, unknown> = {};
private textureUnitMap: Map<string, number> = new Map();
constructor(
/** The div you'd like to mount the shader to. The shader will match its size. */
parentElement: HTMLElement,
fragmentShader: string,
uniforms: ShaderMountUniforms,
webGlContextAttributes?: WebGLContextAttributes,
/** The speed of the animation, or 0 to stop it. Supports negative values to play in reverse. */
speed = 0,
/** Pass a frame to offset the starting u_time value and give deterministic results*/
frame = 0,
/**
* The minimum pixel ratio to render at, defaults to 2.
* May be reduced to improve performance or increased together with `maxPixelCount` to improve antialiasing.
*/
minPixelRatio = 2,
/**
* The maximum amount of physical device pixels to render for the shader,
* by default it's 1920 * 1080 * 2x dpi (per each side) = 8,294,400 pixels of a 4K screen.
* Actual DOM size of the canvas can be larger, it will just lose quality after this.
*
* May be reduced to improve performance or increased to improve quality on high-resolution screens.
*/
maxPixelCount: number = DEFAULT_MAX_PIXEL_COUNT,
) {
if (parentElement instanceof HTMLElement) {
this.parentElement = parentElement as PaperShaderElement;
} else {
throw new Error("Paper Shaders: parent element must be an HTMLElement");
}
if (!document.querySelector("style[data-paper-shader]")) {
const styleElement = document.createElement("style");
styleElement.innerHTML = defaultStyle;
styleElement.setAttribute("data-paper-shader", "");
document.head.prepend(styleElement);
}
// Create the canvas element and mount it into the provided element
const canvasElement = document.createElement("canvas");
this.canvasElement = canvasElement;
this.parentElement.prepend(canvasElement);
this.fragmentShader = fragmentShader;
this.providedUniforms = uniforms;
// Base our starting animation time on the provided frame value
this.currentFrame = frame;
this.minPixelRatio = minPixelRatio;
this.maxPixelCount = maxPixelCount;
const gl = canvasElement.getContext("webgl2", webGlContextAttributes);
if (!gl) {
throw new Error("Paper Shaders: WebGL is not supported in this browser");
}
this.gl = gl;
this.initProgram();
this.setupPositionAttribute();
// Grab the locations of the uniforms in the fragment shader
this.setupUniforms();
// Put the user provided values into the uniforms
this.setUniformValues(this.providedUniforms);
// Set up the resize observer to handle window resizing and set u_resolution
this.setupResizeObserver();
// Set the animation speed after everything is ready to go
this.setSpeed(speed);
// Mark parent element as paper shader mount
this.parentElement.setAttribute("data-paper-shader", "");
// Add the shaderMount instance to the div mount element to make it easily accessible
this.parentElement.paperShaderMount = this;
}
private initProgram = () => {
const program = createProgram(
this.gl,
vertexShaderSource,
this.fragmentShader,
);
if (!program) return;
this.program = program;
};
private setupPositionAttribute = () => {
const positionAttributeLocation = this.gl.getAttribLocation(
this.program!,
"a_position",
);
const positionBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
this.gl.bufferData(
this.gl.ARRAY_BUFFER,
new Float32Array(positions),
this.gl.STATIC_DRAW,
);
this.gl.enableVertexAttribArray(positionAttributeLocation);
this.gl.vertexAttribPointer(
positionAttributeLocation,
2,
this.gl.FLOAT,
false,
0,
0,
);
};
private setupUniforms = () => {
// Create a map to store all uniform locations
const uniformLocations: Record<string, WebGLUniformLocation | null> = {
u_time: this.gl.getUniformLocation(this.program!, "u_time"),
u_pixelRatio: this.gl.getUniformLocation(this.program!, "u_pixelRatio"),
u_resolution: this.gl.getUniformLocation(this.program!, "u_resolution"),
};
// Add locations for all provided uniforms
Object.entries(this.providedUniforms).forEach(([key, value]) => {
uniformLocations[key] = this.gl.getUniformLocation(this.program!, key);
// For texture uniforms, also look for the aspect ratio uniform
if (value instanceof HTMLImageElement) {
const aspectRatioUniformName = `${key}AspectRatio`;
uniformLocations[aspectRatioUniformName] = this.gl.getUniformLocation(
this.program!,
aspectRatioUniformName,
);
}
});
this.uniformLocations = uniformLocations;
};
/**
* The scale that we should render at.
* - Used to target 2x rendering even on 1x screens for better antialiasing
* - Prevents the virtual resolution from going beyond the maximum resolution
* - Accounts for the page zoom level so we render in physical device pixels rather than CSS pixels
*/
private renderScale = 1;
private parentWidth = 0;
private parentHeight = 0;
private resizeObserver: ResizeObserver | null = null;
private setupResizeObserver = () => {
this.resizeObserver = new ResizeObserver(([entry]) => {
if (entry?.borderBoxSize[0]) {
this.parentWidth = entry.borderBoxSize[0].inlineSize;
this.parentHeight = entry.borderBoxSize[0].blockSize;
}
this.handleResize();
});
this.resizeObserver.observe(this.parentElement);
visualViewport?.addEventListener("resize", this.handleVisualViewportChange);
const rect = this.parentElement.getBoundingClientRect();
this.parentWidth = rect.width;
this.parentHeight = rect.height;
this.handleResize();
};
// Visual viewport resize handler, mainly used to react to browser zoom changes.
// Wait 2 frames to align with when the resize observer callback is done (in case it might follow):
// - Frame 1: a paint after the visual viewport resize
// - Frame 2: a paint after the resize observer has been handled, if it was ever triggered
//
// Both resize observer and visual viewport will react to classic browser zoom changes,
// so we dedupe the callbacks, but pinch zoom only triggers the visual viewport handler.
private resizeRafId: number | null = null;
private handleVisualViewportChange = () => {
if (this.resizeRafId !== null) {
cancelAnimationFrame(this.resizeRafId);
}
this.resizeRafId = requestAnimationFrame(() => {
this.resizeRafId = requestAnimationFrame(() => {
this.handleResize();
});
});
};
/** Resize handler for when the container div changes size or the max pixel count changes and we want to resize our canvas to match */
private handleResize = () => {
// Cancel any scheduled resize handlers
if (this.resizeRafId !== null) {
cancelAnimationFrame(this.resizeRafId);
}
const pinchZoom = visualViewport?.scale ?? 1;
// Zoom level can be calculated comparing the browser's outerWidth and the viewport width.
// Note: avoid innerWidth, use visualViewport.width instead.
// - innerWidth is affected by pinch zoom in Safari, but not other browsers.
// visualViewport.width works consistently in all browsers.
// - innerWidth is rounded to integer, but not visualViewport.width.
// - visualViewport.width is affected by hard scrollbars, so they need to be added manually
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
const innerWidth = visualViewport
? visualViewport.scale * visualViewport.width + scrollbarWidth
: window.innerWidth;
// Slight rounding here helps the <canvas> maintain a consistent computed size as the zoom level changes
const classicZoom =
Math.round((10000 * window.outerWidth) / innerWidth) / 10000;
// As of 2025, Safari reports physical devicePixelRatio, but other browsers add the current zoom level
// https://bugs.webkit.org/show_bug.cgi?id=124862
const realPixelRatio = this.isSafari
? devicePixelRatio
: devicePixelRatio / classicZoom;
const targetPixelRatio = Math.max(realPixelRatio, this.minPixelRatio);
const targetRenderScale = targetPixelRatio * classicZoom * pinchZoom;
const targetPixelWidth = this.parentWidth * targetRenderScale;
const targetPixelHeight = this.parentHeight * targetRenderScale;
// Prevent the total rendered pixel count from exceeding maxPixelCount
const maxPixelCountHeadroom =
Math.sqrt(this.maxPixelCount) /
Math.sqrt(targetPixelWidth * targetPixelHeight);
const newRenderScale =
targetRenderScale * Math.min(1, maxPixelCountHeadroom);
const newWidth = Math.round(this.parentWidth * newRenderScale);
const newHeight = Math.round(this.parentHeight * newRenderScale);
if (
this.canvasElement.width !== newWidth ||
this.canvasElement.height !== newHeight ||
this.renderScale !== newRenderScale // Usually, only render scale change when the user zooms in/out
) {
this.renderScale = newRenderScale;
this.canvasElement.width = newWidth;
this.canvasElement.height = newHeight;
this.resolutionChanged = true;
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height);
// this is necessary to avoid flashes while resizing (the next scheduled render will set uniforms)
this.render(performance.now());
}
};
private render = (currentTime: number) => {
if (this.hasBeenDisposed) return;
if (this.program === null) {
console.warn("Tried to render before program or gl was initialized");
return;
}
// Calculate the delta time
const dt = currentTime - this.lastRenderTime;
this.lastRenderTime = currentTime;
// Increase the total animation time by dt * animationSpeed
if (this.speed !== 0) {
this.currentFrame += dt * this.speed;
}
// Clear the canvas
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
// Update uniforms
// biome-ignore lint/correctness/useHookAtTopLevel: asd
this.gl.useProgram(this.program);
// Update the time uniform
this.gl.uniform1f(this.uniformLocations.u_time!, this.currentFrame * 0.001);
// If the resolution has changed, we need to update the uniform
if (this.resolutionChanged) {
this.gl.uniform2f(
this.uniformLocations.u_resolution!,
this.gl.canvas.width,
this.gl.canvas.height,
);
this.gl.uniform1f(this.uniformLocations.u_pixelRatio!, this.renderScale);
this.resolutionChanged = false;
}
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
// Loop if we're animating
if (this.speed !== 0) {
this.requestRender();
} else {
this.rafId = null;
}
};
private requestRender = () => {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(this.render);
};
/** Creates a texture from an image and sets it into a uniform value */
private setTextureUniform = (
uniformName: string,
image: HTMLImageElement,
): void => {
if (!image.complete || image.naturalWidth === 0) {
throw new Error(
`Paper Shaders: image for uniform ${uniformName} must be fully loaded`,
);
}
// Clean up existing texture if present
const existingTexture = this.textures.get(uniformName);
if (existingTexture) {
this.gl.deleteTexture(existingTexture);
}
// Get texture unit
if (!this.textureUnitMap.has(uniformName)) {
this.textureUnitMap.set(uniformName, this.textureUnitMap.size);
}
const textureUnit = this.textureUnitMap.get(uniformName)!;
// Activate correct texture unit before creating the texture
this.gl.activeTexture(this.gl.TEXTURE0 + textureUnit);
// Create and set up the new texture
const texture = this.gl.createTexture();
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
// Set texture parameters
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_S,
this.gl.CLAMP_TO_EDGE,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_T,
this.gl.CLAMP_TO_EDGE,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_MIN_FILTER,
this.gl.LINEAR,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_MAG_FILTER,
this.gl.LINEAR,
);
// Upload image to texture
this.gl.texImage2D(
this.gl.TEXTURE_2D,
0,
this.gl.RGBA,
this.gl.RGBA,
this.gl.UNSIGNED_BYTE,
image,
);
const error = this.gl.getError();
if (error !== this.gl.NO_ERROR || texture === null) {
console.error(
"Paper Shaders: WebGL error when uploading texture:",
error,
);
return;
}
// Store the texture
this.textures.set(uniformName, texture);
// Set up texture unit and uniform
const location = this.uniformLocations[uniformName];
if (location) {
this.gl.uniform1i(location, textureUnit);
// Calculate and set the aspect ratio uniform
const aspectRatioUniformName = `${uniformName}AspectRatio`;
const aspectRatioLocation = this.uniformLocations[aspectRatioUniformName];
if (aspectRatioLocation) {
const aspectRatio = image.naturalWidth / image.naturalHeight;
this.gl.uniform1f(aspectRatioLocation, aspectRatio);
}
}
};
/** Utility: recursive equality test for all the uniforms */
private areUniformValuesEqual = (a: any, b: any): boolean => {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
return a.every((val, i) =>
this.areUniformValuesEqual(val, (b as any)[i]),
);
}
return false;
};
/** Sets the provided uniform values into the WebGL program, can be a partial list of uniforms that have changed */
private setUniformValues = (updatedUniforms: ShaderMountUniforms) => {
this.gl.useProgram(this.program);
Object.entries(updatedUniforms).forEach(([key, value]) => {
// Grab the value to use in the uniform cache
let cacheValue: ShaderMountUniforms[keyof ShaderMountUniforms] | string =
value;
if (value instanceof HTMLImageElement) {
// Images use their src for the cache value to save memory
cacheValue = `${value.src.slice(0, 200)}|${value.naturalWidth}x${value.naturalHeight}`;
}
// Check if the uniform value has changed and, if not, bail early to avoid extra work
if (this.areUniformValuesEqual(this.uniformCache[key], cacheValue))
return;
// Update the uniform cache if we are still here
this.uniformCache[key] = cacheValue;
const location = this.uniformLocations[key];
if (!location) {
console.warn(`Uniform location for ${key} not found`);
return;
}
if (value instanceof HTMLImageElement) {
// Texture case, requires a good amount of code so it gets its own function:
this.setTextureUniform(key, value);
} else if (Array.isArray(value)) {
// Array case
let flatArray: number[] | null = null;
let valueLength: number | null = null;
// If it's an array of same-sized arrays, flatten it down so we can set the uniform
if (value[0] !== undefined && Array.isArray(value[0])) {
const firstChildLength = value[0].length;
if (
value.every((arr) => (arr as number[]).length === firstChildLength)
) {
// Array of same-sized arrays case, flattens the array sets it
flatArray = value.flat();
valueLength = firstChildLength;
} else {
console.warn(`All child arrays must be the same length for ${key}`);
return;
}
} else {
// Array of primitive values case, supports 2, 3, 4, 9, 16 length arrays
flatArray = value as number[];
valueLength = flatArray.length;
}
// Set the uniform based on array length... supports 2, 3, 4, 9, 16 length arrays of primitive values
// or arbitrary length arrays of arrays
switch (valueLength) {
case 2:
this.gl.uniform2fv(location, flatArray);
break;
case 3:
this.gl.uniform3fv(location, flatArray);
break;
case 4:
this.gl.uniform4fv(location, flatArray);
break;
case 9:
this.gl.uniformMatrix3fv(location, false, flatArray);
break;
case 16:
this.gl.uniformMatrix4fv(location, false, flatArray);
break;
default:
console.warn(`Unsupported uniform array length: ${valueLength}`);
}
} else if (typeof value === "number") {
// Number case, supports floats and ints
this.gl.uniform1f(location, value);
} else if (typeof value === "boolean") {
// Boolean case, supports true and false
this.gl.uniform1i(location, value ? 1 : 0);
} else {
// May happen on the server for SSR when undefined images are passed in
console.warn(`Unsupported uniform type for ${key}: ${typeof value}`);
}
});
};
/** Gets the current total animation time from 0ms */
public getCurrentFrame = (): number => {
return this.currentFrame;
};
/** Set a frame to get a deterministic result, frames are literally just milliseconds from zero since the animation started */
public setFrame = (newFrame: number): void => {
this.currentFrame = newFrame;
this.lastRenderTime = performance.now();
this.render(performance.now());
};
/** Set an animation speed (or 0 to stop animation) */
public setSpeed = (newSpeed: number = 1): void => {
// Set the new animation speed
this.speed = newSpeed;
if (this.rafId === null && newSpeed !== 0) {
// Moving from 0 to animating, kick off a new rAF loop
this.lastRenderTime = performance.now();
this.rafId = requestAnimationFrame(this.render);
}
if (this.rafId !== null && newSpeed === 0) {
// Moving from animating to not animating, cancel the rAF loop
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
};
/** Set the maximum pixel count for the shader, this will limit the number of pixels that will be rendered */
public setMaxPixelCount = (
newMaxPixelCount: number = DEFAULT_MAX_PIXEL_COUNT,
): void => {
this.maxPixelCount = newMaxPixelCount;
this.handleResize();
};
/** Set the minimum pixel ratio for the shader */
public setMinPixelRatio = (newMinPixelRatio: number = 2): void => {
this.minPixelRatio = newMinPixelRatio;
this.handleResize();
};
/** Update the uniforms that are provided by the outside shader, can be a partial set with only the uniforms that have changed */
public setUniforms = (newUniforms: ShaderMountUniforms): void => {
this.setUniformValues(newUniforms);
this.providedUniforms = { ...this.providedUniforms, ...newUniforms };
this.render(performance.now());
};
/** Dispose of the shader mount, cleaning up all of the WebGL resources */
public dispose = (): void => {
// Immediately mark as disposed to prevent future renders from leaking in
this.hasBeenDisposed = true;
// Cancel the rAF loop
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.gl && this.program) {
// Clean up all textures
this.textures.forEach((texture) => {
this.gl.deleteTexture(texture);
});
this.textures.clear();
this.gl.deleteProgram(this.program);
this.program = null;
// Reset the WebGL context
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, null);
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
// Clear any errors
this.gl.getError();
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
visualViewport?.removeEventListener(
"resize",
this.handleVisualViewportChange,
);
this.uniformLocations = {};
// Remove the shader mount from the div wrapper element to avoid any GC issues
this.parentElement.paperShaderMount = undefined;
};
}
function createShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(
"An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader),
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(
gl: WebGL2RenderingContext,
vertexShaderSource: string,
fragmentShaderSource: string,
): WebGLProgram | null {
const format = gl.getShaderPrecisionFormat(
gl.FRAGMENT_SHADER,
gl.MEDIUM_FLOAT,
);
const precision = format ? format.precision : null;
// MEDIUM_FLOAT precision can be 10, 16 or 23 bits depending on device;
// Shaders fail on 10 bit (and 16 bit is hard to test) => we force 23-bit by switching to highp
if (precision && precision < 23) {
vertexShaderSource = vertexShaderSource.replace(
/precision\s+(lowp|mediump)\s+float;/g,
"precision highp float;",
);
fragmentShaderSource = fragmentShaderSource
.replace(/precision\s+(lowp|mediump)\s+float/g, "precision highp float")
.replace(
/\b(uniform|varying|attribute)\s+(lowp|mediump)\s+float/g,
"$1 highp float",
);
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(
gl,
gl.FRAGMENT_SHADER,
fragmentShaderSource,
);
if (!vertexShader || !fragmentShader) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(
"Unable to initialize the shader program: " +
gl.getProgramInfoLog(program),
);
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
// Clean up shaders after successful linking
gl.detachShader(program, vertexShader);
gl.detachShader(program, fragmentShader);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return program;
}
const defaultStyle = `@layer paper-shaders {
:where([data-paper-shader]) {
isolation: isolate;
position: relative;
& canvas {
contain: strict;
display: block;
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
border-radius: inherit;
}
}
}`;
/** The parent `<div>` element that has a ShaderMount available on it */
interface PaperShaderElement extends HTMLElement {
paperShaderMount: ShaderMountVanilla | undefined;
}
/**
* Uniform types that we support to be auto-mapped into the fragment shader
*
* We accept undefined as a convenience for server rendering, when some things may be undefined
* We just skip setting the uniform if it's undefined. This allows the shader mount to still take up space during server rendering
*/
export interface ShaderMountUniforms {
[key: string]:
| boolean
| number
| number[]
| number[][]
| HTMLImageElement
| undefined;
}
export interface ShaderMotionParams {
speed?: number;
frame?: number;
}
export type ShaderPreset<T> = {
name: string;
params: Required<T>;
};
export function isSafari() {
const ua = navigator.userAgent.toLowerCase();
return (
ua.includes("safari") && !ua.includes("chrome") && !ua.includes("android")
);
}
/**
* React Shader Mount can also accept strings as uniform values, which will assumed to be URLs and loaded as images
*
* We accept undefined as a convenience for server rendering, when some things may be undefined
* We just skip setting the uniform if it's undefined. This allows the shader mount to still take up space during server rendering
*/
interface ShaderMountUniformsReact {
[key: string]:
| string
| boolean
| number
| number[]
| number[][]
| HTMLImageElement
| undefined;
}
export interface ShaderMountProps
extends Omit<React.ComponentProps<"div">, "color" | "ref">,
ShaderMotionParams {
ref?: React.Ref<PaperShaderElement>;
fragmentShader: string;
uniforms: ShaderMountUniformsReact;
minPixelRatio?: number;
maxPixelCount?: number;
webGlContextAttributes?: WebGLContextAttributes;
}
export interface ShaderComponentProps
extends Omit<React.ComponentProps<"div">, "color" | "ref"> {
ref?: React.Ref<PaperShaderElement>;
minPixelRatio?: number;
maxPixelCount?: number;
webGlContextAttributes?: WebGLContextAttributes;
}
/** Parse the provided uniforms, turning URL strings into loaded images */
async function processUniforms(
uniformsProp: ShaderMountUniformsReact,
): Promise<ShaderMountUniforms> {
const processedUniforms = {} as ShaderMountUniforms;
const imageLoadPromises: Promise<void>[] = [];
const isValidUrl = (url: string): boolean => {
try {
// Handle absolute paths
if (url.startsWith("/")) return true;
// Check if it's a valid URL
new URL(url);
return true;
} catch {
return false;
}
};
const isExternalUrl = (url: string): boolean => {
try {
if (url.startsWith("/")) return false;
const urlObject = new URL(url, window.location.origin);
return urlObject.origin !== window.location.origin;
} catch {
return false;
}
};
Object.entries(uniformsProp).forEach(([key, value]) => {
if (typeof value === "string") {
// Make sure the provided string is a valid URL or just skip trying to set this uniform entirely
if (!isValidUrl(value)) {
console.warn(
`Uniform "${key}" has invalid URL "${value}". Skipping image loading.`,
);
return;
}
const imagePromise = new Promise<void>((resolve, reject) => {
const img = new Image();
if (isExternalUrl(value)) {
img.crossOrigin = "anonymous";
}
img.onload = () => {
processedUniforms[key] = img;
resolve();
};
img.onerror = () => {
console.error(
`Could not set uniforms. Failed to load image at ${value}`,
);
reject();
};
img.src = value;
});
imageLoadPromises.push(imagePromise);
} else {
processedUniforms[key] = value;
}
});
await Promise.all(imageLoadPromises);
return processedUniforms;
}
export type ShaderProps = ShaderMountProps;
/**
* A React component that mounts a shader and updates its uniforms as the component's props change
* If you pass a string as a uniform value, it will be assumed to be a URL and attempted to be loaded as an image
*/
export const Shader: React.FC<ShaderMountProps> = forwardRef<
PaperShaderElement,
ShaderMountProps
>(function ShaderMountImpl(
{
fragmentShader,
uniforms: uniformsProp,
webGlContextAttributes,
speed = 0,
frame = 0,
minPixelRatio,
maxPixelCount,
...divProps
},
forwardedRef,
) {
const [isInitialized, setIsInitialized] = useState(false);
const divRef = useRef<PaperShaderElement>(null);
const shaderMountRef: React.RefObject<ShaderMountVanilla | null> =
useRef<ShaderMountVanilla>(null);
// Initialize the ShaderMountVanilla
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
const initShader = async () => {
const uniforms = await processUniforms(uniformsProp);
if (divRef.current && !shaderMountRef.current) {
shaderMountRef.current = new ShaderMountVanilla(
divRef.current,
fragmentShader,
uniforms,
webGlContextAttributes,
speed,
frame,
minPixelRatio,
maxPixelCount,
);
setIsInitialized(true);
}
};
initShader();
return () => {
shaderMountRef.current?.dispose();
shaderMountRef.current = null;
};
}, [fragmentShader, webGlContextAttributes]);
// Uniforms
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
const updateUniforms = async () => {
const uniforms = await processUniforms(uniformsProp);
shaderMountRef.current?.setUniforms(uniforms);
};
updateUniforms();
}, [uniformsProp, isInitialized]);
// Speed
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
shaderMountRef.current?.setSpeed(speed);
}, [speed, isInitialized]);
// Max Pixel Count
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
shaderMountRef.current?.setMaxPixelCount(maxPixelCount);
}, [maxPixelCount, isInitialized]);
// Min Pixel Ratio
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
shaderMountRef.current?.setMinPixelRatio(minPixelRatio);
}, [minPixelRatio, isInitialized]);
// Frame
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
shaderMountRef.current?.setFrame(frame);
}, [frame, isInitialized]);
const mergedRef = useMergeRefs([
divRef,
forwardedRef,
]) as unknown as React.RefObject<HTMLDivElement>;
const {
// @ts-ignore
offsetX,
// @ts-ignore
offsetY,
// @ts-ignore
originX,
// @ts-ignore
originY,
// @ts-ignore
worldWidth,
// @ts-ignore
worldHeight,
...restDivProps
} = divProps;
return <div ref={mergedRef} {...restDivProps} />;
});
Shader.displayName = "Shader";
export type vec4 = [number, number, number, number];
Also save the shader-utils.ts
file to your project.
// language=GLSL
export const declarePI = `
#define TWO_PI 6.28318530718
#define PI 3.14159265358979323846
`;
// language=GLSL
export const declareRotate = `
vec2 rotate(vec2 uv, float th) {
return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv;
}
`;
// language=GLSL
export const declareRandom = `
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
`;
// language=GLSL
export const declareValueNoise = `
float valueNoise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
float x1 = mix(a, b, u.x);
float x2 = mix(c, d, u.x);
return mix(x1, x2, u.y);
}
`;
// It does use the standard random function but we don't call it to keep
// colorBandingFix insertion independent from declareRandom
export const colorBandingFix = `
color += 1. / 256. * (fract(sin(dot(.014 * gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453123) - .5);
`;
// language=GLSL
export const declareSimplexNoise = `
vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy),
dot(x12.zw, x12.zw)), 0.0);
m = m * m;
m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
`;
// language=GLSL
export const declareFiberNoise = `
float fiberRandom(vec2 p) {
vec2 uv = floor(p) / 100.;
return texture(u_noiseTexture, fract(uv)).b;
}
float fiberValueNoise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = fiberRandom(i);
float b = fiberRandom(i + vec2(1.0, 0.0));
float c = fiberRandom(i + vec2(0.0, 1.0));
float d = fiberRandom(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
float x1 = mix(a, b, u.x);
float x2 = mix(c, d, u.x);
return mix(x1, x2, u.y);
}
float fiberNoiseFbm(in vec2 n, vec2 seedOffset) {
float total = 0.0, amplitude = 1.;
for (int i = 0; i < 4; i++) {
n = rotate(n, .7);
total += fiberValueNoise(n + seedOffset) * amplitude;
n *= 2.;
amplitude *= 0.6;
}
return total;
}
float fiberNoise(vec2 uv, vec2 seedOffset) {
float epsilon = 0.001;
float n1 = fiberNoiseFbm(uv + vec2(epsilon, 0.0), seedOffset);
float n2 = fiberNoiseFbm(uv - vec2(epsilon, 0.0), seedOffset);
float n3 = fiberNoiseFbm(uv + vec2(0.0, epsilon), seedOffset);
float n4 = fiberNoiseFbm(uv - vec2(0.0, epsilon), seedOffset);
return length(vec2(n1 - n2, n3 - n4)) / (2.0 * epsilon);
}
`;
// language=GLSL
export const declareImageFrame = `
float getUvFrame(vec2 uv) {
float aax = 2. * fwidth(uv.x);
float aay = 2. * fwidth(uv.y);
float left = smoothstep(0., aax, uv.x);
float right = smoothstep(1., 1. - aax, uv.x);
float bottom = smoothstep(0., aay, uv.y);
float top = smoothstep(1., 1. - aay, uv.y);
return left * right * bottom * top;
}
`;
/*
===================================================================
The space coordinates passed from the vertex shader.
`sizingVariablesDeclaration` must be included in every fragment
shader—unless we want to apply sizing to the coordinates directly
in the fragment shader.
So far, the only exception is when we need to apply pixelization
before sizing (e.g., to make dithering pixels independent of sizing).
*/
export const sizingVariablesDeclaration = `
in vec2 v_objectUV;
in vec2 v_responsiveUV;
in vec2 v_responsiveBoxGivenSize;
in vec2 v_patternUV;
in vec2 v_imageUV;`;
/*
===================================================================
Additional variables used only for drawing a sizing overlay.
*/
export const sizingDebugVariablesDeclaration = `
in vec2 v_objectBoxSize;
in vec2 v_objectHelperBox;
in vec2 v_responsiveBoxSize;
in vec2 v_responsiveHelperBox;
in vec2 v_patternBoxSize;
in vec2 v_patternHelperBox;`;
/*
===================================================================
In most cases, sizing UNIFORMS are used only in the vertex shader.
But there are cases where we need to pass them to the fragment shader
as well:
- dithering pixelization where all the sizing happens in fragment
- drawing the sizing helpers
*/
export const sizingUniformsDeclaration = `
uniform float u_originX;
uniform float u_originY;
uniform float u_worldWidth;
uniform float u_worldHeight;
uniform float u_fit;
uniform float u_scale;
uniform float u_rotation;
uniform float u_offsetX;
uniform float u_offsetY;`;
/*
===================================================================
In most cases, sizing TRANSFORMS are applied in the vertex shader.
For exceptions (e.g., dithering pixelization), the operations below
need to be included in the fragment shader instead.
The transforms are identical to those in the vertex shader,
except for the `USE_PIXELIZATION` part we insert at start.
*/
export const sizingUV = `
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
#ifdef USE_PIXELIZATION
float pxSize = u_pxSize * u_pixelRatio;
vec2 pxSizeUv = gl_FragCoord.xy;
pxSizeUv -= .5 * u_resolution;
pxSizeUv /= pxSize;
uv = floor(pxSizeUv) * pxSize / u_resolution.xy;
uv += .5;
#endif
uv -= .5;
// ===================================================
// sizing params shared between objects and patterns
vec2 boxOrigin = vec2(.5 - u_originX, u_originY - .5);
vec2 givenBoxSize = vec2(u_worldWidth, u_worldHeight);
givenBoxSize = max(givenBoxSize, vec2(1.)) * u_pixelRatio;
vec2 maxBoxSize = vec2(max(u_resolution.x, givenBoxSize.x), max(u_resolution.y, givenBoxSize.y));
float r = u_rotation * 3.14159265358979323846 / 180.;
mat2 graphicRotation = mat2(cos(r), sin(r), -sin(r), cos(r));
vec2 graphicOffset = vec2(-u_offsetX, u_offsetY);
// ===================================================
// Sizing api for objects (graphics with fixed ratio)
#ifdef USE_OBJECT_SIZING
float fixedRatio = 1.;
vec2 fixedRatioBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
vec2 objectBoxSize = vec2(0.);
// fit = none
objectBoxSize.x = fixedRatio * min(fixedRatioBoxGivenSize.x / fixedRatio, fixedRatioBoxGivenSize.y);
if (u_fit == 1.) { // fit = contain
objectBoxSize.x = fixedRatio * min(maxBoxSize.x / fixedRatio, maxBoxSize.y);
} else if (u_fit == 2.) { // fit = cover
objectBoxSize.x = fixedRatio * max(maxBoxSize.x / fixedRatio, maxBoxSize.y);
}
objectBoxSize.y = objectBoxSize.x / fixedRatio;
vec2 objectWorldScale = u_resolution.xy / objectBoxSize;
#ifdef ADD_HELPERS
vec2 objectHelperBox = gl_FragCoord.xy / u_resolution.xy;
objectHelperBox -= .5;
objectHelperBox *= objectWorldScale;
objectHelperBox += boxOrigin * (objectWorldScale - 1.);
#endif
vec2 objectUV = uv;
objectUV *= objectWorldScale;
objectUV += boxOrigin * (objectWorldScale - 1.);
objectUV += vec2(-u_offsetX, u_offsetY);
objectUV /= u_scale;
objectUV = graphicRotation * objectUV;
#endif
// ===================================================
// ===================================================
// Sizing api for patterns (graphics respecting u_worldWidth / u_worldHeight ratio)
#ifdef USE_PATTERN_SIZING
float patternBoxRatio = givenBoxSize.x / givenBoxSize.y;
vec2 patternBoxGivenSize = vec2(
(u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x,
(u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y
);
vec2 patternBoxSize = vec2(0.);
// fit = none
patternBoxSize.x = patternBoxRatio * min(patternBoxGivenSize.x / patternBoxRatio, patternBoxGivenSize.y);
float patternWorldNoFitBoxWidth = patternBoxSize.x;
if (u_fit == 1.) { // fit = contain
patternBoxSize.x = patternBoxRatio * min(maxBoxSize.x / patternBoxRatio, maxBoxSize.y);
} else if (u_fit == 2.) { // fit = cover
patternBoxSize.x = patternBoxRatio * max(maxBoxSize.x / patternBoxRatio, maxBoxSize.y);
}
patternBoxSize.y = patternBoxSize.x / patternBoxRatio;
vec2 patternWorldScale = u_resolution.xy / patternBoxSize;
#ifdef ADD_HELPERS
vec2 patternHelperBox = gl_FragCoord.xy / u_resolution.xy;
patternHelperBox -= .5;
patternHelperBox *= patternWorldScale;
patternHelperBox += boxOrigin * (patternWorldScale - 1.);
#endif
vec2 patternUV = uv;
patternUV += vec2(-u_offsetX, u_offsetY) / patternWorldScale;
patternUV += boxOrigin;
patternUV -= boxOrigin / patternWorldScale;
patternUV *= u_resolution.xy;
patternUV /= u_pixelRatio;
if (u_fit > 0.) {
patternUV *= (patternWorldNoFitBoxWidth / patternBoxSize.x);
}
patternUV /= u_scale;
patternUV = graphicRotation * patternUV;
patternUV += boxOrigin / patternWorldScale;
patternUV -= boxOrigin;
patternUV += .5;
#endif
// ===================================================
// ===================================================
// Sizing api for image filters
#ifdef USE_IMAGE_SIZING
vec2 imageBoxSize;
if (u_fit == 1.) { // contain
imageBoxSize.x = min(maxBoxSize.x / u_imageAspectRatio, maxBoxSize.y) * u_imageAspectRatio;
} else if (u_fit == 2.) { // cover
imageBoxSize.x = max(maxBoxSize.x / u_imageAspectRatio, maxBoxSize.y) * u_imageAspectRatio;
} else {
imageBoxSize.x = min(10.0, 10.0 / u_imageAspectRatio * u_imageAspectRatio);
}
imageBoxSize.y = imageBoxSize.x / u_imageAspectRatio;
vec2 imageBoxScale = u_resolution.xy / imageBoxSize;
#ifdef ADD_HELPERS
vec2 imageHelperBox = uv;
imageHelperBox *= imageBoxScale;
imageHelperBox += boxOrigin * (imageBoxScale - 1.);
#endif
vec2 imageUV = uv;
imageUV *= imageBoxScale;
imageUV += boxOrigin * (imageBoxScale - 1.);
imageUV += graphicOffset;
imageUV /= u_scale;
imageUV.x *= u_imageAspectRatio;
imageUV = graphicRotation * imageUV;
imageUV.x /= u_imageAspectRatio;
imageUV += .5;
imageUV.y = 1. - imageUV.y;
#endif
`;
/*
===================================================================
Helpers used to draw the sizing (box, box origin and graphic origin)
over the shader. Can be used with both vertex sizing and vector sizing
helperBox and boxSize should be defined before inserting the code
*/
export const drawSizingHelpers = `
vec2 worldBoxDist = abs(helperBox);
float boxStroke = (step(max(worldBoxDist.x, worldBoxDist.y), .5) - step(max(worldBoxDist.x, worldBoxDist.y), .495));
color.rgb = mix(color.rgb, vec3(1., 0., 0.), boxStroke);
opacity += boxStroke;
vec2 boxOriginCopy = vec2(.5 - u_originX, u_originY - .5);
vec2 boxOriginDist = helperBox + boxOriginCopy;
boxOriginDist.x *= (boxSize.x / boxSize.y);
float boxOriginPoint = 1. - smoothstep(0., .05, length(boxOriginDist));
vec2 graphicOriginPointDist = helperBox + vec2(-u_offsetX, u_offsetY);
graphicOriginPointDist.x *= (boxSize.x / boxSize.y);
float graphicOriginPoint = 1. - smoothstep(0., .05, length(graphicOriginPointDist));
color.rgb = mix(color.rgb, vec3(0., 1., 0.), boxOriginPoint);
opacity += boxOriginPoint;
color.rgb = mix(color.rgb, vec3(0., 0., 1.), graphicOriginPoint);
opacity += graphicOriginPoint;
`;
export interface ShaderSizingUniforms {
u_fit: (typeof ShaderFitOptions)[ShaderFit];
u_scale: number;
u_rotation: number;
u_originX: number;
u_originY: number;
u_offsetX: number;
u_offsetY: number;
u_worldWidth: number;
u_worldHeight: number;
}
export interface ShaderSizingParams {
fit?: "none" | "contain" | "cover";
scale?: number;
rotation?: number;
originX?: number;
originY?: number;
offsetX?: number;
offsetY?: number;
worldWidth?: number;
worldHeight?: number;
}
export const defaultObjectSizing: Required<ShaderSizingParams> = {
fit: "contain",
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
originX: 0.5,
originY: 0.5,
worldWidth: 0,
worldHeight: 0,
};
export const defaultPatternSizing: Required<ShaderSizingParams> = {
fit: "none",
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
originX: 0.5,
originY: 0.5,
worldWidth: 0,
worldHeight: 0,
};
export const ShaderFitOptions = {
none: 0,
contain: 1,
cover: 2,
} as const;
export type ShaderFit = keyof typeof ShaderFitOptions;
interface PropsWithColors {
colors?: string[];
[key: string]: unknown;
}
export function colorPropsAreEqual(
prevProps: PropsWithColors,
nextProps: PropsWithColors,
): boolean {
for (const key in prevProps) {
if (key === "colors") {
const prevIsArray = Array.isArray(prevProps.colors);
const nextIsArray = Array.isArray(nextProps.colors);
if (!prevIsArray || !nextIsArray) {
if (Object.is(prevProps.colors, nextProps.colors) === false) {
return false;
}
continue;
}
if (prevProps.colors?.length !== nextProps.colors?.length) {
return false;
}
if (
!prevProps.colors?.every(
(color, index) => color === nextProps.colors?.[index],
)
) {
return false;
}
continue;
}
if (Object.is(prevProps[key], nextProps[key]) === false) {
return false;
}
}
return true;
}
/** Convert color string from HSL, RGB, or hex to 0-to-1-range-RGBA array */
export function getShaderColorFromString(
colorString:
| string
| [number, number, number]
| [number, number, number, number]
| undefined,
): [number, number, number, number] {
// If the color string is already an array of 3 or 4 numbers, return it (with alpha=1 if needed)
if (Array.isArray(colorString)) {
if (colorString.length === 4)
return colorString as [number, number, number, number];
if (colorString.length === 3) return [...colorString, 1];
return fallbackColor;
}
// If the color string is not a string, return the fallback
if (typeof colorString !== "string") {
return fallbackColor;
}
let r: number,
g: number,
b: number,
a = 1;
if (colorString.startsWith("#")) {
[r, g, b, a] = hexToRgba(colorString);
} else if (colorString.startsWith("rgb")) {
[r, g, b, a] = parseRgba(colorString);
} else if (colorString.startsWith("hsl")) {
[r, g, b, a] = hslaToRgba(parseHsla(colorString));
} else {
console.error("Unsupported color format", colorString);
return fallbackColor;
}
return [clamp(r, 0, 1), clamp(g, 0, 1), clamp(b, 0, 1), clamp(a, 0, 1)];
}
/** Convert hex to RGBA (0 to 1 range) */
function hexToRgba(hex: string): [number, number, number, number] {
// Remove # if present
hex = hex.replace(/^#/, "");
// Expand three-letter hex to six-letter
if (hex.length === 3) {
hex = hex
.split("")
.map((char) => char + char)
.join("");
}
// Expand six-letter hex to eight-letter (add full opacity if no alpha)
if (hex.length === 6) {
hex = hex + "ff";
}
// Parse the components
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const a = parseInt(hex.slice(6, 8), 16) / 255;
return [r, g, b, a];
}
/** Parse RGBA string to RGBA (0 to 1 range) */
function parseRgba(rgba: string): [number, number, number, number] {
// Match both rgb and rgba patterns
const match = rgba.match(
/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)$/i,
);
if (!match) return [0, 0, 0, 1];
return [
parseInt(match[1] ?? "0") / 255,
parseInt(match[2] ?? "0") / 255,
parseInt(match[3] ?? "0") / 255,
match[4] === undefined ? 1 : parseFloat(match[4]),
];
}
/** Parse HSLA string */
function parseHsla(hsla: string): [number, number, number, number] {
const match = hsla.match(
/^hsla?\s*\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([0-9.]+))?\s*\)$/i,
);
if (!match) return [0, 0, 0, 1];
return [
parseInt(match[1] ?? "0"),
parseInt(match[2] ?? "0"),
parseInt(match[3] ?? "0"),
match[4] === undefined ? 1 : parseFloat(match[4]),
];
}
/** Convert HSLA to RGBA (0 to 1 range) */
function hslaToRgba(
hsla: [number, number, number, number],
): [number, number, number, number] {
const [h, s, l, a] = hsla;
const hDecimal = h / 360;
const sDecimal = s / 100;
const lDecimal = l / 100;
let r, g, b;
if (s === 0) {
r = g = b = lDecimal; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q =
lDecimal < 0.5
? lDecimal * (1 + sDecimal)
: lDecimal + sDecimal - lDecimal * sDecimal;
const p = 2 * lDecimal - q;
r = hue2rgb(p, q, hDecimal + 1 / 3);
g = hue2rgb(p, q, hDecimal);
b = hue2rgb(p, q, hDecimal - 1 / 3);
}
return [r, g, b, a];
}
export const clamp = (n: number, min: number, max: number): number =>
Math.min(Math.max(n, min), max);
const fallbackColor = [0, 0, 0, 1] as [0, 0, 0, 1];
export const ShaderColorSpaces = {
rgb: 0,
oklch: 1,
} as const;
export type ShaderColorSpace = keyof typeof ShaderColorSpaces;
export const declareOklchTransforms = `
// magic numbers (and magic could be better tbh)
#define OKLCH_CHROMA_THRESHOLD .001
#define OKLCH_HUE_NEUTRALIZER - 2.
vec3 srgbToLinear(vec3 srgb) {
return pow(srgb, vec3(2.2));
}
vec3 linearToSrgb(vec3 linear) {
return pow(linear, vec3(1.0 / 2.2));
}
vec3 LrgbToOklab(vec3 rgb) {
float L = pow(0.4122214708 * rgb.r + 0.5363325363 * rgb.g + 0.0514459929 * rgb.b, 1.0 / 3.0);
float M = pow(0.2119034982 * rgb.r + 0.6806995451 * rgb.g + 0.1073969566 * rgb.b, 1.0 / 3.0);
float S = pow(0.0883024619 * rgb.r + 0.2817188376 * rgb.g + 0.6299787005 * rgb.b, 1.0 / 3.0);
return vec3(
0.2104542553 * L + 0.793617785 * M - 0.0040720468 * S,
1.9779984951 * L - 2.428592205 * M + 0.4505937099 * S,
0.0259040371 * L + 0.7827717662 * M - 0.808675766 * S
);
}
vec3 OklabToLrgb(vec3 oklab) {
float L = oklab.x;
float a = oklab.y;
float b = oklab.z;
float l_ = L + 0.3963377774 * a + 0.2158037573 * b;
float m_ = L - 0.1055613458 * a - 0.0638541728 * b;
float s_ = L - 0.0894841775 * a - 1.291485548 * b;
float l = l_ * l_ * l_;
float m = m_ * m_ * m_;
float s = s_ * s_ * s_;
return vec3(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
);
}
vec3 oklabToOklch(vec3 oklab) {
float C = length(oklab.yz);
float H = atan(oklab.z, oklab.y);
if (C < OKLCH_CHROMA_THRESHOLD) {
H = OKLCH_HUE_NEUTRALIZER;
}
return vec3(oklab.x, C, H);
}
vec3 oklchToOklab(vec3 oklch) {
float a = oklch.y * cos(oklch.z);
float b = oklch.y * sin(oklch.z);
return vec3(oklch.x, a, b);
}
float mixHue(float h1, float h2, float mixer) {
float delta = mod(h2 - h1 + PI, TWO_PI) - PI;
return h1 + mixer * delta;
}
vec3 srgbToOklab(vec3 rgb) {
return oklabToOklch(LrgbToOklab(srgbToLinear(rgb)));
}
vec3 oklabToSrgb(vec3 oklab) {
return linearToSrgb(OklabToLrgb(oklchToOklab(oklab)));
}
vec3 mixOklabVector(vec3 color1, vec3 color2, float mixer) {
color1.x = mix(color1.x, color2.x, mixer);
color1.y = mix(color1.y, color2.y, mixer);
if (color1.y > OKLCH_CHROMA_THRESHOLD && color2.y > OKLCH_CHROMA_THRESHOLD) {
color1.z = mixHue(color1.z, color2.z, mixer);
}
return color1;
}
`;
Last updated on