0
River Meander & Oxbow Lakes
drag Y for erosion rate · click to seed
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.