0

River Meander & Oxbow Lakes

drag Y for erosion rate · click to seed

A 2D river channel — a polyline crossing the canvas left-to-right — evolves under a curvature-driven erosion rule. At each interior point the local discrete curvature is estimated from the segments meeting at ; the point migrates along the outward normal of the bend at velocity , where is a global erosion scale set by . Because the outward normal is the cut bank and the inward side is the depositional point bar, bends amplify over time: a gentle wiggle stretches into a horseshoe, then into a near-closed loop. The polyline is re-spaced each frame (split where neighbors drift past px, merged where they close to px) so arc length can grow as the river lengthens. When two non-adjacent points approach within px, a neck cutoff fires: the river takes the shortcut, and the abandoned loop becomes an oxbow lake — drawn in muted blue and slowly fading to floodplain. Click anywhere to splice a fresh, lightly sinuous segment around the cursor's , restarting the meander game in that stretch.

idle
220 lines · vanilla
view source
// River meander evolution. A sinuous channel crosses the canvas left-to-right
// as a discrete polyline of points. Each frame:
//   1. Local curvature κ_i is estimated from neighbors.
//   2. The point migrates along the local outward normal at speed v_i = E·κ_i,
//      where E is a global erosion scale (driven by mouseY).
//   3. The polyline is re-spaced — too-close neighbors merged, too-far ones
//      split — so the channel can grow arc length as bends amplify.
//   4. Non-adjacent points within DCUT are detected: this is a neck cutoff.
//      The bypassed loop is harvested as an "oxbow" lake and the main channel
//      takes the shortcut.
//
// Click drops a fresh ~straight segment with light sinuosity, replacing the
// nearest stretch around the cursor x. mouseY scrubs the erosion rate from
// near-frozen (top) to runaway (bottom). Oxbows fade with age and eventually
// vanish, so the channel can keep evolving indefinitely without unbounded
// memory.

const MIN_SEG = 6;          // px — split if neighbors farther than ~2×MIN_SEG
const MAX_SEG = 14;
const DCUT2 = 64;           // (8 px)^2 cutoff distance for a neck
const NECK_GAP = 12;        // index gap below which we DON'T cut (adjacent bends)
const MAX_POINTS = 1400;    // hard cap on polyline length to keep tick fast
const MAX_OXBOWS = 32;      // ring buffer; oldest evicted

let W = 0, H = 0;
let xs, ys;                 // polyline coords (typed arrays, length 'n')
let n = 0;                  // current point count
let tmpX, tmpY;             // scratch for re-spacing pass
let oxbows = [];            // { xs: Float32Array, ys: Float32Array, age: 0..1 }
let erosion = 1.0;          // multiplier; modulated by mouseY each tick
let lastCutTime = 0;

function reseed() {
  // Build a ~horizontal channel from left edge to right edge with mild
  // sinusoidal perturbation; this is the "young" river.
  const targetN = Math.max(120, Math.floor(W / 9));
  n = Math.min(targetN, MAX_POINTS);
  for (let i = 0; i < n; i++) {
    const u = i / (n - 1);
    const x = u * W;
    // base wiggle: low-frequency sin + small high-freq noise
    const baseY = H * 0.5
      + Math.sin(u * Math.PI * 3.0 + Math.random() * 0.2) * H * 0.06
      + Math.sin(u * Math.PI * 11.0) * H * 0.012;
    xs[i] = x;
    ys[i] = baseY;
  }
}

function init({ width, height }) {
  W = width; H = height;
  xs = new Float32Array(MAX_POINTS);
  ys = new Float32Array(MAX_POINTS);
  tmpX = new Float32Array(MAX_POINTS);
  tmpY = new Float32Array(MAX_POINTS);
  oxbows = [];
  reseed();
}

// Replace a window of indices around cursor x with a fresh slightly-wavy
// segment. Keeps endpoints anchored at the canvas edges.
function injectSegment(cx, cy) {
  // Find indices flanking cx
  let i0 = 0, i1 = n - 1;
  for (let i = 0; i < n; i++) {
    if (xs[i] < cx) i0 = i;
    if (xs[n - 1 - i] > cx) i1 = n - 1 - i;
  }
  const spanLeft = Math.max(0, i0 - Math.floor(n * 0.18));
  const spanRight = Math.min(n - 1, i1 + Math.floor(n * 0.18));
  const xL = xs[spanLeft], yL = ys[spanLeft];
  const xR = xs[spanRight], yR = ys[spanRight];
  const count = spanRight - spanLeft;
  if (count < 4) return;
  // Random sinuosity params
  const phase = Math.random() * Math.PI * 2;
  const freq = 2 + Math.random() * 3;
  const amp = (H * 0.05 + Math.random() * H * 0.06) * (Math.random() < 0.5 ? -1 : 1);
  const cyBias = (cy - (yL + yR) * 0.5) * 0.5;
  for (let k = 1; k < count; k++) {
    const u = k / count;
    const x = xL + (xR - xL) * u;
    const yLine = yL + (yR - yL) * u;
    const env = Math.sin(Math.PI * u);  // 0 at endpoints, 1 in the middle
    ys[spanLeft + k] = yLine + env * (Math.sin(u * Math.PI * freq + phase) * amp + cyBias);
    xs[spanLeft + k] = x;
  }
}

