12

Mantle Convection (3D)

drag to orbit · drag Y to scrub Ra

A 2D Boussinesq fluid heated from below and cooled from above — a toy model of Earth's mantle in Rayleigh-Bénard form. The Rayleigh number measures buoyancy against the diffusive damping that wants to keep the fluid still; below the critical value (Rayleigh, 1916) conduction wins and nothing moves, above it convection rolls organize, and well above it the rolls go unsteady and shed plumes. Numerics on a colocated grid: semi-Lagrangian advection of velocity and temperature, explicit viscous and thermal diffusion, buoyancy injected into the vertical component as , then Jacobi pressure projection enforces . Periodic side walls, no-slip top and bottom with Dirichlet temperature. Drag horizontally to orbit the cell, drag vertically to scrub Ra across four orders of magnitude — the readout calls out the regime.

idle
352 lines · three
view source
// Mantle convection — 2D Boussinesq fluid (Rayleigh-Bénard) rendered as a
// textured slab inside a wireframe 3D cell. Drag horizontally to orbit,
// drag vertically to scrub the Rayleigh number across the critical value
// Ra_c ≈ 1708. Below Ra_c the fluid sits still (pure conduction, smooth
// vertical gradient). Above it, convection rolls organize. Push Ra high
// enough and the rolls turn unsteady, shedding plumes.
//
// Numerics: colocated grid, semi-Lagrangian advection for u/v/T, explicit
// viscous + thermal diffusion, buoyancy injected into v, then Jacobi
// pressure projection (∇²p = ∇·u/dt, then u ← u − ∇p · dt) to enforce
// ∇·u = 0. Periodic in x, no-slip + fixed-T walls in y.

const NX = 64;
const NY = 40;
const PROJ_ITERS = 24;
const SUBSTEPS = 2;
const DT = 0.018;

// Domain length scales: width = 2, height = 1 (aspect 2:1).
const DX = 2.0 / NX;
const DY = 1.0 / NY;

// Dimensionless. Ra is the knob; Pr fixed.
const PR = 1.0;
let RA = 5000;     // mutable via vertical drag
const RA_MIN = 200;
const RA_MAX = 80000;
const RA_C = 1708;

// Fields
let u, v, T;        // primary
let uTmp, vTmp, tTmp;
let p, div;         // for pressure projection

// THREE objects
let slabMesh, slabTex, slabData;
let cellBox;
let raSprite, raCtx, raCanvas, raTex;

// Camera
let userYaw = 0;
let pitch = -0.35;
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
let idleTime = 0;
let elapsed = 0;

function idx(i, j) { return j * NX + i; }
function wrapX(i) { return ((i % NX) + NX) % NX; }
function clampY(j) { return j < 0 ? 0 : (j > NY - 1 ? NY - 1 : j); }

function makeField(initVal) {
  const a = new Float32Array(NX * NY);
  if (initVal !== 0) a.fill(initVal);
  return a;
}

function resetFields() {
  u = makeField(0);
  v = makeField(0);
  T = makeField(0);
  uTmp = makeField(0);
  vTmp = makeField(0);
  tTmp = makeField(0);
  p = makeField(0);
  div = makeField(0);
  // Initial temperature: linear gradient (cold top, hot bottom) plus tiny noise to seed instability.
  for (let j = 0; j < NY; j++) {
    const base = 1 - (j + 0.5) / NY;
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      T[k] = base + (Math.random() - 0.5) * 0.01;
    }
  }
}

// Bilinear sample, periodic in x, clamped in y.
function sampleField(f, x, y) {
  // x, y in grid units (cell centers at i+0.5, j+0.5)
  let xi = x - 0.5;
  let yj = y - 0.5;
  let i0 = Math.floor(xi);
  let j0 = Math.floor(yj);
  const fx = xi - i0;
  const fy = yj - j0;
  const i0w = wrapX(i0);
  const i1w = wrapX(i0 + 1);
  const j0c = clampY(j0);
  const j1c = clampY(j0 + 1);
  const a = f[idx(i0w, j0c)];
  const b = f[idx(i1w, j0c)];
  const c = f[idx(i0w, j1c)];
  const d = f[idx(i1w, j1c)];
  const ab = a + (b - a) * fx;
  const cd = c + (d - c) * fx;
  return ab + (cd - ab) * fy;
}

