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
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/metaballsFiles 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);
}