25

Cahn-Hilliard Spinodal Decomposition

tap to re-noise

The Cahn-Hilliard equation with double-well potential models phase separation of a binary mixture. Unlike Allen-Cahn, the right-hand side is the Laplacian of something, so total mass is conserved exactly — the two phases can't simply annihilate, they have to rearrange. From a noisy initial field the unstable mode at wavelength blows up first, freezing in a labyrinth of domains; from there the interfaces move to lower surface energy and the characteristic domain size grows as , the Lifshitz-Slyozov coarsening law for conserved order parameters. Solved on a periodic grid: at each step we form the chemical potential via a 5-point Laplacian, then advance — the biharmonic that limits stability is just one more Laplacian application. The field is rendered with a divergent purple → off-white → orange colormap and re-noised every twelve seconds so the coarsening cycle stays visible. Inspired by Gabriel Peyré's gradient-flow visualizations.

idle
139 lines · vanilla
view source
// Cahn-Hilliard spinodal decomposition on a periodic N x N grid.
//   du/dt = Laplacian( W'(u) - eps^2 * Laplacian(u) )
//   W(u)  = (1 - u^2)^2 / 4   =>   W'(u) = u^3 - u
// Explicit IMEX-ish solver: compute mu = W'(u) - eps^2 * lap(u),
// then advance u += dt * lap(mu).  The biharmonic term is the
// stability driver, so dt has to be small (we substep per frame).
// Mass is conserved exactly by construction (sum of Laplacian of
// anything periodic is zero up to roundoff).

const N = 128;
const EPS2 = 1.0;            // interface width^2 (in grid units)
// Explicit stability for the biharmonic requires dt < (dx)^4 / (16 * eps^2);
// with dx=1, eps^2=1 that's dt < 1/16 ≈ 0.0625. We pick 0.02 with a healthy
// margin (W''(u) at |u|=1 adds to the worst-case eigenvalue) and substep
// 40x per frame so coarsening still looks lively.
const DT_SIM = 0.02;         // sim timestep (was 0.05 — blew up to NaN)
const SUBSTEPS = 40;         // per render frame
const RESEED_SEC = 12;       // restart cycle for the loop
const U_CLAMP = 2.0;         // safety clamp on |u| after each step

let u, mu, lapU;
let img;                     // ImageData for the field buffer
let buf;                     // OffscreenCanvas at N x N
let bctx;
let W;
let H;
let elapsed;                 // sim-time since last reseed
let wallStart;               // wall clock at last reseed (for HUD)
let rngState;

// tiny deterministic PRNG (mulberry32) so reseeds are reproducible-ish
function rand() {
  rngState |= 0;
  rngState = (rngState + 0x6d2b79f5) | 0;
  let t = rngState;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}

function reseed() {
  // Small zero-mean noise around u=0 — symmetric mixture.
  for (let i = 0; i < N * N; i++) {
    u[i] = (rand() - 0.5) * 0.2;
  }
  elapsed = 0;
}

// Periodic 5-point Laplacian into `out`.
function laplacian(field, out) {
  for (let j = 0; j < N; j++) {
    const jm = j === 0 ? N - 1 : j - 1;
    const jp = j === N - 1 ? 0 : j + 1;
    const row = j * N;
    const rowM = jm * N;
    const rowP = jp * N;
    for (let i = 0; i < N; i++) {
      const im = i === 0 ? N - 1 : i - 1;
      const ip = i === N - 1 ? 0 : i + 1;
      out[row + i] =
        field[row + ip] + field[row + im] +
        field[rowP + i] + field[rowM + i] -
        4 * field[row + i];
    }
  }
}

function step(dt) {
  // mu = u^3 - u - eps^2 * lap(u)
  laplacian(u, lapU);
  for (let k = 0; k < N * N; k++) {
    const v = u[k];
    mu[k] = v * v * v - v - EPS2 * lapU[k];
  }
  // u += dt * lap(mu), then clamp to keep the explicit scheme from
  // running away if the user pokes a bad value or numeric drift hits.
  laplacian(mu, lapU);
  for (let k = 0; k < N * N; k++) {
    let v = u[k] + dt * lapU[k];
    if (!(v > -U_CLAMP)) v = -U_CLAMP;       // catches NaN too
    else if (v > U_CLAMP) v = U_CLAMP;
    u[k] = v;
  }
}

