Mesh Gradient

An animated mesh-gradient background: three drifting colour blobs, a vignette and optional film grain, all in one fragment shader.

WebGPUWebGLGLSLgradientblobsvignetteShaders · 3 files · 719 lines
WebGPU / WebGL

<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

  1. Three soft blobs. Each blob is x² with x = max(0, 1 − |(uv − c) / scale|² · softness · 0.12): no exp, no texture. Summed with their colours and intensities, they make the gradient; the xray draws where each one fades to half and to nothing.

  2. 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.

  3. Vignette, dither, grain. A smoothstep between 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 by quality.

  4. One config, two languages. SHADER_CONFIG is 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

  1. Only its window. A canvas that shows part of the composition (the hero's, the footer's) renders only that part: compositionWindow rescales uv in the shader instead of oversizing the canvas and clipping it.

  2. Armed late, capped. With armMargin the 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,
  };
}
mesh-background.shader.ts · 392 lines · 13K · ts_Source_