{"slug":"metaballs","number":"04","title":"Chrome blobs","era":"Metaballs · 1995","summary":"Raymarched metaballs with a studio chrome environment.","description":"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.","apis":["WebGL 1","GLSL ES 1.0","requestAnimationFrame","matchMedia"],"tags":["shader","raymarch"],"hasGlsl":true,"interaction":null,"url":"https://3d-retro.com/experiments/metaballs","example_url":"https://3d-retro.com/examples/metaballs.html","api_url":"https://3d-retro.com/api/v1/experiments/metaballs","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"Raymarched metaballs with a studio chrome environment.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/metaballs\" />\n  <title>Chrome blobs · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#66dbc0\",\n    speed: 1,\n    size: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [0.4, 0.86, 0.74];\n    return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  function fail(message) {\n    fallback.hidden = false;\n    fallback.textContent = message;\n  }\n\n  const gl = canvas.getContext(\"webgl\", { alpha: false, antialias: false });\n  if (!gl) {\n    fail(\"WebGL is not available in this browser. Try a current Firefox, Chrome, Safari, or Edge.\");\n    return;\n  }\n\n  const VERT = `attribute vec2 a_pos;\nvoid main() {\n  gl_Position = vec4(a_pos, 0.0, 1.0);\n}`;\n\n  const FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\nuniform float u_time;\nuniform vec2 u_resolution;\nuniform vec3 u_color;\nuniform float u_speed;\nuniform float u_size;\n\nfloat smin(float a, float b, float k) {\n  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);\n  return mix(b, a, h) - k * h * (1.0 - h);\n}\n\nvec3 blob0(float t) { return vec3(sin(t * 0.70) * 0.52, cos(t * 0.48) * 0.22, sin(t * 0.41) * 0.32); }\nvec3 blob1(float t) { return vec3(cos(t * 0.62) * 0.48, sin(t * 0.79) * 0.28, cos(t * 0.51) * 0.38); }\nvec3 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); }\n\nfloat map(vec3 p, float t) {\n  float d = length(p - blob0(t)) - 0.42 * u_size;\n  d = smin(d, length(p - blob1(t)) - 0.35 * u_size, 0.42);\n  d = smin(d, length(p - blob2(t)) - 0.31 * u_size, 0.42);\n  return d;\n}\n\nvec3 calcNormal(vec3 p, float t) {\n  vec2 e = vec2(0.0016, 0.0);\n  return normalize(vec3(\n    map(p + e.xyy, t) - map(p - e.xyy, t),\n    map(p + e.yxy, t) - map(p - e.yxy, t),\n    map(p + e.yyx, t) - map(p - e.yyx, t)\n  ));\n}\n\nvec3 envMap(vec3 dir) {\n  vec3 zenith = vec3(0.62, 0.78, 0.84);\n  vec3 horizon = vec3(0.16, 0.18, 0.20);\n  vec3 ground = vec3(0.035, 0.038, 0.042);\n  vec3 col = mix(ground, horizon, smoothstep(-0.45, 0.06, dir.y));\n  col = mix(col, zenith, smoothstep(0.04, 0.92, dir.y));\n  vec3 key = normalize(vec3(0.42, 0.74, 0.32));\n  col += vec3(1.0, 0.93, 0.82) * pow(max(dot(dir, key), 0.0), 52.0) * 1.55;\n  vec3 fill = normalize(vec3(-0.62, 0.22, 0.48));\n  col += u_color * pow(max(dot(dir, fill), 0.0), 18.0) * 0.42;\n  return col;\n}\n\nvoid main() {\n  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;\n  float t = u_time * u_speed;\n  vec3 ro = vec3(0.0, 0.12, 2.55);\n  vec3 rd = normalize(vec3(uv, -1.45));\n\n  float dist = 0.0;\n  float hit = 0.0;\n  vec3 p = ro;\n  for (int i = 0; i < 72; i++) {\n    p = ro + rd * dist;\n    float d = map(p, t);\n    if (d < 0.001) { hit = 1.0; break; }\n    dist += d;\n    if (dist > 10.0) break;\n  }\n\n  vec3 col = envMap(rd) * 0.42;\n  if (hit > 0.5) {\n    vec3 n = calcNormal(p, t);\n    vec3 r = reflect(rd, n);\n    vec3 chrome = envMap(r);\n    float fres = pow(1.0 - max(dot(n, -rd), 0.0), 2.4);\n    col = mix(chrome * 0.52, chrome, fres);\n    col *= 0.82 + 0.18 * n.y;\n  }\n\n  vec2 q = gl_FragCoord.xy / u_resolution;\n  col *= 0.72 + 0.28 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.28);\n  gl_FragColor = vec4(col, 1.0);\n}`;\n\n  function compile(type, src) {\n    const sh = gl.createShader(type);\n    gl.shaderSource(sh, src);\n    gl.compileShader(sh);\n    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {\n      throw new Error(gl.getShaderInfoLog(sh) || \"Shader compile failed\");\n    }\n    return sh;\n  }\n\n  const prog = gl.createProgram();\n  gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));\n  gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));\n  gl.linkProgram(prog);\n  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {\n    throw new Error(gl.getProgramInfoLog(prog) || \"Program link failed\");\n  }\n  gl.useProgram(prog);\n\n  const buf = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);\n  const loc = gl.getAttribLocation(prog, \"a_pos\");\n  gl.enableVertexAttribArray(loc);\n  gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);\n\n  const uTime = gl.getUniformLocation(prog, \"u_time\");\n  const uRes = gl.getUniformLocation(prog, \"u_resolution\");\n  const uColor = gl.getUniformLocation(prog, \"u_color\");\n  const uSpeed = gl.getUniformLocation(prog, \"u_speed\");\n  const uSize = gl.getUniformLocation(prog, \"u_size\");\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  let raf = 0;\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 1.75);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n    gl.viewport(0, 0, canvas.width, canvas.height);\n  }\n\n  function frame(now) {\n    resize();\n    const rgb = hexToRgb(PARAMS.color);\n    gl.uniform1f(uTime, reduced ? 1.7 : now * 0.001);\n    gl.uniform2f(uRes, canvas.width, canvas.height);\n    gl.uniform3f(uColor, rgb[0], rgb[1], rgb[2]);\n    gl.uniform1f(uSpeed, PARAMS.speed);\n    gl.uniform1f(uSize, PARAMS.size);\n    gl.drawArrays(gl.TRIANGLES, 0, 6);\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  canvas.addEventListener(\"webglcontextlost\", function (e) {\n    e.preventDefault();\n    cancelAnimationFrame(raf);\n    fail(\"The WebGL context was lost. Reload to restore the demo.\");\n  });\n\n  try {\n    raf = requestAnimationFrame(frame);\n  } catch (err) {\n    fail(err && err.message ? err.message : String(err));\n  }\n})();\n</script>\n</body>\n</html>\n"}