// Sample T with Dirichlet walls (T=1 below domain, T=0 above).
function sampleT(x, y) {
  let xi = x - 0.5;
  let yj = y - 0.5;
  let i0 = Math.floor(xi);
  let j0 = Math.floor(yj);
  const fx = xi - i0;
  const fy = yj - j0;
  const i0w = wrapX(i0);
  const i1w = wrapX(i0 + 1);
  const getRow = (jr) => {
    if (jr < 0) return [1, 1];
    if (jr > NY - 1) return [0, 0];
    return [T[idx(i0w, jr)], T[idx(i1w, jr)]];
  };
  const [a, b] = getRow(j0);
  const [c, d] = getRow(j0 + 1);
  const ab = a + (b - a) * fx;
  const cd = c + (d - c) * fx;
  return ab + (cd - ab) * fy;
}

function advect(dt) {
  // Semi-Lagrangian: trace back from each cell center along velocity.
  for (let j = 0; j < NY; j++) {
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      // Cell center position in grid units.
      const x = i + 0.5;
      const y = j + 0.5;
      // Convert velocity (in dimensionless physical units, scaled by DX/DY)
      // to grid-unit displacement. We treat one grid cell == one unit here:
      // u is already in physical units where 1 grid cell width = DX. So
      // back-trace by velocity * dt / DX in x and DY in y.
      const px = x - u[k] * dt / DX;
      const py = y - v[k] * dt / DY;
      uTmp[k] = sampleField(u, px, py);
      vTmp[k] = sampleField(v, px, py);
      tTmp[k] = sampleT(px, py);
    }
  }
  // Swap
  let tmp = u; u = uTmp; uTmp = tmp;
  tmp = v; v = vTmp; vTmp = tmp;
  tmp = T; T = tTmp; tTmp = tmp;
}

function diffuseAndBuoyancy(dt) {
  // Pr = nu / kappa. Pick units so kappa is what Ra reflects.
  // Effective diffusion coefficients (lattice-stable explicit step):
  // nu_grid = nu * dt / DX^2 must stay small. We pick small base values.
  const nu = PR * 0.004;
  const kappa = 0.004;
  // Buoyancy coefficient: derive from Ra. Ra = g*beta*dT*L^3 / (nu*kappa);
  // with L=1, dT=1, so g*beta = Ra * nu * kappa.
  const buoy = RA * nu * kappa;

  // Velocity viscous diffusion (5-point Laplacian, periodic x, no-slip y means u=v=0 at walls).
  for (let j = 0; j < NY; j++) {
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      const iL = idx(wrapX(i - 1), j);
      const iR = idx(wrapX(i + 1), j);
      const uU = (j === NY - 1) ? -u[k] : u[idx(i, j + 1)]; // no-slip: ghost = -interior
      const uD = (j === 0)      ? -u[k] : u[idx(i, j - 1)];
      const vU = (j === NY - 1) ? -v[k] : v[idx(i, j + 1)];
      const vD = (j === 0)      ? -v[k] : v[idx(i, j - 1)];
      const lapU = (u[iL] + u[iR] - 2 * u[k]) / (DX * DX)
                 + (uU + uD - 2 * u[k]) / (DY * DY);
      const lapV = (v[iL] + v[iR] - 2 * v[k]) / (DX * DX)
                 + (vU + vD - 2 * v[k]) / (DY * DY);
      uTmp[k] = u[k] + dt * nu * lapU;
      vTmp[k] = v[k] + dt * nu * lapV;
    }
  }
  let tmp = u; u = uTmp; uTmp = tmp;
  tmp = v; v = vTmp; vTmp = tmp;

  // Thermal diffusion (Dirichlet T=1 at j=-1, T=0 at j=NY).
  for (let j = 0; j < NY; j++) {
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      const iL = idx(wrapX(i - 1), j);
      const iR = idx(wrapX(i + 1), j);
      const tU = (j === NY - 1) ? (2 * 0 - T[k]) : T[idx(i, j + 1)];
      const tD = (j === 0)      ? (2 * 1 - T[k]) : T[idx(i, j - 1)];
      const lapT = (T[iL] + T[iR] - 2 * T[k]) / (DX * DX)
                 + (tU + tD - 2 * T[k]) / (DY * DY);
      tTmp[k] = T[k] + dt * kappa * lapT;
    }
  }
  tmp = T; T = tTmp; tTmp = tmp;

  // Buoyancy: dv/dt += buoy * (T - <T_horiz_mean>). Subtract horizontal mean
  // so the constant conduction profile doesn't generate spurious flow.
  for (let j = 0; j < NY; j++) {
    // horizontal mean at this row
    let s = 0;
    for (let i = 0; i < NX; i++) s += T[idx(i, j)];
    const mean = s / NX;
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      v[k] += dt * buoy * (T[k] - mean);
    }
  }
}

