← Mike Higgins Home
Custom Code Experiment •

Liquid Crystal

Source Code View HTML / CSS / JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Liquid crystal</title>
<style>
:root{
  --bg:#e7e5ef;
  --field:#fbfaff;
  --halo:.20;
  color-scheme:light;
  box-sizing:border-box;
  padding-top:env(safe-area-inset-top,0px);
  padding-bottom:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){
    --bg:#0a0818;
    --field:#1b1638;
    --halo:.28;
    color-scheme:dark;
  }
}
:root[data-theme="dark"]{
  --bg:#0a0818;
  --field:#1b1638;
  --halo:.28;
  color-scheme:dark;
}
*,*::before,*::after{box-sizing:inherit}
html{height:100%;scroll-padding-top:env(safe-area-inset-top,0px)}
body{
  margin:0;
  height:100%;
  overflow:auto;
  background-color:var(--bg);
  background-image:radial-gradient(circle at 50% 50%, var(--field) 0, var(--bg) min(70vmin, 620px));
  display:grid;
  place-items:center;
}
.stage{
  display:grid;
  place-items:center;
  padding:24px;
}
.orb{
  position:relative;
  width:min(84vmin, 560px);
  aspect-ratio:1 / 1;
}
.orb canvas{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  display:block;
}
.halo{
  position:absolute;
  inset:20%;
  border-radius:50%;
  background:conic-gradient(from 0deg, #c3459e, #f2d64b, #35c9b5, #3a6ff0, #8a3cc8, #c3459e);
  filter:blur(44px);
  opacity:calc(var(--halo) * (0.55 + var(--melt, 1) * 0.6));
  animation:spin 16s linear infinite;
}
.blob{
  display:none;
  position:absolute;
  inset:26%;
  border-radius:42% 58% 55% 45% / 48% 42% 58% 52%;
  background:conic-gradient(from 200deg, #8a3cc8, #3a6ff0, #35c9b5, #f2d64b, #f07a6b, #c3459e, #8a3cc8);
  animation:morph 5s ease-in-out infinite alternate, spin 12s linear infinite;
}
.no-gl canvas{display:none}
.no-gl .blob{display:block}

@keyframes spin{to{transform:rotate(360deg)}}
@keyframes morph{
  0%{border-radius:42% 58% 55% 45% / 48% 42% 58% 52%}
  100%{border-radius:58% 42% 40% 60% / 40% 60% 44% 56%}
}
@media (prefers-reduced-motion: reduce){
  .halo{animation:none}
  .blob{animation:none}
}
</style>
</head>
<body>
<main class="stage">
  <div class="orb" id="orb" role="img" aria-label="A glassy, iridescent liquid that gathers into a faceted crystal, then melts again">
    <div class="halo" aria-hidden="true"></div>
    <canvas id="lc" aria-hidden="true"></canvas>
    <div class="blob" aria-hidden="true"></div>
  </div>
</main>

<script id="vs" type="x-shader/x-vertex">
attribute vec2 aPos;
void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }
</script>

<script id="fs" type="x-shader/x-fragment">
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif

uniform vec2 uRes;
uniform float uTime;
uniform float uOrder;   // 0 = liquid, 1 = crystal
uniform vec2 uTilt;
uniform float uLight;
uniform float uSweep;
uniform float uGlint;

mat2 gTy; mat2 gTx; mat2 gSpin; mat2 gWob;
float gMelt; float gDropOn;
vec3 gD0; vec3 gD1; vec3 gD2; vec3 gD3; vec4 gDR;

const mat2 R45 = mat2(0.7071068, -0.7071068, 0.7071068, 0.7071068);

mat2 rot(float a){ float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }

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);
}

