04. Metaballs · 1995
Chrome blobs
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));
}
})();