// Divergent purple <-> off-white <-> orange colormap.
// u in roughly [-1, 1].  Map to t in [0, 1].
const C_PURPLE = [60, 30, 90];     // deep purple
const C_MID    = [245, 240, 230];  // warm off-white
const C_ORANGE = [225, 120, 40];   // burnt orange

function colormap(v, out, off) {
  // clamp to [-1.15, 1.15] and rescale
  let t = v;
  if (t < -1.15) t = -1.15;
  else if (t > 1.15) t = 1.15;
  t = (t + 1.15) / 2.3;
  let r, g, b;
  if (t < 0.5) {
    const s = t / 0.5;
    r = C_PURPLE[0] + (C_MID[0] - C_PURPLE[0]) * s;
    g = C_PURPLE[1] + (C_MID[1] - C_PURPLE[1]) * s;
    b = C_PURPLE[2] + (C_MID[2] - C_PURPLE[2]) * s;
  } else {
    const s = (t - 0.5) / 0.5;
    r = C_MID[0] + (C_ORANGE[0] - C_MID[0]) * s;
    g = C_MID[1] + (C_ORANGE[1] - C_MID[1]) * s;
    b = C_MID[2] + (C_ORANGE[2] - C_MID[2]) * s;
  }
  out[off] = r;
  out[off + 1] = g;
  out[off + 2] = b;
  out[off + 3] = 255;
}

function render() {
  const d = img.data;
  for (let k = 0; k < N * N; k++) {
    colormap(u[k], d, k * 4);
  }
  bctx.putImageData(img, 0, 0);
}

function init({ ctx, width, height }) {
  W = width;
  H = height;
  u = new Float32Array(N * N);
  mu = new Float32Array(N * N);
  lapU = new Float32Array(N * N);
  buf = new OffscreenCanvas(N, N);
  bctx = buf.getContext('2d');
  img = bctx.createImageData(N, N);
  rngState = 0x9e3779b9;
  reseed();
  // burn in a few steps so frame 1 isn't flat noise
  for (let i = 0; i < 40; i++) step(DT_SIM);
  wallStart = 0;
  // background
  ctx.fillStyle = '#1a0e22';
  ctx.fillRect(0, 0, W, H);
}

function tick({ ctx, dt, time, width, height, input }) {
  if (width !== W || height !== H) {
    W = width;
    H = height;
  }

  // tap to re-noise (also resets the 12s loop timer)
  if (input && input.consumeClicks && input.consumeClicks() > 0) {
    reseed();
    wallStart = time;
    for (let i = 0; i < 40; i++) step(DT_SIM);
  }

  // advance sim
  for (let i = 0; i < SUBSTEPS; i++) step(DT_SIM);
  elapsed += dt;

  // reseed loop
  if (elapsed > RESEED_SEC) {
    reseed();
    wallStart = time;
    for (let i = 0; i < 40; i++) step(DT_SIM);
  }

  render();

  // letterbox background
  ctx.fillStyle = '#1a0e22';
  ctx.fillRect(0, 0, W, H);

  // fit the square field into the canvas, centered
  const side = Math.min(W, H);
  const ox = (W - side) * 0.5;
  const oy = (H - side) * 0.5;
  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(buf, ox, oy, side, side);

  // subtle elapsed-time tick in the corner (Peyré-style minimal HUD)
  const t = elapsed.toFixed(1);
  ctx.font = '11px ui-monospace, monospace';
  ctx.fillStyle = 'rgba(245, 240, 230, 0.55)';
  ctx.textBaseline = 'top';
  ctx.fillText(`t = ${t}`, ox + 8, oy + 6);
}

Comments (0)

Log in to comment.