mesh-gradient@0.1.0
Mesh gradient
Four color spots drifting on their own slow orbits, blended as a weighted OKLab sum so the midpoints stay alive. A center swirl and simplex distortion bend the domain into a silky, organic gradient, and a whisper of grain keeps the long ramps from banding.
Controls
Install
One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/mesh-gradient.json works with no setup). npm dependencies install alongside.
npx shadcn@latest add @vshaders/mesh-gradientFiles that land in your tree: shaders/mesh-gradient.wgsl, components/mesh-gradient.tsx, lib/run-effect.ts, lib/shared-gpu.ts, wgsl-env.d.ts.
The shader imports WGSL modules from npm, resolved by vgpu's loader. In next.config.ts, wire @vgpu/wgsl/loader-webpack for *.wgsl under both turbopack.rules and the webpack() hook, and keep the installed wgsl-env.d.ts at your project root so .wgsl imports typecheck. Loader setup: https://vgpu.sh/docs. Effect gallery and sources: https://vshaders.com. Color params are linear-light RGB, not sRGB hex/255: convert hex through the sRGB transfer function, or use Copy params in the live editor (https://vshaders.com/effects/mesh-gradient) for exact values.
Imports
What this entry shader composes; every module resolves from npm.
import { linearSrgbToOklab, oklabToLinearSrgb } from "@vshaders/color/oklab";
import { simplex2d } from "@vgpu/wgsl-std/noise/simplex";
import { rotate2d } from "@vgpu/wgsl-std/math";
import { hash2 } from "@vgpu/wgsl-std/hash";
import { linearToSrgb3 } from "@vgpu/wgsl-std/color";
Source
The full entry shader, mesh-gradient.wgsl. Copy it and it is yours.
// effect 001 · mesh gradient — flowing color spots under swirl and noise.
// Four color spots drift along their own slow orbits; every pixel blends
// them with inverse-square falloff, mixed as a weighted sum in OKLab so the
// midpoints stay alive instead of graying out. Before sampling, the domain
// is swirled around the center (stronger inside, dying toward the edges)
// and bent by simplex distortion, which is what turns radial blobs into a
// silky, organic gradient. A whisper of grain keeps the long ramps from
// banding. Works in linear light; encodes to sRGB once at the end.
import { linearSrgbToOklab, oklabToLinearSrgb } from "@vshaders/color/oklab";
import { simplex2d } from "@vgpu/wgsl-std/noise/simplex";
import { rotate2d } from "@vgpu/wgsl-std/math";
import { hash2 } from "@vgpu/wgsl-std/hash";
import { linearToSrgb3 } from "@vgpu/wgsl-std/color";
// Tunable members follow resolution/time. Defaults live in lib/effects.ts and
// must be set by the runner: an unset uniform member reads as zero. Members
// are ordered so each vec3f lands on a 16-byte boundary with an f32 in its pad.
struct Uniforms {
resolution: vec2f,
time: f32,
// How far the simplex field bends the sampling domain.
distortion: f32,
colorA: vec3f,
// Rotation of the domain around the center; decays toward the edges.
swirl: f32,
colorB: vec3f,
// Multiplies time in the drift, the swirl, and the distortion.
speed: f32,
colorC: vec3f,
// Film grain amount over the final image.
grain: f32,
colorD: vec3f,
// How many palette colors the spots cycle through, 1 to 4.
colorCount: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
// Sharpened inverse-power weight of a spot at `site` for the point `p`.
// The exponent keeps each color region holding its own ink, so the gradient
// reads as flowing zones instead of averaging into a single cream.
fn spotWeight(p: vec2f, site: vec2f) -> f32 {
let d = p - site;
return 1.0 / pow(dot(d, d) + 0.03, 1.5);
}
@fragment
fn main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let res = max(uniforms.resolution, vec2f(1.0));
let uv = position.xy / res;
let aspect = res.x / res.y;
let p = (uv - vec2f(0.5)) * vec2f(aspect, 1.0);
let t = uniforms.time * 0.1 * uniforms.speed;
// Swirl: rotate around the center, strongest inside, easing off toward
// the edges — wide and strong enough to twist the colors into arms.
let radius = length(p);
let swirlAngle = uniforms.swirl * 3.4 * exp(-radius * 1.1) + t * 0.2;
var q = rotate2d(p, swirlAngle);
// Organic distortion at two octaves: the broad field bends the zones, the
// finer one keeps their boundaries alive.
let bend = vec2f(
simplex2d(q * 1.1 + vec2f(t * 0.8, 3.1)) + 0.5 * simplex2d(q * 2.4 - vec2f(t * 1.2, 1.7)),
simplex2d(q * 1.1 - vec2f(2.7, t * 0.8)) + 0.5 * simplex2d(q * 2.4 + vec2f(1.3, t * 1.2)),
);
q += bend * uniforms.distortion * 0.4;
// The spots: four sites on independent slow orbits.
let siteA = vec2f(0.62 * cos(t * 0.9), 0.45 * sin(t * 0.7));
let siteB = vec2f(0.66 * cos(-t * 0.6 + 2.1), 0.5 * sin(t * 0.8 + 1.1));
let siteC = vec2f(0.55 * cos(t * 0.5 + 4.2), 0.55 * sin(-t * 0.9 + 2.6));
let siteD = vec2f(0.7 * cos(-t * 0.75 + 1.3), 0.4 * sin(t * 0.65 + 4.4));
// The spots cycle through the first colorCount palette entries, so the
// same mesh reads as duotone at 2 and full at 4.
var palette = array<vec3f, 4>(uniforms.colorA, uniforms.colorB, uniforms.colorC, uniforms.colorD);
let count = i32(clamp(round(uniforms.colorCount), 1.0, 4.0));
let wA = spotWeight(q, siteA);
let wB = spotWeight(q, siteB);
let wC = spotWeight(q, siteC);
let wD = spotWeight(q, siteD);
let wSum = max(wA + wB + wC + wD, 1e-5);
// Weighted OKLab sum: an N-way perceptual mix, vivid through the middle.
var lab = linearSrgbToOklab(palette[0 % count]) * wA
+ linearSrgbToOklab(palette[1 % count]) * wB
+ linearSrgbToOklab(palette[2 % count]) * wC
+ linearSrgbToOklab(palette[3 % count]) * wD;
var color = oklabToLinearSrgb(lab / wSum);
// Grain, in linear light, before encoding: hides banding on the soft ramps.
let g = hash2(position.xy * 0.911).x - 0.5;
color += vec3f(g * uniforms.grain * 0.08);
return vec4f(linearToSrgb3(clamp(color, vec3f(0.0), vec3f(1.0))), 1.0);
}