Home

04. Metaballs · 1995

Chrome blobs

Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Three spheres blended with a polynomial smooth-min, raymarched in the fragment shader. Normals come from the gradient of the field. The material is a fake studio HDR: a horizon gradient plus two specular lights sampled along the reflected ray, with Fresnel mix. It is 1990s chrome, without a cubemap.

Browser APIs

  • WebGL 1
  • GLSL ES 1.0
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.

Source

(function () {
  const PARAMS = {
    color: "#66dbc0",
    speed: 1,
    size: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [0.4, 0.86, 0.74];
    return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  function fail(message) {
    fallback.hidden = false;
    fallback.textContent = message;
  }

  const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
  if (!gl) {
    fail("WebGL is not available in this browser. Try a current Firefox, Chrome, Safari, or Edge.");
    return;
  }

  const VERT = `attribute vec2 a_pos;
void main() {
  gl_Position = vec4(a_pos, 0.0, 1.0);
}`;

  const FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform float u_time;
uniform vec2 u_resolution;
uniform vec3 u_color;
uniform float u_speed;
uniform float u_size;

float smin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

vec3 blob0(float t) { return vec3(sin(t * 0.70) * 0.52, cos(t * 0.48) * 0.22, sin(t * 0.41) * 0.32); }
vec3 blob1(float t) { return vec3(cos(t * 0.62) * 0.48, sin(t * 0.79) * 0.28, cos(t * 0.51) * 0.38); }
vec3 blob2(float t) { return vec3(sin(t * 0.91 + 2.0) * 0.38, cos(t * 0.67 + 1.1) * 0.32, sin(t * 0.58 + 2.6) * 0.28); }

float map(vec3 p, float t) {
  float d = length(p - blob0(t)) - 0.42 * u_size;
  d = smin(d, length(p - blob1(t)) - 0.35 * u_size, 0.42);
  d = smin(d, length(p - blob2(t)) - 0.31 * u_size, 0.42);
  return d;
}

vec3 calcNormal(vec3 p, float t) {
  vec2 e = vec2(0.0016, 0.0);
  return normalize(vec3(
    map(p + e.xyy, t) - map(p - e.xyy, t),
    map(p + e.yxy, t) - map(p - e.yxy, t),
    map(p + e.yyx, t) - map(p - e.yyx, t)
  ));
}

vec3 envMap(vec3 dir) {
  vec3 zenith = vec3(0.62, 0.78, 0.84);
  vec3 horizon = vec3(0.16, 0.18, 0.20);
  vec3 ground = vec3(0.035, 0.038, 0.042);
  vec3 col = mix(ground, horizon, smoothstep(-0.45, 0.06, dir.y));
  col = mix(col, zenith, smoothstep(0.04, 0.92, dir.y));
  vec3 key = normalize(vec3(0.42, 0.74, 0.32));
  col += vec3(1.0, 0.93, 0.82) * pow(max(dot(dir, key), 0.0), 52.0) * 1.55;
  vec3 fill = normalize(vec3(-0.62, 0.22, 0.48));
  col += u_color * pow(max(dot(dir, fill), 0.0), 18.0) * 0.42;
  return col;
}

void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;
  float t = u_time * u_speed;
  vec3 ro = vec3(0.0, 0.12, 2.55);
  vec3 rd = normalize(vec3(uv, -1.45));

  float dist = 0.0;
  float hit = 0.0;
  vec3 p = ro;
  for (int i = 0; i < 72; i++) {
    p = ro + rd * dist;
    float d = map(p, t);
    if (d < 0.001) { hit = 1.0; break; }
    dist += d;
    if (dist > 10.0) break;
  }

  vec3 col = envMap(rd) * 0.42;
  if (hit > 0.5) {
    vec3 n = calcNormal(p, t);
    vec3 r = reflect(rd, n);
    vec3 chrome = envMap(r);
    float fres = pow(1.0 - max(dot(n, -rd), 0.0), 2.4);
    col = mix(chrome * 0.52, chrome, fres);
    col *= 0.82 + 0.18 * n.y;
  }

  vec2 q = gl_FragCoord.xy / u_resolution;
  col *= 0.72 + 0.28 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.28);
  gl_FragColor = vec4(col, 1.0);
}`;

  function compile(type, src) {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      throw new Error(gl.getShaderInfoLog(sh) || "Shader compile failed");
    }
    return sh;
  }

  const prog = gl.createProgram();
  gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
  gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    throw new Error(gl.getProgramInfoLog(prog) || "Program link failed");
  }
  gl.useProgram(prog);

  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
  const loc = gl.getAttribLocation(prog, "a_pos");
  gl.enableVertexAttribArray(loc);
  gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

  const uTime = gl.getUniformLocation(prog, "u_time");
  const uRes = gl.getUniformLocation(prog, "u_resolution");
  const uColor = gl.getUniformLocation(prog, "u_color");
  const uSpeed = gl.getUniformLocation(prog, "u_speed");
  const uSize = gl.getUniformLocation(prog, "u_size");
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  let raf = 0;

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 1.75);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
    gl.viewport(0, 0, canvas.width, canvas.height);
  }

  function frame(now) {
    resize();
    const rgb = hexToRgb(PARAMS.color);
    gl.uniform1f(uTime, reduced ? 1.7 : now * 0.001);
    gl.uniform2f(uRes, canvas.width, canvas.height);
    gl.uniform3f(uColor, rgb[0], rgb[1], rgb[2]);
    gl.uniform1f(uSpeed, PARAMS.speed);
    gl.uniform1f(uSize, PARAMS.size);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  canvas.addEventListener("webglcontextlost", function (e) {
    e.preventDefault();
    cancelAnimationFrame(raf);
    fail("The WebGL context was lost. Reload to restore the demo.");
  });

  try {
    raf = requestAnimationFrame(frame);
  } catch (err) {
    fail(err && err.message ? err.message : String(err));
  }
})();