function project(dt) {
  // ∇·u in dimensionless grid form
  for (let j = 0; j < NY; j++) {
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      const uR = u[idx(wrapX(i + 1), j)];
      const uL = u[idx(wrapX(i - 1), j)];
      const vU = (j === NY - 1) ? 0 : v[idx(i, j + 1)];
      const vD = (j === 0)      ? 0 : v[idx(i, j - 1)];
      div[k] = ((uR - uL) / (2 * DX) + (vU - vD) / (2 * DY)) / dt;
      p[k] = 0;
    }
  }
  // Jacobi iterations: ∇²p = div  →  p_ij = (avg neighbors - h² div)/4 (with anisotropic spacing)
  const ax = 1 / (DX * DX), ay = 1 / (DY * DY);
  const ac = 2 * (ax + ay);
  for (let it = 0; it < PROJ_ITERS; it++) {
    for (let j = 0; j < NY; j++) {
      for (let i = 0; i < NX; i++) {
        const k = idx(i, j);
        const pL = p[idx(wrapX(i - 1), j)];
        const pR = p[idx(wrapX(i + 1), j)];
        // Neumann at y walls (dp/dy = 0)
        const pU = (j === NY - 1) ? p[k] : p[idx(i, j + 1)];
        const pD = (j === 0)      ? p[k] : p[idx(i, j - 1)];
        p[k] = (ax * (pL + pR) + ay * (pU + pD) - div[k]) / ac;
      }
    }
  }
  // Subtract gradient.
  for (let j = 0; j < NY; j++) {
    for (let i = 0; i < NX; i++) {
      const k = idx(i, j);
      const pR = p[idx(wrapX(i + 1), j)];
      const pL = p[idx(wrapX(i - 1), j)];
      const pU = (j === NY - 1) ? p[k] : p[idx(i, j + 1)];
      const pD = (j === 0)      ? p[k] : p[idx(i, j - 1)];
      u[k] -= dt * (pR - pL) / (2 * DX);
      v[k] -= dt * (pU - pD) / (2 * DY);
      // Re-enforce no-slip at walls.
      if (j === 0 || j === NY - 1) { u[k] = 0; v[k] = 0; }
    }
  }
}

function step(dt) {
  advect(dt);
  diffuseAndBuoyancy(dt);
  project(dt);
}

