Home

04. Blinn · 1982 · Retro techniques

Chrome blobs

Demos are animating. Following your system setting.
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. Metaballs are Jim Blinn's 1982 blobby surfaces; the chrome finish is the 1990s demo treatment of them: a fake studio HDR of a horizon gradient plus two specular lights sampled along the reflected ray, with a Fresnel mix, and no cubemap.

Reference

J. F. Blinn, "A Generalization of Algebraic Surface Drawing", ACM Transactions on Graphics 1(3), 1982

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. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

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 motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.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.");
  });

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  try {
    if (reduced) frame(performance.now());
    else raf = requestAnimationFrame(frame);
    readMotionHash();
  } catch (err) {
    fail(err && err.message ? err.message : String(err));
  }
})();