float sdBox(vec3 p, vec3 b){
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

vec3 tiltP(vec3 p){ p.yz = gTy * p.yz; p.xz = gTx * p.xz; return p; }
vec3 spinP(vec3 p){ p.xz = gSpin * p.xz; p.xy = gWob * p.xy; return p; }

// Faceted gem: octahedron with an octagonal girdle
float crystal(vec3 p){
  vec3 q = p; q.y *= 0.8;
  float o = (abs(q.x) + abs(q.y) + abs(q.z) - 0.66) * 0.57735;
  float b1 = sdBox(q, vec3(0.45, 4.0, 0.45));
  vec2 r = R45 * q.xz;
  float b2 = sdBox(vec3(r.x, q.y, r.y), vec3(0.40, 4.0, 0.40));
  return max(o, max(b1, b2));
}

// Rippling liquid core
float liquid(vec3 p){
  float t = uTime;
  float w = sin(p.x * 4.0 + t * 1.7) * sin(p.y * 3.5 - t * 1.3) * sin(p.z * 4.0 + t * 1.1);
  w += 0.6 * sin(p.x * 7.0 - t * 2.3 + p.y * 2.0) * sin(p.z * 6.5 + t * 1.9 - p.y * 1.5);
  return length(p * vec3(1.0, 0.92, 1.0)) - 0.56 - 0.055 * w;
}

float map(vec3 pw){
  vec3 q = tiltP(pw);
  vec3 p = spinP(q);
  float d = crystal(p);
  if (gMelt > 0.002) d = mix(d, liquid(p), gMelt);
  if (gDropOn > 0.5){
    d = smin(d, length(q - gD0) - gDR.x, 0.26);
    d = smin(d, length(q - gD1) - gDR.y, 0.26);
    d = smin(d, length(q - gD2) - gDR.z, 0.26);
    d = smin(d, length(q - gD3) - gDR.w, 0.26);
  }
  return d;
}

vec3 dropPos(float fi, float orbit){
  float a = uTime * (0.75 + 0.18 * fi) + fi * 1.5708;
  float R = orbit * (0.88 + 0.12 * sin(uTime * 1.1 + fi * 2.1));
  return vec3(cos(a) * R, sin(a * 1.3 + fi * 1.7) * 0.30 * orbit, sin(a) * R);
}
float dropRad(float fi, float base){ return base * (0.75 + 0.3 * sin(uTime * 1.9 + fi * 2.7)); }

vec3 calcN(vec3 p){
  const float e = 0.0015;
  vec2 k = vec2(1.0, -1.0);
  return normalize(k.xyy * map(p + k.xyy * e) + k.yyx * map(p + k.yyx * e) +
                   k.yxy * map(p + k.yxy * e) + k.xxx * map(p + k.xxx * e));
}

float lobe(float a, float c, float w){ float x = (a - c) * w; return exp(-x * x); }

// Procedural studio: softbox, strip lights, coloured rims
vec3 env(vec3 d){
  float y = d.y;
  float a = atan(d.z, d.x);
  vec3 lo = mix(vec3(0.012, 0.010, 0.030), vec3(0.50, 0.50, 0.58), uLight);
  vec3 hi = mix(vec3(0.080, 0.070, 0.150), vec3(0.95, 0.95, 0.99), uLight);
  vec3 col = mix(lo, hi, smoothstep(-1.0, 1.0, y));
  col += vec3(1.5) * smoothstep(0.62, 0.93, y);
  float band = smoothstep(-0.35, 0.2, y) * (1.0 - smoothstep(0.5, 0.8, y));
  col += vec3(1.5, 1.45, 1.38) * lobe(a, 0.7, 6.0) * band;
  col += vec3(1.05, 1.15, 1.4) * lobe(a, -2.3, 6.0) * band;
  float hz = 1.0 - smoothstep(0.0, 0.5, abs(y));
  col += vec3(1.0, 0.22, 0.62) * lobe(a, -0.8, 2.4) * hz;
  col += vec3(0.12, 0.72, 1.0) * lobe(a, 2.4, 2.4) * hz;
  col += vec3(0.4, 1.0, 0.55) * lobe(a, -1.7, 3.0) * hz * 0.35;
  return col;
}

// Thin-film interference on the surface
vec3 thinFilm(float cosT, float d){
  float n = 1.38;
  float ct = sqrt(max(0.0, 1.0 - (1.0 - cosT * cosT) / (n * n)));
  float opd = 2.0 * n * d * ct;
  return 0.5 + 0.5 * cos(6.2831853 * opd / vec3(640.0, 545.0, 460.0));
}

// Birefringence colours (Michel-Lévy) for a retardation G in nm
float ml1(float G, float l){ float s = sin(3.14159265 * G / l); return s * s; }
vec3 michelLevy(float G){
  vec3 c = vec3(0.35, 0.0, 1.0) * ml1(G, 430.0)
         + vec3(0.0, 0.3, 1.0)  * ml1(G, 470.0)
         + vec3(0.0, 0.9, 0.6)  * ml1(G, 510.0)
         + vec3(0.3, 1.0, 0.1)  * ml1(G, 550.0)
         + vec3(1.0, 0.75, 0.0) * ml1(G, 590.0)
         + vec3(1.0, 0.25, 0.0) * ml1(G, 630.0)
         + vec3(0.8, 0.0, 0.05) * ml1(G, 670.0);
  return c / vec3(3.45, 3.2, 2.75);
}

vec3 march(vec3 ro, vec3 rd, float t0, float t1){
  float t = t0, md = 1e4, tm = t0;
  bool exited = false;
  for (int i = 0; i < 110; i++){
    float d = map(ro + rd * t);
    if (d < md){ md = d; tm = t; }
    if (d < 0.0007) return vec3(t, 0.0, t);
    t += d * 0.68;
    if (t > t1){ exited = true; break; }
  }
  // ran out of steps while still skimming the surface: count it as a hit
  if (!exited && md < 0.02) return vec3(tm, 0.0, tm);
  return vec3(-1.0, md, tm);
}

float traceIn(vec3 ro, vec3 rd){
  float t = 0.0;
  for (int i = 0; i < 40; i++){
    float d = -map(ro + rd * t);
    if (d < 0.002) break;
    t += max(d * 0.85, 0.008);
    if (t > 2.6) break;
  }
  return t;
}

vec3 shade(vec3 p, vec3 rd, vec2 uv){
  vec3 n = calcN(p);
  vec3 po = spinP(tiltP(p));
  float cosT = clamp(dot(-rd, n), 0.0, 1.0);
  float F = 0.05 + 0.95 * pow(1.0 - cosT, 4.0);

  float thick = 380.0 + 170.0 * sin(po.y * 4.2 + uTime * 0.7)
              + 110.0 * sin(dot(po, vec3(3.3, 1.9, 2.6)) - uTime * 1.2);
  vec3 film = thinFilm(cosT, thick);
  vec3 R = reflect(rd, n);
  vec3 refl = env(R) * (0.35 + 1.3 * film);

  vec3 ri = refract(rd, n, 0.6667);
  if (dot(ri, ri) < 1e-4) ri = rd;
  vec3 pin = p - n * 0.012;
  float ti = traceIn(pin, ri);
  vec3 pe = pin + ri * ti;
  vec3 ne = calcN(pe);
  vec3 tir = reflect(ri, -ne);
  vec3 oR = refract(ri, -ne, 1.47); if (dot(oR, oR) < 1e-4) oR = tir;
  vec3 oG = refract(ri, -ne, 1.50); if (dot(oG, oG) < 1e-4) oG = tir;
  vec3 oB = refract(ri, -ne, 1.54); if (dot(oB, oB) < 1e-4) oB = tir;
  vec3 tr = vec3(env(oR).r, env(oG).g, env(oB).b);

  float G = ti * 1050.0
          + 230.0 * sin(dot(po, vec3(2.4, 3.2, 1.4)) * 1.3 + uTime * 0.9)
          + 150.0 * sin(po.y * 6.5 - po.x * 2.0 - uTime * 1.4);
  vec3 ml = michelLevy(max(G, 0.0));
  tr = tr * (0.4 + 0.9 * ml) + ml * mix(0.42, 0.5, uLight);

  vec3 col = mix(tr, refl, F);
  float spec = pow(max(dot(R, normalize(vec3(-0.45, 0.85, 0.55))), 0.0), 90.0);
  col += spec * (1.4 + film);
  col += pow(1.0 - cosT, 3.0) * film * 0.2;

  float sx = (uv.x * 0.8 + uv.y - uSweep) * 7.0;
  col += exp(-sx * sx) * uGlint * (0.5 + 0.7 * film);
  return col;
}

void main(){
  vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;

  gTy = rot(uTilt.y);
  gTx = rot(uTilt.x);
  gSpin = rot(uTime * 0.5);
  gWob = rot(0.16 * sin(uTime * 0.37));
  float m = clamp(1.0 - uOrder, 0.0, 1.0);
  gMelt = m * m * (3.0 - 2.0 * m);
  float orbit = mix(0.95, 0.0, smoothstep(0.05, 0.8, uOrder));
  float dr = 0.13 * (1.0 - smoothstep(0.55, 0.92, uOrder));
  gDropOn = dr > 0.002 ? 1.0 : 0.0;
  gD0 = dropPos(0.0, orbit); gD1 = dropPos(1.0, orbit);
  gD2 = dropPos(2.0, orbit); gD3 = dropPos(3.0, orbit);
  gDR = vec4(dropRad(0.0, dr), dropRad(1.0, dr), dropRad(2.0, dr), dropRad(3.0, dr));

  vec3 ro = vec3(0.0, 0.0, 4.0);
  vec3 rd = normalize(vec3(uv, -1.65));

  float b = dot(ro, rd);
  float c = dot(ro, ro) - 1.69;
  float h = b * b - c;
  if (h < 0.0){ gl_FragColor = vec4(0.0); return; }
  h = sqrt(h);

  vec3 res = march(ro, rd, max(-b - h, 0.0), -b + h);
  vec3 col;
  float alpha = 1.0;
  if (res.x > 0.0){
    col = shade(ro + rd * res.x, rd, uv);
  } else {
    float w = res.z / (uRes.y * 1.65) * 1.6;
    if (res.y > w){ gl_FragColor = vec4(0.0); return; }
    alpha = 1.0 - smoothstep(0.0, w, res.y);
    col = shade(ro + rd * res.z, rd, uv);
  }

  col *= mix(1.15, 1.4, uLight);
  col = 1.0 - exp(-col);
  gl_FragColor = vec4(col * alpha, alpha);
}
</script>

<script>
(() => {
  const root = document.documentElement;
  const orb = document.getElementById('orb');
  const canvas = document.getElementById('lc');
  const reduceMQ = matchMedia('(prefers-reduced-motion: reduce)');
  const darkMQ = matchMedia('(prefers-color-scheme: dark)');

  const isLight = () => {
    const t = root.getAttribute('data-theme');
    if (t === 'light') return true;
    if (t === 'dark') return false;
    return !darkMQ.matches;
  };

  // ---------- Animation cycle (seconds): liquid → crystallise → crystal → melt ----------
  const LIQUID = 1.8, FORM = 3.2, SOLID = 2.6, MELT = 1.8;
  const PERIOD = LIQUID + FORM + SOLID + MELT;
  const FORMED = LIQUID + FORM;
  const ease = x => x * x * (3 - 2 * x);
  function orderAt(c) {
    if (c < LIQUID) return 0;
    c -= LIQUID;
    if (c < FORM) return ease(c / FORM);
    c -= FORM;
    if (c < SOLID) return 1;
    c -= SOLID;
    return 1 - ease(Math.min(c / MELT, 1));
  }

  // ---------- WebGL setup ----------
  let gl = null, prog = null, U = {};
  try {
    gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, powerPreference: 'high-performance' });
  } catch (e) { gl = null; }

  function compile(type, src) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      console.error(gl.getShaderInfoLog(s));
      return null;
    }
    return s;
  }

  function initGL() {
    if (!gl) return false;
    const vs = compile(gl.VERTEX_SHADER, document.getElementById('vs').textContent);
    const fs = compile(gl.FRAGMENT_SHADER, document.getElementById('fs').textContent);
    if (!vs || !fs) return false;
    prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(prog));
      return false;
    }
    gl.useProgram(prog);
    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const loc = gl.getAttribLocation(prog, 'aPos');
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
    ['uRes', 'uTime', 'uOrder', 'uTilt', 'uLight', 'uSweep', 'uGlint'].forEach(n => U[n] = gl.getUniformLocation(prog, n));
    gl.clearColor(0, 0, 0, 0);
    return true;
  }

  let glOK = initGL();
  if (!glOK) orb.classList.add('no-gl');

  canvas.addEventListener('webglcontextlost', e => {
    e.preventDefault();
    glOK = false;
    orb.classList.add('no-gl');
  });

  // ---------- Sizing with adaptive quality ----------
  let scale = Math.min(window.devicePixelRatio || 1, 1.75);
  function resize() {
    const r = canvas.getBoundingClientRect();
    const w = Math.max(1, Math.round(r.width * scale));
    const h = Math.max(1, Math.round(r.height * scale));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }
  if ('ResizeObserver' in window) new ResizeObserver(resize).observe(canvas);
  window.addEventListener('resize', resize);
  resize();

  // ---------- Pointer tilt ----------
  const tilt = { x: 0, y: 0 }, tiltT = { x: 0, y: 0 };
  window.addEventListener('pointermove', e => {
    tiltT.x = (e.clientX / window.innerWidth - 0.5) * 0.9;
    tiltT.y = (e.clientY / window.innerHeight - 0.5) * 0.5;
  });
  document.addEventListener('pointerleave', () => { tiltT.x = 0; tiltT.y = 0; });

  // ---------- Loop ----------
  let last = performance.now();
  let shaderTime = 0, cycle = 0, light = isLight() ? 1 : 0;
  let glintAt = -1;
  let statSum = 0, statN = 0, warm = 0;

  function frame(now) {
    const rawDt = (now - last) / 1000;
    const dt = Math.min(rawDt, 0.1);
    last = now;
    const reduce = reduceMQ.matches;
    const speed = reduce ? 0.3 : 1;
    shaderTime += dt * speed;

    const prev = cycle;
    cycle = (cycle + dt * speed) % PERIOD;
    if (prev < FORMED && cycle >= FORMED && !reduce) {
      glintAt = now;
      const target = glOK ? canvas : orb.querySelector('.blob');
      if (target.animate) {
        target.animate(
          [{ transform: 'scale(1)' }, { transform: 'scale(1.04)', offset: 0.35 }, { transform: 'scale(1)' }],
          { duration: 800, easing: 'ease-out' }
        );
      }
    }
    const order = orderAt(cycle);

    light += ((isLight() ? 1 : 0) - light) * (1 - Math.exp(-dt * 6));
    tilt.x += (tiltT.x - tilt.x) * (1 - Math.exp(-dt * 3));
    tilt.y += (tiltT.y - tilt.y) * (1 - Math.exp(-dt * 3));

    let sweep = 0, glint = 0;
    if (glintAt >= 0) {
      const g = (now - glintAt) / 900;
      if (g <= 1) { sweep = -1.4 + 2.8 * g; glint = Math.sin(Math.PI * g); }
      else glintAt = -1;
    }

    orb.style.setProperty('--melt', (1 - order).toFixed(3));

    if (glOK) {
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.uniform2f(U.uRes, canvas.width, canvas.height);
      gl.uniform1f(U.uTime, shaderTime);
      gl.uniform1f(U.uOrder, order);
      gl.uniform2f(U.uTilt, tilt.x, 0.24 + tilt.y);
      gl.uniform1f(U.uLight, light);
      gl.uniform1f(U.uSweep, sweep);
      gl.uniform1f(U.uGlint, glint);
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      // drop resolution on slower GPUs (skip the first second and tab-switch spikes)
      warm += dt;
      if (warm > 1 && rawDt < 0.09) {
        statSum += rawDt; statN++;
        if (statN >= 45) {
          if (statSum / statN > 0.026 && scale > 0.6) {
            scale = Math.max(0.6, scale * 0.82);
            resize();
          }
          statSum = 0; statN = 0;
        }
      }
    }

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>