// Detect a neck cutoff and rewire. Returns true if a cut happened.
function tryCutoff() {
  // Only scan a fraction per frame for cost; round-robin via a static offset.
  // We do a sparse i, j search: i strides by 2, j strides by 2.
  for (let i = 2; i < n - NECK_GAP - 2; i += 2) {
    const xi = xs[i], yi = ys[i];
    for (let j = i + NECK_GAP; j < n - 2; j += 2) {
      const dx = xs[j] - xi;
      const dy = ys[j] - yi;
      const d2 = dx * dx + dy * dy;
      if (d2 < DCUT2) {
        // Cut: harvest indices (i+1..j-1) as an oxbow, then splice them out.
        const loopLen = j - i - 1;
        if (loopLen >= 8) {
          // include i and j to close the loop visually
          const ox = new Float32Array(loopLen + 2);
          const oy = new Float32Array(loopLen + 2);
          for (let k = 0; k <= loopLen + 1; k++) {
            ox[k] = xs[i + k];
            oy[k] = ys[i + k];
          }
          if (oxbows.length >= MAX_OXBOWS) oxbows.shift();
          oxbows.push({ xs: ox, ys: oy, age: 0 });
        }
        // splice: shift everything from j down to i+1
        const shift = j - (i + 1);
        for (let k = i + 1; k + shift < n; k++) {
          xs[k] = xs[k + shift];
          ys[k] = ys[k + shift];
        }
        n -= shift;
        return true;
      }
    }
  }
  return false;
}

// Re-space the polyline: drop too-close points, insert midpoints where gaps
// stretch beyond MAX_SEG. Endpoints (anchored to canvas edges) are kept.
function respace() {
  let m = 0;
  tmpX[m] = xs[0]; tmpY[m] = ys[0]; m++;
  for (let i = 1; i < n; i++) {
    const dx = xs[i] - tmpX[m - 1];
    const dy = ys[i] - tmpY[m - 1];
    const d = Math.sqrt(dx * dx + dy * dy);
    if (d < MIN_SEG && i < n - 1) continue;  // drop (but never drop endpoint)
    if (d > MAX_SEG && m < MAX_POINTS - 2) {
      // insert one midpoint, then the original
      tmpX[m] = tmpX[m - 1] + dx * 0.5;
      tmpY[m] = tmpY[m - 1] + dy * 0.5;
      m++;
      if (m >= MAX_POINTS - 1) break;
    }
    tmpX[m] = xs[i]; tmpY[m] = ys[i]; m++;
    if (m >= MAX_POINTS - 1) break;
  }
  // Anchor endpoints to canvas edges so the river always crosses the screen.
  tmpX[0] = 0;
  tmpX[m - 1] = W;
  for (let i = 0; i < m; i++) { xs[i] = tmpX[i]; ys[i] = tmpY[i]; }
  n = m;
}

