metaballs@0.1.0

Metaballs

Six droplets on their own slow orbits, one riding the pointer, melted together by a smooth boolean: the classic gooey look, kept deliberately flat. Each droplet carries one color from a palette of up to four, blended in OKLab across the necks — crisp silhouette, pure black outside, no light and no depth.

Controls

Color A
#ECECEC
Color B
#818186
Color C
#0070F3
Color D
#7928CA
Colors3
Gooeyness0.090
Speed1.00
Grain0.35
Background
#0A0A0D

Install

One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/metaballs.json works with no setup). npm dependencies install alongside.

npx shadcn@latest add @vshaders/metaballs

Files that land in your tree: shaders/metaballs.wgsl, components/metaballs.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/metaballs) for exact values.

Imports

What this entry shader composes; every module resolves from npm.

import { sdCircle } from "@vshaders/sdf/2d";

import { opSmoothUnion } from "@vshaders/sdf/ops";

import { linearSrgbToOklab, oklabToLinearSrgb, mixOklab } from "@vshaders/color/oklab";

import { clamp01, safeNormalize2 } from "@vgpu/wgsl-std/math";

import { hash2 } from "@vgpu/wgsl-std/hash";

import { linearToSrgb3 } from "@vgpu/wgsl-std/color";

Source

The full entry shader, metaballs.wgsl. Copy it and it is yours.

// effect 002 · metaballs — flat gooey droplets on black.
// Six droplets orbit on their own slow paths (the sixth rides the pointer),
// melted together by a smooth boolean: the classic gooey look, because
// opSmoothUnion IS the metaball. The fill is deliberately flat — no light,
// no shadow, no depth: each droplet carries one color from a palette of up
// to four, and the interior is a soft OKLab blend of them, sharpened so each
// lobe holds its own ink and the transitions live in the necks. Crisp
// silhouette, pure black outside. Restrained on purpose: pick 1 color for
// monochrome droplets, 4 for the full (still quiet) palette.
// Works in linear light; encodes to sRGB once at the end.

import { sdCircle } from "@vshaders/sdf/2d";
import { opSmoothUnion } from "@vshaders/sdf/ops";
import { linearSrgbToOklab, oklabToLinearSrgb } from "@vshaders/color/oklab";
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,
  // Multiplies time in the orbits.
  speed: f32,
  colorA: vec3f,
  // How many palette colors the droplets cycle through, 1 to 4.
  colorCount: f32,
  colorB: vec3f,
  // Blend radius of the smooth union: how gooey the necks are.
  smoothness: f32,
  colorC: vec3f,
  // Film grain amount.
  grain: f32,
  colorD: vec3f,
  // Pointer position, physical pixels; one droplet rides it.
  pointer: vec2f,
  // Ground the effect sinks into at the edges, linear light: expose it so
  // the effect sits on any page, not just this gallery's ink.
  backgroundColor: vec3f,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

const BALL_COUNT = 6;

fn ballCenter(index: i32, t: f32, pointerField: vec2f) -> vec2f {
  if (index == BALL_COUNT - 1) {
    return pointerField;
  }
  let fi = f32(index);
  // Distinct rates and phases per droplet, so paths never sync up.
  let rate = 0.35 + fi * 0.11;
  let phase = fi * 2.39996; // golden angle: spreads the phases evenly
  let extent = vec2f(0.5 - fi * 0.05, 0.34 - fi * 0.03);
  return vec2f(cos(t * rate + phase), sin(t * (rate * 0.8) + phase * 1.7)) * extent;
}

fn ballRadius(index: i32) -> f32 {
  return 0.17 - f32(index) * 0.012;
}

@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);
  // The pointer transforms exactly like position.
  let pointerField = (uniforms.pointer / res - vec2f(0.5)) * vec2f(aspect, 1.0);
  let t = uniforms.time * 0.4 * uniforms.speed;
  let smoothness = max(uniforms.smoothness, 1e-3);

  var palette = array<vec3f, 4>(uniforms.colorA, uniforms.colorB, uniforms.colorC, uniforms.colorD);
  let count = i32(clamp(round(uniforms.colorCount), 1.0, 4.0));

  // One pass over the droplets: the fused field, and each droplet's color
  // weighted by a sharpened inverse power of the distance to its center, so
  // every lobe holds its own ink and the blends live in the necks.
  var d = 1e5;
  var lab = vec3f(0.0);
  var wSum = 0.0;
  for (var i = 0; i < BALL_COUNT; i++) {
    let center = ballCenter(i, t, pointerField);
    d = opSmoothUnion(d, sdCircle(p - center, ballRadius(i)), smoothness);
    let offset = p - center;
    let w = 1.0 / pow(dot(offset, offset) + 0.012, 1.6);
    lab += linearSrgbToOklab(palette[i % count]) * w;
    wSum += w;
  }
  let ink = oklabToLinearSrgb(lab / max(wSum, 1e-5));

  // Flat fill, crisp silhouette, pure black outside — no light, no depth.
  // The anti-alias band is one screen pixel, from the field's derivative:
  // a fixed band in field units scales with the canvas and reads blurry.
  let aa = max(fwidth(d), 1e-5);
  let inside = smoothstep(aa, -aa, d);
  var color = mix(uniforms.backgroundColor, ink, inside);

  // Grain, in linear light, inside the droplets only: the black stays pure.
  let g = hash2(position.xy * 0.887).x - 0.5;
  color += vec3f(g * uniforms.grain * 0.05) * inside;

  return vec4f(linearToSrgb3(clamp(color, vec3f(0.0), vec3f(1.0))), 1.0);
}