From de82fd409b9c7df6289ea8bfbf4dfd12d68c3896 Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 25 Sep 2026 21:49:14 -0700 Subject: [PATCH] Let the tide pool's water move its sand: loose sand lies in the hollows and gullies and wherever borers heap their spoil, fast water lifts it and carries it and slow water lets it fall, so gullies scour, spoil heaps wash out and storms rearrange the shore, with the sand drawn pale and smooth where it lies. --- public/tide-pool-world.js | 54 +++++++++++++++++++++++++++++++++++- public/tide-pool.js | 26 ++++++++++------- src/components/tide-pool.tsx | 4 +-- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/public/tide-pool-world.js b/public/tide-pool-world.js index ddeece5..a47a9a6 100644 --- a/public/tide-pool-world.js +++ b/public/tide-pool-world.js @@ -363,6 +363,54 @@ function water(world, elapsed) { const v = w[k] - EVAPORATE * dt; w[k] = v < FLOOR[k] ? FLOOR[k] : v; } + // The sand moves with the water, a few times a second. + world.sandDue = (world.sandDue || 0) + dt; + if (world.sandDue >= .1) { sediment(world, world.sandDue); world.sandDue = 0; } +} + +// Sand moves. Loose sand lies over the rock in the hollows and gullies, and wherever the machines have heaped their spoil. +// Water running fast lifts it, most where the waves break and where the tide races up a gully; the sand goes where the +// water goes and settles again where the water slows, in the pools and in the lee of rocks. So the gullies scour, the pools +// slowly silt, spoil heaps wash out, and a storm rearranges the shore. +const LIFT = .3; // water slower than this, in metres a second, leaves the sand where it lies +const CARRY = .06; // how much sand water can hold: its depth, times the square of its speed above LIFT, times this +const TAKE = .5, SETTLE = 1; // how fast water takes up the sand it could carry, and lets fall what it cannot +const HELD = new Float32Array(N); +function sediment(world, dt) { + const { h, w, wall, qx, qz, sand, silt, pin } = world; + let changed = 0; + for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { + const k = j * COLS + i, d = w[k] - h[k] - wall[k]; + // Water too thin to hold anything drops what it carried. + if (d < WET) { if (silt[k] > 0) { sand[k] += silt[k]; h[k] += silt[k]; changed += silt[k]; silt[k] = 0; } continue; } + const u = ((i > 0 ? qx[k - 1] : 0) + qx[k]) * .5 / d, v = ((j > 0 ? qz[k - COLS] : 0) + qz[k]) * .5 / d; + const over = Math.hypot(u, v) - LIFT, room = over > 0 ? CARRY * d * over * over : 0; + if (silt[k] < room) { + // The footing of a structure is held, and there is only so much sand to lift. + const take = pin[k] ? 0 : Math.min((room - silt[k]) * TAKE * dt, sand[k]); + sand[k] -= take; h[k] -= take; silt[k] += take; changed += take; + } else if (silt[k] > room) { + const fall = Math.min((silt[k] - room) * SETTLE * dt, silt[k]); + sand[k] += fall; h[k] += fall; silt[k] -= fall; changed += fall; + } + } + // Across each face the flow carries its share of the sand held in the cell it leaves; out past the edge, the sea keeps it. + HELD.set(silt); + for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { + const k = j * COLS + i; + for (let side = 0; side < 2; side++) { + const q = side ? qz[k] : qx[k], n = side ? k + COLS : k + 1; + if (!q || (side ? j === ROWS - 1 : i === COLS - 1)) continue; + const from = q > 0 ? k : n, to = q > 0 ? n : k, d = w[from] - h[from] - wall[from]; + if (HELD[from] <= 0 || d < WET) continue; + const moved = Math.min(Math.abs(q) * dt / CELL / d * HELD[from], silt[from]); + silt[from] -= moved; silt[to] += moved; + } + } + for (let k = (ROWS - SPONGE) * COLS; k < N; k++) silt[k] = 0; + // The pools' lips are looked at again once enough sand has shifted to matter. + world.sandMoved = (world.sandMoved || 0) + changed; + if (world.sandMoved > .05) { world.sandMoved = 0; world.spillDirty = true; world.terrainVersion++; } } // Loose ground cannot stand steeper than the talus slope. Checked where the ground last changed. @@ -387,6 +435,8 @@ function slump(world) { const move = (Math.abs(diff) - limit) / 2 * Math.sign(diff); h[k] -= move; h[n] += move; if (world.built[k] > 0 && move > 0) { const b = Math.min(world.built[k], move); world.built[k] -= b; world.built[n] += b; } + const from = move > 0 ? k : n, to = move > 0 ? n : k, loose = Math.min(world.sand[from], Math.abs(move)); + world.sand[from] -= loose; world.sand[to] += loose; enqueue(n); enqueue(k); world.spillDirty = true; } @@ -417,6 +467,8 @@ function reshape(world, x, z, amount, radius = 1, builtShare = 0) { world.h[k] += delta; moved += delta; if (delta > 0 && builtShare) world.built[k] += delta * builtShare; if (delta < 0 && world.built[k] > 0) world.built[k] = Math.max(0, world.built[k] + delta); + // What is heaped loose is sand the water can move; digging takes the sand first. + world.sand[k] = Math.max(0, world.sand[k] + (delta > 0 ? delta * (1 - builtShare) : delta)); world.disturbed.add(k); } world.spillDirty = true; @@ -536,7 +588,7 @@ export function createWorld(seed = 41) { }; const world = { time: 0, nextObject: 0, nextCreature: 0, random, - h: new Float32Array(N), w: new Float32Array(N), S: new Float32Array(N), built: new Float32Array(N), qx: new Float32Array(N), qz: new Float32Array(N), sand: new Float32Array(N), + h: new Float32Array(N), w: new Float32Array(N), S: new Float32Array(N), built: new Float32Array(N), qx: new Float32Array(N), qz: new Float32Array(N), sand: new Float32Array(N), silt: new Float32Array(N), film: new Float32Array(N), plankton: new Float32Array(N), damp: new Float32Array(N), seaDist: new Float32Array(N).fill(-1), gate: new Float32Array(N), wall: new Float32Array(N), pin: new Uint8Array(N), machines: [], nextMachine: 0, blocks: new Map(), blockVersion: 0, structures: [], nextSupport: 0, gearTurn: 0, debris: [], wallCells: [], wallVersion: '', creatures: [], objects: [], ripples: [], events: [], census: [], ended: [], diff --git a/public/tide-pool.js b/public/tide-pool.js index 59a32ff..dcd43e8 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -1,7 +1,7 @@ import { createWorld, advanceWorld, offerObject, SPECIES, SPECIES_ORDER, CELL, COLS, ROWS, X0, Z0, X1, Z1, START_POOL, waterLevel, daylight, heightAt, surfaceAt, depthAt, onShelf, bodyAt, tideOf, tideRising, label, goalText, describeWorld, creatureById, MACHINES, BLOCK, BLOCK_Y0, blockKey, footAt, debrisAt, footing, stormAt, shineLantern, douseLantern, moonlight, -} from './tide-pool-world.js?v=37'; +} from './tide-pool-world.js?v=38'; const root = document.querySelector('[data-tide-pool]'); const status = document.querySelector('#tide-status'); @@ -140,7 +140,7 @@ async function initialize() { const shared = { uWater: { value: -.35 }, uTime: { value: 0 }, uLight: { value: 1 }, uSun: { value: new T.Vector3(0, 1, 0) }, uView: { value: new T.Vector3(0, 1, 0) }, uRipples: { value: rippleVectors }, uGrid: { value: gridTexture }, uGridBox: { value: new T.Vector4(X0, Z0, X1 - X0, Z1 - Z0) } }; - // The water's motion from the simulation: how it runs (r, g) and the foam on it (b). + // The water's motion from the simulation: how it runs (r, g) and the foam on it (b); and the loose sand on the ground (a). const flowTexture = new T.DataTexture(new Uint16Array(COLS * ROWS * 4), COLS, ROWS, T.RGBAFormat, T.HalfFloatType); flowTexture.magFilter = flowTexture.minFilter = T.LinearFilter; flowTexture.needsUpdate = true; shared.uFlow = { value: flowTexture }; @@ -291,18 +291,19 @@ async function initialize() { return geo; } // The rock's shape, shared by its colour pass and its shadow pass so boulders and ledges cast true shadows. - const ROCK_HEAD = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\n' + GRID + ` + const ROCK_HEAD = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\nvarying float vSand;\nuniform sampler2D uFlow;\n' + GRID + ` float rockHash(vec2 p) { return fract(sin(dot(floor(p * 2.01), vec2(127.1, 311.7))) * 43758.5453); } `; const ROCK_BODY = `#include vCell = gridAt(position.xz); + vSand = texture2D(uFlow, (position.xz - uGridBox.xy) / uGridBox.zw).a; // How far outside the simulated shelf this point lies, on the landward sides only. vec2 lo = uGridBox.xy, hi = uGridBox.xy + uGridBox.zw; float out_ = max(max(lo.x - position.x, position.x - hi.x), lo.y - position.z); vBeyond = clamp(out_ / 22., 0., 1.); float dry = 1. - smoothstep(.0, .05, vCell.r - vCell.g); // Visual relief only: a small broken texture on dry rock, rising into banks beyond the shelf. - float relief = (rockHash(position.xz) - .5) * .09 * dry; + float relief = (rockHash(position.xz) - .5) * .09 * dry * (1. - smoothstep(.1, .5, vSand)); // Ambient occlusion: where the ground around stands higher than here, less of the sky reaches in. float around = 0.; for (int k = 0; k < 6; k++) { @@ -319,7 +320,7 @@ async function initialize() { }; const rockMaterial = submerge(new T.MeshLambertMaterial({ color: 0xffffff, flatShading: true }), shader => { shader.vertexShader = ROCK_HEAD + shader.vertexShader.replace('#include ', ROCK_BODY); - shader.fragmentShader = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\n' + shader.fragmentShader.replace('#include ', `#include + shader.fragmentShader = 'varying vec4 vCell;\nvarying float vBeyond;\nvarying float vOcclude;\nvarying float vSand;\n' + shader.fragmentShader.replace('#include ', `#include { // Dry rock is dark slate; standing water lights the floor beneath it; set stone reads paler. float wet = smoothstep(.0, .45, vCell.r - vCell.g); @@ -333,6 +334,10 @@ async function initialize() { vec3 tone = mix(rock, vec3(.048, .04, .068), wet); tone = mix(tone, vec3(.032, .027, .045), clamp(vCell.r - vCell.g - .7, 0., 1.) * .6); tone = mix(tone, vec3(.11, .1, .125) * grain, clamp(vCell.b, 0., 1.) * (1. - wet * .4)); + // Loose sand, where it has settled or been heaped: pale and fine, darker while wet, rippled where the water runs over it. + float ripples = .92 + .08 * sin(dot(vWorldC.xz, vec2(5.1, 3.7)) + sin(vWorldC.x * .7) * 2.); + vec3 sand = mix(vec3(.074, .067, .076), vec3(.046, .042, .055), max(wet, clamp(vCell.a, 0., 1.) * .8)) * ripples; + tone = mix(tone, sand, smoothstep(.15, .6, vSand)); // Far beyond the shelf the rock sinks into the dark. diffuseColor.rgb = tone * mix(1., .15, vBeyond) * vOcclude; }`); @@ -546,18 +551,19 @@ async function initialize() { let flowWorld = null, flowTime = 0; function uploadFlow() { const { w, h, wall, qx, qz } = world, data = flowTexture.image.data; - if (flowWorld !== world) { flowWorld = world; flowTime = world.time; lastSurface.set(w); current.fill(0); foam.fill(0); } + const fresh = flowWorld !== world; + if (fresh) { flowWorld = world; flowTime = world.time; lastSurface.set(w); current.fill(0); foam.fill(0); } const dt = Math.min(.25, world.time - flowTime); - if (dt <= 0) return; + if (dt <= 0 && !fresh) return; flowTime = world.time; - const ease = 1 - Math.exp(-dt / 1.2), fade = Math.exp(-dt / 2.4); + const ease = 1 - Math.exp(-Math.max(dt, 0) / 1.2), fade = Math.exp(-Math.max(dt, 0) / 2.4); for (let j = 0; j < ROWS; j++) for (let i = 0; i < COLS; i++) { const k = j * COLS + i, d = w[k] - h[k] - wall[k]; let u = 0, v = 0, froth = 0; if (d > .008) { const deep = Math.max(d, .03); u = ((i > 0 ? qx[k - 1] : 0) + qx[k]) * .5 / deep; v = ((j > 0 ? qz[k - COLS] : 0) + qz[k]) * .5 / deep; - const speed = Math.hypot(u, v), rise = (w[k] - lastSurface[k]) / dt; + const speed = Math.hypot(u, v), rise = dt > 0 ? (w[k] - lastSurface[k]) / dt : 0; froth = smooth(.9, 1.5, speed / Math.sqrt(9.81 * deep)) * smooth(.02, .06, d) + smooth(.06, .16, rise) * (1 - smooth(.1, .45, d)); const turn = swirl[k], c = Math.cos(turn) * drag[k], sn = Math.sin(turn) * drag[k]; [u, v] = [u * c - v * sn, u * sn + v * c]; @@ -576,7 +582,7 @@ async function initialize() { foam.set(foamNext); for (let k = 0; k < cellCount; k++) { data[k * 4] = toHalf(clamp(current[k * 2], -2, 2)); data[k * 4 + 1] = toHalf(clamp(current[k * 2 + 1], -2, 2)); - data[k * 4 + 2] = toHalf(foam[k]); data[k * 4 + 3] = 0; + data[k * 4 + 2] = toHalf(foam[k]); data[k * 4 + 3] = toHalf(Math.min(1, world.sand[k] * 6)); } flowTexture.needsUpdate = true; } diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index 991f9c1..3f0ea2e 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -129,7 +129,7 @@ export function TidePoolContent() {

Preparing the pool.

Inside the pool -

Everything here is one surface: a rocky shore that the tide floods and drains every four minutes. The water is simulated: as the tide rises the sea comes in over the rock, up the gullies first, and pours over the lip of the pool; as it falls it drains away again. A swell rolls in off the sea all the while, and its waves break into foam on the rocks, bigger in a storm. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shore bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.

+

Everything here is one surface: a rocky shore that the tide floods and drains every four minutes. The water is simulated: as the tide rises the sea comes in over the rock, up the gullies first, and pours over the lip of the pool; as it falls it drains away again. A swell rolls in off the sea all the while, and its waves break into foam on the rocks, bigger in a storm. Loose sand lies in the hollows and gullies and wherever the borers heap their spoil; fast water lifts it and slow water lets it fall, so the gullies scour, spoil heaps wash out, and a storm rearranges the shore. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shore bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.

    {SPECIES.map(s =>
  • {s.code} {s.name}. {s.note}
  • )}
@@ -142,7 +142,7 @@ export function TidePoolContent() {
{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */} + ); } -- 2.51.2