// ---- temperature → color (a thermal palette) ----
function tempToRGB(t) {
  // Clamp
  const x = Math.max(0, Math.min(1, t));
  // 4-stop palette: deep blue → cyan → yellow → red
  const stops = [
    [0.00, 0.05, 0.10, 0.30],
    [0.35, 0.10, 0.55, 0.85],
    [0.65, 0.95, 0.85, 0.25],
    [1.00, 0.95, 0.20, 0.10],
  ];
  for (let s = 0; s < stops.length - 1; s++) {
    if (x <= stops[s + 1][0]) {
      const t0 = stops[s][0], t1 = stops[s + 1][0];
      const f = (x - t0) / (t1 - t0);
      return [
        stops[s][1] + (stops[s + 1][1] - stops[s][1]) * f,
        stops[s][2] + (stops[s + 1][2] - stops[s][2]) * f,
        stops[s][3] + (stops[s + 1][3] - stops[s][3]) * f,
      ];
    }
  }
  return [stops[stops.length - 1][1], stops[stops.length - 1][2], stops[stops.length - 1][3]];
}

function writeSlabTexture() {
  for (let j = 0; j < NY; j++) {
    for (let i = 0; i < NX; i++) {
      const t = T[idx(i, j)];
      const [r, g, b] = tempToRGB(t);
      const o = (j * NX + i) * 4;
      slabData[o]     = (r * 255) | 0;
      slabData[o + 1] = (g * 255) | 0;
      slabData[o + 2] = (b * 255) | 0;
      slabData[o + 3] = 255;
    }
  }
  slabTex.needsUpdate = true;
}

function regimeLabel(ra) {
  if (ra < RA_C * 0.95) return "conduction";
  if (ra < RA_C * 1.6) return "onset";
  if (ra < 12000) return "steady rolls";
  if (ra < 35000) return "unsteady rolls";
  return "plume regime";
}

function updateRaReadout() {
  raCtx.clearRect(0, 0, raCanvas.width, raCanvas.height);
  raCtx.fillStyle = "rgba(8,10,18,0.78)";
  raCtx.fillRect(0, 0, raCanvas.width, raCanvas.height);
  raCtx.fillStyle = "#e5ecff";
  raCtx.font = "bold 44px ui-monospace, monospace";
  raCtx.textBaseline = "top";
  raCtx.fillText("Ra = " + Math.round(RA).toString(), 16, 10);
  raCtx.font = "30px ui-monospace, monospace";
  raCtx.fillStyle = RA < RA_C ? "#7fb6ff" : "#ffb070";
  raCtx.fillText(regimeLabel(RA), 16, 64);
  raCtx.font = "22px ui-monospace, monospace";
  raCtx.fillStyle = "#8c93a8";
  raCtx.fillText("Ra_c ≈ " + RA_C, 16, 104);
  raTex.needsUpdate = true;
}

