Mesh Gradient
An animated mesh-gradient background: three drifting colour blobs, a vignette and optional film grain, all in one fragment shader.
<MeshBackgroundCanvas quality={0.60} animate={true} /> · pending
man mesh-background
A line ref opens it in the source below
Name
mesh-background — the footer's and the hero's gradient: three blobs, a vignette and a grain, in one pass.
How it works
Three soft blobs. Each blob is
x²withx = max(0, 1 − |(uv − c) / scale|² · softness · 0.12): noexp, no texture. Summed with their colours and intensities, they make the gradient; the xray draws where each one fades to half and to nothing.Centres move in the vertex shader. Each centre drifts on a sine and a cosine of
time × 0.06, by 1 to 1.4 % of the frame: computed per vertex (six of them), not per pixel, and handed down as varyings.Vignette, dither, grain. A
smoothstepbetween radius 0.38 and 0.92 dims the edges; a ±0.006 hash dither breaks 8-bit banding even with the grain off; the film grain mixes a fine and a coarse hash reseeded 18 times a second, scaled byquality.One config, two languages.
SHADER_CONFIGis printed into both a GLSL and a WGSL source. The renderer tries WebGPU first and falls back to WebGL if it is missing or takes more than 250 ms to start; the readout names the one that won.
Cost
Only its window. A canvas that shows part of the composition (the hero's, the footer's) renders only that part:
compositionWindowrescalesuvin the shader instead of oversizing the canvas and clipping it.Armed late, capped. With
armMarginthe GPU session starts only when the canvas nears the viewport; the buffer stays at the device's pixel ratio, 3 at most.
See also
- grain-shader — the paper's still grain, baked into a tile.
const toGlslFloat = (value: number) => value.toFixed(8).replace(/0+$/, "").replace(/\.$/, ".0");
const toWgslFloat = toGlslFloat;
const glslVec3 = (value: readonly [number, number, number]) =>
`vec3(${value.map(toGlslFloat).join(", ")})`;
const wgslVec3 = (value: readonly [number, number, number]) =>
`vec3f(${value.map(toWgslFloat).join(", ")})`;
const square = (value: number) => value * value;
export const SHADER_CONFIG = {
animationSpeed: 0.06,
scrollAnimationSpeed: 0.01,
// A balanced coarse layer keeps the grain readable without looking digital.
filmGrainFlickerBase: 1,
filmGrainFlickerRange: 0.22,
filmGrainStrength: 0.4,
filmGrainCoarseMix: 0.42,
filmGrainCoarseSize: 2.25,
filmGrainResponse: 0.94,
blob1: {
centerX: 0.16,
centerY: 0.14,
moveX: 0.012,
moveY: 0.01,
speedX: 0.9,
speedY: 0.8,
scaleX: 0.28,
scaleY: 0.24,
softness: 3.6,
color: [0.97, 0.78, 0.28] as const,
intensity: 0.26,
},
blob2: {
centerX: 0.1,
centerY: 0.8,
moveX: 0.012,
moveY: 0.01,
speedX: 1.1,
speedY: 0.7,
scaleX: 0.14,
scaleY: 0.17,
softness: 4.2,
color: [0.33, 0.84, 0.56] as const,
intensity: 0.33,
},
blob3: {
centerX: 0.7,
centerY: 0.62,
moveX: 0.014,
moveY: 0.012,
speedX: 0.6,
speedY: 1,
scaleX: 0.23,
scaleY: 0.29,
softness: 4,
color: [0.79, 0.29, 0.88] as const,
intensity: 0.31,
},
vignetteStart: 0.38,
vignetteEnd: 0.92,
vignetteBase: 0.72,
vignetteStrength: 0.28,
baseColor: [0.02, 0.03, 0.05] as const,
// Keep the buffer aligned with physical pixels: resampling softens the grain.
// A 3x cap preserves native detail on high-density displays.
minRenderScale: 1,
maxRenderScale: 3,
defaultQuality: 0.6,
highQuality: 1.5,
heroQualityMultiplier: 0.5,
webgpuInitTimeoutMs: 250,
} as const;
export const VERTEX_SHADER = `
attribute vec2 a_position;
varying vec2 v_uv;
varying vec2 v_c1;
varying vec2 v_c2;
varying vec2 v_c3;
uniform mediump float u_time;
uniform float u_scroll;
const float ANIMATION_SPEED = ${toGlslFloat(SHADER_CONFIG.animationSpeed)};
const float SCROLL_ANIMATION_SPEED = ${toGlslFloat(SHADER_CONFIG.scrollAnimationSpeed)};
void main() {
float t = u_time * ANIMATION_SPEED + u_scroll * SCROLL_ANIMATION_SPEED;
v_uv = a_position * 0.5 + 0.5;
v_c1 = vec2(
${toGlslFloat(SHADER_CONFIG.blob1.centerX)} + sin(t * ${toGlslFloat(SHADER_CONFIG.blob1.speedX)}) * ${toGlslFloat(SHADER_CONFIG.blob1.moveX)},
${toGlslFloat(SHADER_CONFIG.blob1.centerY)} + cos(t * ${toGlslFloat(SHADER_CONFIG.blob1.speedY)}) * ${toGlslFloat(SHADER_CONFIG.blob1.moveY)}
);
v_c2 = vec2(
${toGlslFloat(SHADER_CONFIG.blob2.centerX)} + cos(t * ${toGlslFloat(SHADER_CONFIG.blob2.speedX)}) * ${toGlslFloat(SHADER_CONFIG.blob2.moveX)},
${toGlslFloat(SHADER_CONFIG.blob2.centerY)} + sin(t * ${toGlslFloat(SHADER_CONFIG.blob2.speedY)}) * ${toGlslFloat(SHADER_CONFIG.blob2.moveY)}
);
v_c3 = vec2(
${toGlslFloat(SHADER_CONFIG.blob3.centerX)} + sin(t * ${toGlslFloat(SHADER_CONFIG.blob3.speedX)}) * ${toGlslFloat(SHADER_CONFIG.blob3.moveX)},
${toGlslFloat(SHADER_CONFIG.blob3.centerY)} + cos(t * ${toGlslFloat(SHADER_CONFIG.blob3.speedY)}) * ${toGlslFloat(SHADER_CONFIG.blob3.moveY)}
);
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
/**
* Which window of the mesh composition a canvas shows, in uv units
* (`uv = rawUv * scale + offset`). A canvas smaller than the composition renders
* only its visible part instead of oversizing the canvas and clipping it.
* Grain and dither stay on the raw uv, i.e. on the canvas's physical pixels.
*/
export interface MeshCompositionWindow {
scale: readonly [number, number];
offset: readonly [number, number];
}
const FULL_COMPOSITION: MeshCompositionWindow = { scale: [1, 1], offset: [0, 0] };
const glslVec2 = (value: readonly [number, number]) => `vec2(${value.map(toGlslFloat).join(", ")})`;
const wgslVec2 = (value: readonly [number, number]) =>
`vec2f(${value.map(toWgslFloat).join(", ")})`;
export const buildMeshFragmentShader = (view: MeshCompositionWindow = FULL_COMPOSITION) => `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
varying vec2 v_uv;
varying vec2 v_c1;
varying vec2 v_c2;
varying vec2 v_c3;
uniform vec2 u_resolution;
uniform mediump float u_time;
uniform float u_quality;
const float FILM_GRAIN_FLICKER_BASE = ${toGlslFloat(SHADER_CONFIG.filmGrainFlickerBase)};
const float FILM_GRAIN_FLICKER_RANGE = ${toGlslFloat(SHADER_CONFIG.filmGrainFlickerRange)};
const float FILM_GRAIN_STRENGTH = ${toGlslFloat(SHADER_CONFIG.filmGrainStrength)};
const float FILM_GRAIN_COARSE_MIX = ${toGlslFloat(SHADER_CONFIG.filmGrainCoarseMix)};
const float FILM_GRAIN_COARSE_SIZE = ${toGlslFloat(SHADER_CONFIG.filmGrainCoarseSize)};
const float FILM_GRAIN_RESPONSE = ${toGlslFloat(SHADER_CONFIG.filmGrainResponse)};
float hash12(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float blobFast(vec2 uv, vec2 center, vec2 invScale, float softness) {
vec2 d = (uv - center) * invScale;
float x = max(0.0, 1.0 - dot(d, d) * softness * 0.12);
return x * x;
}
void main() {
vec2 uv = v_uv * ${glslVec2(view.scale)} + ${glslVec2(view.offset)};
float m1 = blobFast(
uv,
v_c1,
vec2(${toGlslFloat(1 / SHADER_CONFIG.blob1.scaleX)}, ${toGlslFloat(1 / SHADER_CONFIG.blob1.scaleY)}),
${toGlslFloat(SHADER_CONFIG.blob1.softness)}
);
float m2 = blobFast(
uv,
v_c2,
vec2(${toGlslFloat(1 / SHADER_CONFIG.blob2.scaleX)}, ${toGlslFloat(1 / SHADER_CONFIG.blob2.scaleY)}),
${toGlslFloat(SHADER_CONFIG.blob2.softness)}
);
float m3 = blobFast(
uv,
v_c3,
vec2(${toGlslFloat(1 / SHADER_CONFIG.blob3.scaleX)}, ${toGlslFloat(1 / SHADER_CONFIG.blob3.scaleY)}),
${toGlslFloat(SHADER_CONFIG.blob3.softness)}
);
vec3 color =
${glslVec3(SHADER_CONFIG.blob1.color)} * m1 * ${toGlslFloat(SHADER_CONFIG.blob1.intensity)} +
${glslVec3(SHADER_CONFIG.blob2.color)} * m2 * ${toGlslFloat(SHADER_CONFIG.blob2.intensity)} +
${glslVec3(SHADER_CONFIG.blob3.color)} * m3 * ${toGlslFloat(SHADER_CONFIG.blob3.intensity)};
vec2 d = uv - vec2(0.5);
float dist2 = dot(d, d);
float vignette = 1.0 - smoothstep(
${toGlslFloat(square(SHADER_CONFIG.vignetteStart))},
${toGlslFloat(square(SHADER_CONFIG.vignetteEnd))},
dist2
);
color *= ${toGlslFloat(SHADER_CONFIG.vignetteBase)} + ${toGlslFloat(SHADER_CONFIG.vignetteStrength)} * vignette;
color += ${glslVec3(SHADER_CONFIG.baseColor)};
vec2 px = v_uv * u_resolution;
// Dither sub-perceptuel (~1.5/255) : casse le banding 8 bits des dégradés
// sans texture visible, même quand le film grain est coupé (u_quality = 0).
color += (hash12(floor(px)) - 0.5) * 0.012;
if (u_quality > 0.001) {
float frame = mod(floor(u_time * 18.0), 1024.0);
vec2 grainSeed = vec2(frame, frame * 1.37);
float fineGrain = hash12(floor(px) + grainSeed) - 0.5;
float coarseGrain = hash12(floor(px / FILM_GRAIN_COARSE_SIZE) + grainSeed * 1.71) - 0.5;
float grain = mix(fineGrain, coarseGrain, FILM_GRAIN_COARSE_MIX);
grain = sign(grain) * pow(min(abs(grain) * 2.0, 1.0), FILM_GRAIN_RESPONSE) * 0.5;
float flicker = FILM_GRAIN_FLICKER_BASE + (hash12(vec2(frame, 91.7)) - 0.5) * FILM_GRAIN_FLICKER_RANGE;
color += grain * flicker * FILM_GRAIN_STRENGTH * u_quality;
}
gl_FragColor = vec4(max(color, vec3(0.0)), 1.0);
}
`;
export const FRAGMENT_SHADER = buildMeshFragmentShader();
export const buildMeshWebGPUShader = (view: MeshCompositionWindow = FULL_COMPOSITION) => `
struct VertexOut {
@builtin(position) pos : vec4f,
@location(0) uv : vec2f,
@location(1) c1 : vec2f,
@location(2) c2 : vec2f,
@location(3) c3 : vec2f,
};
struct Uniforms {
resolution : vec2f,
time : f32,
quality : f32,
scroll : f32,
};
@group(0) @binding(0) var<uniform> u : Uniforms;
const ANIMATION_SPEED : f32 = ${toWgslFloat(SHADER_CONFIG.animationSpeed)};
const SCROLL_ANIMATION_SPEED : f32 = ${toWgslFloat(SHADER_CONFIG.scrollAnimationSpeed)};
const FILM_GRAIN_FLICKER_BASE : f32 = ${toWgslFloat(SHADER_CONFIG.filmGrainFlickerBase)};
const FILM_GRAIN_FLICKER_RANGE : f32 = ${toWgslFloat(SHADER_CONFIG.filmGrainFlickerRange)};
const FILM_GRAIN_STRENGTH : f32 = ${toWgslFloat(SHADER_CONFIG.filmGrainStrength)};
const FILM_GRAIN_COARSE_MIX : f32 = ${toWgslFloat(SHADER_CONFIG.filmGrainCoarseMix)};
const FILM_GRAIN_COARSE_SIZE : f32 = ${toWgslFloat(SHADER_CONFIG.filmGrainCoarseSize)};
const FILM_GRAIN_RESPONSE : f32 = ${toWgslFloat(SHADER_CONFIG.filmGrainResponse)};
@vertex
fn vsMain(@builtin(vertex_index) i : u32) -> VertexOut {
var p = array<vec2f, 6>(
vec2f(-1.0, -1.0),
vec2f(1.0, -1.0),
vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0),
vec2f(1.0, -1.0),
vec2f(1.0, 1.0)
);
let pos = p[i];
let t = u.time * ANIMATION_SPEED + u.scroll * SCROLL_ANIMATION_SPEED;
var out : VertexOut;
out.pos = vec4f(pos, 0.0, 1.0);
out.uv = pos * 0.5 + vec2f(0.5);
out.c1 = vec2f(
${toWgslFloat(SHADER_CONFIG.blob1.centerX)} + sin(t * ${toWgslFloat(SHADER_CONFIG.blob1.speedX)}) * ${toWgslFloat(SHADER_CONFIG.blob1.moveX)},
${toWgslFloat(SHADER_CONFIG.blob1.centerY)} + cos(t * ${toWgslFloat(SHADER_CONFIG.blob1.speedY)}) * ${toWgslFloat(SHADER_CONFIG.blob1.moveY)}
);
out.c2 = vec2f(
${toWgslFloat(SHADER_CONFIG.blob2.centerX)} + cos(t * ${toWgslFloat(SHADER_CONFIG.blob2.speedX)}) * ${toWgslFloat(SHADER_CONFIG.blob2.moveX)},
${toWgslFloat(SHADER_CONFIG.blob2.centerY)} + sin(t * ${toWgslFloat(SHADER_CONFIG.blob2.speedY)}) * ${toWgslFloat(SHADER_CONFIG.blob2.moveY)}
);
out.c3 = vec2f(
${toWgslFloat(SHADER_CONFIG.blob3.centerX)} + sin(t * ${toWgslFloat(SHADER_CONFIG.blob3.speedX)}) * ${toWgslFloat(SHADER_CONFIG.blob3.moveX)},
${toWgslFloat(SHADER_CONFIG.blob3.centerY)} + cos(t * ${toWgslFloat(SHADER_CONFIG.blob3.speedY)}) * ${toWgslFloat(SHADER_CONFIG.blob3.moveY)}
);
return out;
}
fn hash12(p: vec2f) -> f32 {
var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
p3 += dot(p3, p3.yzx + vec3f(33.33));
return fract((p3.x + p3.y) * p3.z);
}
fn blobFast(uv: vec2f, center: vec2f, invScale: vec2f, softness: f32) -> f32 {
let d = (uv - center) * invScale;
let x = max(0.0, 1.0 - dot(d, d) * softness * 0.12);
return x * x;
}
@fragment
fn fsMain(in: VertexOut) -> @location(0) vec4f {
let uv = in.uv * ${wgslVec2(view.scale)} + ${wgslVec2(view.offset)};
let m1 = blobFast(
uv,
in.c1,
vec2f(${toWgslFloat(1 / SHADER_CONFIG.blob1.scaleX)}, ${toWgslFloat(1 / SHADER_CONFIG.blob1.scaleY)}),
${toWgslFloat(SHADER_CONFIG.blob1.softness)}
);
let m2 = blobFast(
uv,
in.c2,
vec2f(${toWgslFloat(1 / SHADER_CONFIG.blob2.scaleX)}, ${toWgslFloat(1 / SHADER_CONFIG.blob2.scaleY)}),
${toWgslFloat(SHADER_CONFIG.blob2.softness)}
);
let m3 = blobFast(
uv,
in.c3,
vec2f(${toWgslFloat(1 / SHADER_CONFIG.blob3.scaleX)}, ${toWgslFloat(1 / SHADER_CONFIG.blob3.scaleY)}),
${toWgslFloat(SHADER_CONFIG.blob3.softness)}
);
var color =
${wgslVec3(SHADER_CONFIG.blob1.color)} * m1 * ${toWgslFloat(SHADER_CONFIG.blob1.intensity)} +
${wgslVec3(SHADER_CONFIG.blob2.color)} * m2 * ${toWgslFloat(SHADER_CONFIG.blob2.intensity)} +
${wgslVec3(SHADER_CONFIG.blob3.color)} * m3 * ${toWgslFloat(SHADER_CONFIG.blob3.intensity)};
let d = uv - vec2f(0.5);
let dist2 = dot(d, d);
let vignette = 1.0 - smoothstep(
${toWgslFloat(square(SHADER_CONFIG.vignetteStart))},
${toWgslFloat(square(SHADER_CONFIG.vignetteEnd))},
dist2
);
color *= ${toWgslFloat(SHADER_CONFIG.vignetteBase)} + ${toWgslFloat(SHADER_CONFIG.vignetteStrength)} * vignette;
color += ${wgslVec3(SHADER_CONFIG.baseColor)};
let px = in.uv * u.resolution;
// Dither sub-perceptuel (~1.5/255) : casse le banding 8 bits des dégradés
// sans texture visible, même quand le film grain est coupé (quality = 0).
color += vec3f((hash12(floor(px)) - 0.5) * 0.012);
if (u.quality > 0.001) {
let elapsedFrames = floor(u.time * 18.0);
let frame = elapsedFrames - floor(elapsedFrames / 1024.0) * 1024.0;
let grainSeed = vec2f(frame, frame * 1.37);
let fineGrain = hash12(floor(px) + grainSeed) - 0.5;
let coarseGrain = hash12(floor(px / FILM_GRAIN_COARSE_SIZE) + grainSeed * 1.71) - 0.5;
var grain = mix(fineGrain, coarseGrain, FILM_GRAIN_COARSE_MIX);
grain = sign(grain) * pow(min(abs(grain) * 2.0, 1.0), FILM_GRAIN_RESPONSE) * 0.5;
let flicker = FILM_GRAIN_FLICKER_BASE + (hash12(vec2f(frame, 91.7)) - 0.5) * FILM_GRAIN_FLICKER_RANGE;
color += vec3f(grain * flicker * FILM_GRAIN_STRENGTH * u.quality);
}
return vec4f(max(color, vec3f(0.0)), 1.0);
}
`;
export const WEBGPU_SHADER = buildMeshWebGPUShader();
export function getShaderQuality() {
return document.documentElement.dataset.quality === "high"
? SHADER_CONFIG.highQuality
: SHADER_CONFIG.defaultQuality;
}
export function getShaderRenderScale() {
return {
min: SHADER_CONFIG.minRenderScale,
max: SHADER_CONFIG.maxRenderScale,
};
}