About this technique →
metaballs-3d/sketch.js
// Metaballs — Implicit blobs via MarchingCubes
//
// Each ball contributes a falloff field: f(p) = Σ strength / |p - bᵢ|²
// The isosurface where f = threshold yields organic blobs that fuse when
// balls approach and separate when they move apart.
//
// Three.js's MarchingCubes is designed exactly for this; uses the built-in
// addBall() API which was designed for metaballs.

import { MarchingCubes } from 'three/addons/objects/MarchingCubes.js';
import * as THREE from 'three';

export const WARMUP = { framesBeforeReady: 60 };

export const PARAMS = {
  resolution:   { value: 50, min: 20, max: 80, step: 2, label: "Grid Resolution", folder: "Structure", rebuildOnChange: true },
  ballCount:    { value: 6, min: 2, max: 12, step: 1, label: "Ball Count", folder: "Structure" },
  ballStrength: { value: 0.5, min: 0.1, max: 2, step: 0.01, label: "Ball Strength", folder: "Behavior" },
  ballSubtract: { value: 12, min: 1, max: 30, step: 0.5, label: "Subtract", folder: "Behavior" },
  speed:        { value: 0.5, min: 0, max: 2, step: 0.01, label: "Speed", folder: "Behavior" },
  hue:          { value: 200, min: 0, max: 360, step: 1, label: "Hue", folder: "Appearance" },
};

export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
  scene.background = new THREE.Color(0x07080a);
  camera.position.set(0, 0, 5);
  camera.lookAt(0, 0, 0);

  const ambient = new THREE.AmbientLight(0xffffff, 0.4);
  scene.add(ambient);
  const dirA = new THREE.DirectionalLight(0xffffff, 1.5);
  dirA.position.set(5, 5, 5);
  scene.add(dirA);
  const dirB = new THREE.DirectionalLight(0x4466ff, 0.5);
  dirB.position.set(-5, -3, -5);
  scene.add(dirB);

  const mat = new THREE.MeshStandardMaterial({
    color: new THREE.Color().setHSL(params.hue / 360, 0.7, 0.55),
    roughness: 0.3,
    metalness: 0.2,
  });

  const resolution = params.resolution | 0;
  const mc = new MarchingCubes(resolution, mat, true, true, 100000);
  mc.position.set(0, 0, 0);
  mc.scale.set(2.5, 2.5, 2.5);
  scene.add(mc);

  return { mc, mat };
}

export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
  const { mc, mat } = state;

  mat.color.setHSL(params.hue / 360, 0.7, 0.55);

  const t = time * params.speed;
  const count = params.ballCount | 0;

  mc.reset();

  // Distribute balls in Lissajous-like orbits for varied, non-repeating motion
  for (let i = 0; i < count; i++) {
    const phase = (i / count) * Math.PI * 2;
    const freq1 = 1.0 + i * 0.23;
    const freq2 = 1.3 + i * 0.17;
    const freq3 = 0.7 + i * 0.31;

    // MarchingCubes expects coords in [0,1]
    const x = 0.5 + Math.sin(t * freq1 + phase) * 0.25;
    const y = 0.5 + Math.cos(t * freq2 + phase * 1.3) * 0.25;
    const z = 0.5 + Math.sin(t * freq3 + phase * 0.7) * 0.25;

    mc.addBall(x, y, z, params.ballStrength, params.ballSubtract);
  }

  mc.update();
}