function tick({ ctx, dt, time, width, height, input }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    // Rescale x-coords proportionally so the channel still spans the canvas.
    if (n > 1) {
      const oldRight = xs[n - 1] || 1;
      const sx = W / oldRight;
      for (let i = 0; i < n; i++) xs[i] *= sx;
      // Clamp y into bounds.
      for (let i = 0; i < n; i++) ys[i] = Math.max(8, Math.min(H - 8, ys[i]));
    } else {
      reseed();
    }
  }
  if (dt > 0.05) dt = 0.05;

  // --- input ---
  // mouseY scrubs erosion rate (top = frozen, bottom = runaway)
  const my = input ? input.mouseY : H * 0.5;
  erosion = Math.max(0.05, Math.min(3.0, (my / H) * 3.0));

  if (input) {
    const clicks = input.consumeClicks();
    if (clicks && clicks.length) {
      const c = clicks[clicks.length - 1];
      injectSegment(c.x, c.y);
    }
  }

  // --- physics: curvature-driven migration ---
  // For each interior point i, compute the discrete curvature using the
  // segment-midpoint approach. Move the point along its local outward normal
  // by v = k * erosion * dt.
  // Skip endpoints (anchored at x=0 and x=W).
  // Time-step gain — keep things visibly evolving but not exploding.
  const GAIN = 38;
  for (let i = 1; i < n - 1; i++) {
    const x0 = xs[i - 1], y0 = ys[i - 1];
    const x1 = xs[i],     y1 = ys[i];
    const x2 = xs[i + 1], y2 = ys[i + 1];
    // Tangent vectors before/after
    const ax = x1 - x0, ay = y1 - y0;
    const bx = x2 - x1, by = y2 - y1;
    const la = Math.sqrt(ax * ax + ay * ay) + 1e-6;
    const lb = Math.sqrt(bx * bx + by * by) + 1e-6;
    // Average tangent
    const tx = (ax / la + bx / lb) * 0.5;
    const ty = (ay / la + by / lb) * 0.5;
    const tl = Math.sqrt(tx * tx + ty * ty) + 1e-6;
    // Outward normal (rotate tangent 90°). Sign chosen by cross(ab) so it
    // points toward the OUTSIDE of the bend — that's where the cut bank is.
    const cross = ax * by - ay * bx;                 // > 0 => bend turns left
    const nxn = -ty / tl;
    const nyn =  tx / tl;
    const sign = cross >= 0 ? 1 : -1;
    // Curvature magnitude ~ |cross| / (la*lb*(la+lb)/2)
    const k = Math.abs(cross) / (la * lb * (la + lb) * 0.5 + 1e-6);
    const v = k * erosion * GAIN;
    let nx = x1 + nxn * sign * v * dt;
    let ny = y1 + nyn * sign * v * dt;
    // Vertical clamp; horizontal allowed to drift but kept on canvas
    if (ny < 8) ny = 8;
    else if (ny > H - 8) ny = H - 8;
    if (nx < 1) nx = 1;
    else if (nx > W - 1) nx = W - 1;
    // Lateral smoothing — a touch of "viscosity" to prevent zigzag instability.
    const sm = 0.18;
    xs[i] = nx * (1 - sm) + (x0 + x2) * 0.5 * sm;
    ys[i] = ny * (1 - sm) + (y0 + y2) * 0.5 * sm;
  }

  respace();

  // Limit cutoff search frequency — once per ~80ms is plenty and cheap.
  if (time - lastCutTime > 0.08) {
    if (tryCutoff()) lastCutTime = time;
    else lastCutTime = time;
  }

  // Age oxbows
  for (let i = 0; i < oxbows.length; i++) oxbows[i].age += dt * 0.05;
  // Drop fully faded
  while (oxbows.length && oxbows[0].age > 1) oxbows.shift();

  // --- render ---
  // Background — pale floodplain with a faint horizontal banding to evoke
  // sediment layers. Repaint cheaply each frame.
  ctx.fillStyle = '#1a140d';
  ctx.fillRect(0, 0, W, H);
  // sediment bands
  ctx.globalAlpha = 0.35;
  for (let y = 0; y < H; y += 12) {
    const t = (y / H);
    const shade = 24 + (Math.sin(y * 0.13) * 6) | 0;
    ctx.fillStyle = `rgb(${30 + shade},${22 + (shade * 0.6) | 0},${14 + (shade * 0.4) | 0})`;
    ctx.fillRect(0, y, W, 6);
  }
  ctx.globalAlpha = 1;

  // Oxbow lakes — drawn beneath the active channel.
  for (let i = 0; i < oxbows.length; i++) {
    const ob = oxbows[i];
    const a = (1 - ob.age) * 0.85;
    if (a <= 0) continue;
    ctx.lineWidth = 5;
    ctx.lineJoin = 'round';
    ctx.strokeStyle = `rgba(60, 95, 130, ${a.toFixed(3)})`;
    ctx.fillStyle = `rgba(45, 85, 120, ${(a * 0.55).toFixed(3)})`;
    ctx.beginPath();
    ctx.moveTo(ob.xs[0], ob.ys[0]);
    for (let k = 1; k < ob.xs.length; k++) ctx.lineTo(ob.xs[k], ob.ys[k]);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    // glint
    ctx.strokeStyle = `rgba(180, 210, 230, ${(a * 0.35).toFixed(3)})`;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(ob.xs[0], ob.ys[0]);
    for (let k = 1; k < ob.xs.length; k += 2) ctx.lineTo(ob.xs[k], ob.ys[k]);
    ctx.stroke();
  }

  // Active channel — wide deep stroke + narrow highlight on top
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.strokeStyle = '#2d6a96';
  ctx.lineWidth = 7;
  ctx.beginPath();
  ctx.moveTo(xs[0], ys[0]);
  for (let i = 1; i < n; i++) ctx.lineTo(xs[i], ys[i]);
  ctx.stroke();
  ctx.strokeStyle = '#7fb6d8';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(xs[0], ys[0]);
  for (let i = 1; i < n; i++) ctx.lineTo(xs[i], ys[i]);
  ctx.stroke();

  // HUD: erosion rate readout, top-left
  ctx.fillStyle = 'rgba(255,255,255,0.78)';
  ctx.font = '12px sans-serif';
  ctx.textBaseline = 'top';
  ctx.fillText(`erosion ×${erosion.toFixed(2)}   bends:${n}   oxbows:${oxbows.length}`, 8, 8);
}

Comments (0)

Log in to comment.