function init({ scene, camera, renderer, width, height }) {
  renderer.setClearColor(0x06080f, 1);
  camera.position.set(0, 0.6, 3.2);
  camera.lookAt(0, 0, 0);

  // Soft directional light — gives the wireframe box some depth cue.
  const amb = new THREE.AmbientLight(0xffffff, 0.6);
  scene.add(amb);
  const dir = new THREE.DirectionalLight(0xffffff, 0.5);
  dir.position.set(2, 3, 4);
  scene.add(dir);

  resetFields();

  // Slab: a thin plane in the X-Y plane (width 2.4, height 1.2) textured with T.
  slabData = new Uint8Array(NX * NY * 4);
  slabTex = new THREE.DataTexture(slabData, NX, NY, THREE.RGBAFormat);
  slabTex.magFilter = THREE.LinearFilter;
  slabTex.minFilter = THREE.LinearFilter;
  slabTex.wrapS = THREE.RepeatWrapping;
  slabTex.needsUpdate = true;
  const slabGeo = new THREE.PlaneGeometry(2.4, 1.2);
  const slabMat = new THREE.MeshBasicMaterial({ map: slabTex });
  slabMesh = new THREE.Mesh(slabGeo, slabMat);
  scene.add(slabMesh);

  // Wireframe cell box around it: thin box 2.4 × 1.2 × 0.04.
  const boxGeo = new THREE.BoxGeometry(2.42, 1.22, 0.06);
  const edges = new THREE.EdgesGeometry(boxGeo);
  cellBox = new THREE.LineSegments(
    edges,
    new THREE.LineBasicMaterial({ color: 0x9fb3ff, transparent: true, opacity: 0.55 }),
  );
  scene.add(cellBox);

  // Top/bottom thermal-boundary indicator bars (cold blue strip on top, hot orange below).
  const hotGeo = new THREE.BoxGeometry(2.42, 0.045, 0.07);
  const hotMat = new THREE.MeshBasicMaterial({ color: 0xff5a2a });
  const hotBar = new THREE.Mesh(hotGeo, hotMat);
  hotBar.position.set(0, -0.62, 0);
  scene.add(hotBar);
  const coldMat = new THREE.MeshBasicMaterial({ color: 0x2a78ff });
  const coldBar = new THREE.Mesh(hotGeo, coldMat);
  coldBar.position.set(0, 0.62, 0);
  scene.add(coldBar);

  // Ra readout: a CanvasTexture sprite that floats above the cell.
  raCanvas = document.createElement("canvas");
  raCanvas.width = 360;
  raCanvas.height = 140;
  raCtx = raCanvas.getContext("2d");
  raTex = new THREE.CanvasTexture(raCanvas);
  raTex.minFilter = THREE.LinearFilter;
  const raMat = new THREE.SpriteMaterial({ map: raTex, transparent: true });
  raSprite = new THREE.Sprite(raMat);
  raSprite.scale.set(1.1, 0.43, 1);
  raSprite.position.set(-0.6, 0.95, 0);
  scene.add(raSprite);
  updateRaReadout();

  // Burn-in: a few steps so the initial noise resolves before render.
  for (let i = 0; i < 8; i++) step(DT);
  writeSlabTexture();

  return { scene, camera };
}

function tick({ dt, time, scene, camera, renderer, width, height, input }) {
  elapsed += dt;
  const clicks = input.consumeClicks();
  void clicks;

  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      // Horizontal drag orbits the camera around Y.
      userYaw -= dx * 0.006;
      // Vertical drag scrubs Ra logarithmically — feels good across 200..80000.
      if (dy !== 0) {
        const logMin = Math.log(RA_MIN);
        const logMax = Math.log(RA_MAX);
        const cur = Math.log(RA);
        // dy in canvas pixels; map full canvas height ≈ full range.
        const span = logMax - logMin;
        const next = cur + dy / Math.max(120, height) * span;
        RA = Math.exp(Math.max(logMin, Math.min(logMax, next)));
        updateRaReadout();
      }
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
    idleTime = 0;
  } else {
    isDragging = false;
    idleTime += dt;
    if (idleTime > 1.5) userYaw += dt * 0.16;
  }

  // Camera orbit
  const r = 3.0;
  const cy = Math.cos(userYaw), sy = Math.sin(userYaw);
  const cp = Math.cos(pitch), sp = Math.sin(pitch);
  camera.position.set(r * cp * sy, -r * sp, r * cp * cy);
  camera.lookAt(0, 0, 0);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // Keep the slab + Ra sprite facing the camera-ish: actually, the slab is
  // a flat plane and we want to *see* it edge-on sometimes — that's the
  // point of orbiting. Leave it fixed; only billboard the Ra sprite (sprites
  // are camera-aligned automatically).

  // Sim sub-steps. Skip when Ra is sub-critical and the field is essentially
  // frozen — but still advance diffusion so the conductive profile settles.
  for (let s = 0; s < SUBSTEPS; s++) step(DT);

  writeSlabTexture();

  // Subtle pulse on the cell box opacity tied to flow strength.
  let kin = 0;
  for (let k = 0; k < u.length; k += 8) kin += u[k] * u[k] + v[k] * v[k];
  const intensity = Math.min(1, Math.sqrt(kin / (u.length / 8)) * 4);
  cellBox.material.opacity = 0.4 + 0.4 * intensity;

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.