Mesh Gradient
Animated mesh gradient with distortion
Quick Start
import { createMeshGradient } from './webgpu-market/mesh-gradient/mesh-gradient';
const gradient = createMeshGradient(device, { format: 'rgba8unorm' });
// Each frame
gradient.render(targetTexture, {
time: elapsed,
colors: [
[0.9, 0.2, 0.3, 1],
[0.2, 0.5, 0.9, 1],
[0.1, 0.8, 0.4, 1],
],
distortion: 0.15,
});
gradient.destroy(); Source
// Mesh gradient — flowing composition of color spots with organic distortion
// Ported from paper-design/shaders (MIT)
struct Uniforms {
time: f32,
colors_count: f32,
distortion: f32,
swirl: f32,
_pad0: f32,
_pad1: f32,
_pad2: f32,
_pad3: f32,
colors: array<vec4<f32>, 10>,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
var pos = array<vec2<f32>, 6>(
vec2(-1.0, -1.0), vec2( 1.0, -1.0), vec2(-1.0, 1.0),
vec2(-1.0, 1.0), vec2( 1.0, -1.0), vec2( 1.0, 1.0),
);
var uvs = array<vec2<f32>, 6>(
vec2(0.0, 1.0), vec2(1.0, 1.0), vec2(0.0, 0.0),
vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(1.0, 0.0),
);
var out: VertexOutput;
out.position = vec4(pos[vi], 0.0, 1.0);
out.uv = uvs[vi];
return out;
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn rotate2(uv: vec2<f32>, th: f32) -> vec2<f32> {
let c = cos(th);
let s = sin(th);
return vec2(c * uv.x + s * uv.y, -s * uv.x + c * uv.y);
}
fn get_position(i: i32, t: f32) -> vec2<f32> {
let fi = f32(i);
let a = fi * 0.37;
let b = 0.6 + fract(fi / 3.0) * 0.9;
let c = 0.8 + fract(f32(i + 1) / 4.0);
let x = sin(t * b + a);
let y = cos(t * c + a * 1.5);
return 0.5 + 0.5 * vec2(x, y);
}
// ── Fragment ────────────────────────────────────────────────────────────
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
var uv = in.uv;
uv = uv - 0.5 + 0.5;
let first_frame_offset = 41.5;
let t = 0.5 * (u.time + first_frame_offset);
let radius = smoothstep(0.0, 1.0, length(uv - 0.5));
let center = 1.0 - radius;
// Organic distortion — sinusoidal waves modulated by position
for (var i = 1.0; i <= 2.0; i += 1.0) {
uv.x += u.distortion * center / i * sin(t + i * 0.4 * smoothstep(0.0, 1.0, uv.y)) * cos(0.2 * t + i * 2.4 * smoothstep(0.0, 1.0, uv.y));
uv.y += u.distortion * center / i * cos(t + i * 2.0 * smoothstep(0.0, 1.0, uv.x));
}
// Swirl distortion — rotational warp increasing toward edges
var uv_rotated = uv - vec2(0.5);
let angle = 3.0 * u.swirl * radius;
uv_rotated = rotate2(uv_rotated, -angle);
uv_rotated += vec2(0.5);
// Color blending — inverse-distance weighted mix of animated color spots
var color = vec3(0.0);
var opacity = 0.0;
var total_weight = 0.0;
let count = i32(u.colors_count);
for (var i = 0; i < 10; i++) {
if (i >= count) { break; }
let pos = get_position(i, t);
let col = u.colors[i];
let color_fraction = col.rgb * col.a;
let opacity_fraction = col.a;
let dist = length(uv_rotated - pos);
let dist_pow = pow(dist, 3.5);
let weight = 1.0 / (dist_pow + 1e-3);
color += color_fraction * weight;
opacity += opacity_fraction * weight;
total_weight += weight;
}
color /= max(1e-4, total_weight);
opacity /= max(1e-4, total_weight);
opacity = clamp(opacity, 0.0, 1.0);
return vec4(color, opacity);
}
import shaderCode from './mesh-gradient.wgsl?raw';
const MAX_COLORS = 10;
const UNIFORM_SIZE = 192; // 8 floats header (32 bytes) + 10 × vec4 (160 bytes)
export interface MeshGradientParams {
time?: number;
/** RGBA colors, each component 0–1. Up to 10 colors. */
colors?: [number, number, number, number][];
/** Organic noise distortion strength (0–1). @default 0.15 */
distortion?: number;
/** Vortex swirl strength (0–1). @default 0 */
swirl?: number;
}
export interface MeshGradientRenderer {
render(target: GPUTexture, params?: MeshGradientParams): void;
destroy(): void;
}
export function createMeshGradient(
device: GPUDevice,
options?: { format?: GPUTextureFormat }
): MeshGradientRenderer {
const format = options?.format ?? 'rgba8unorm';
const shaderModule = device.createShaderModule({ label: 'mesh-gradient', code: shaderCode });
const uniformBuffer = device.createBuffer({
label: 'mesh-gradient uniforms',
size: UNIFORM_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
const bgl = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX,
buffer: { type: 'uniform' }
}
]
});
const pipeline = device.createRenderPipeline({
label: 'mesh-gradient',
layout: device.createPipelineLayout({ bindGroupLayouts: [bgl] }),
vertex: { module: shaderModule, entryPoint: 'vs_main' },
fragment: { module: shaderModule, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'triangle-list' }
});
const bindGroup = device.createBindGroup({
layout: bgl,
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }]
});
let currentTime = 0;
let currentColors: [number, number, number, number][] = [
[1, 0, 0, 1],
[0, 1, 0, 1],
[0, 0, 1, 1],
[1, 1, 0, 1]
];
let currentDistortion = 0.15;
let currentSwirl = 0;
function writeUniforms() {
const data = new ArrayBuffer(UNIFORM_SIZE);
const f32 = new Float32Array(data);
f32[0] = currentTime;
f32[1] = Math.min(currentColors.length, MAX_COLORS);
f32[2] = currentDistortion;
f32[3] = currentSwirl;
const count = Math.min(currentColors.length, MAX_COLORS);
for (let i = 0; i < count; i++) {
const base = 8 + i * 4;
f32[base + 0] = currentColors[i][0];
f32[base + 1] = currentColors[i][1];
f32[base + 2] = currentColors[i][2];
f32[base + 3] = currentColors[i][3];
}
device.queue.writeBuffer(uniformBuffer, 0, data);
}
function render(target: GPUTexture, params?: MeshGradientParams) {
if (params?.time !== undefined) currentTime = params.time;
if (params?.colors !== undefined) currentColors = params.colors;
if (params?.distortion !== undefined) currentDistortion = params.distortion;
if (params?.swirl !== undefined) currentSwirl = params.swirl;
writeUniforms();
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: target.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store'
}
]
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(6);
pass.end();
device.queue.submit([encoder.finish()]);
}
function destroy() {
uniformBuffer.destroy();
}
return { render, destroy };
}
Documentation
mesh-gradient
Animated mesh gradient with distortion and swirl. Renders up to 10 color spots.
API
createMeshGradient(device, options?)
Creates a mesh gradient renderer.
device—GPUDeviceoptions.format— Output texture format. Default'rgba8unorm'.
Returns { render, destroy }.
render(target, params?)
Renders the gradient into a GPUTexture.
target— TheGPUTextureto render into.params.time— Animation time in seconds.params.colors— Array of[r, g, b, a]colors (0–1). Up to 10.params.distortion— Organic wave distortion strength. 0–1, default0.15.params.swirl— Vortex swirl strength. 0–1, default0.
Further Reading
Further Reading
Rationale
Mesh gradients (also called "aurora gradients" or "blob gradients") are everywhere in modern design — Apple, Linear, Stripe, Vercel. The effect is simple to describe (blended color spots that drift organically) but non-trivial to implement well on the GPU with smooth blending, distortion, and grain.
This module gives you a single-file implementation you can drop into any WebGPU project. Useful for backgrounds, hero sections, loading screens, or generative art.
Original Source
- paper-design/shaders — The shader this module is ported from, released under PolyForm Shield license. https://github.com/paper-design/shaders
How It Works
The shader places N color spots at animated positions (sinusoidal paths). For each pixel:
- Compute the inverse-distance weight to every color spot (
1 / dist^3.5). - Blend all colors by their weights (weighted average).
- Optionally apply organic distortion (sinusoidal UV warping) and vortex swirl (rotational warp that increases toward edges).
- Optionally overlay film grain using value noise.
The inverse-distance weighting is the same principle used in Shepard's interpolation (IDW), a technique from spatial statistics.
Further Learning
- Shepard's Method (Inverse Distance Weighting) — The mathematical foundation for the color blending approach. https://en.wikipedia.org/wiki/Inverse_distance_weighting
- The Book of Shaders: 2D Noise — Covers value noise and hash functions used in the grain generation. https://thebookofshaders.com/11/