diff --git a/metabolism/EVOLUTION.md b/metabolism/EVOLUTION.md index 7195a56..023c82d 100644 --- a/metabolism/EVOLUTION.md +++ b/metabolism/EVOLUTION.md @@ -249,6 +249,52 @@ and wander); rock has a budget that survives a whole film (calm songs keep sparse stone, percussive ones accumulate walls); agriculture consumes stone instead of minting it. 44 tests green; realtime holds at 640×360. +### The region pool + the browser as workhorse (landed) + +**Node stays CPU** — it is the dev, test and export path, simple on purpose. +**The browser is the workhorse**: it renders, it previews, and it has the +GPU. The compute architecture, in three layers: + +1. **Think slower than you move** (landed): the strains decide every + `vmEvery`-th substep (default 2) and empty ground never thinks. Measured + at field 128 with 41 k organisms: 25 → 20 ms/frame, ecology unchanged. +2. **The region pool** (landed): the field is carved into horizontal + coarse-row bands; every shared array lives in ONE SharedArrayBuffer + (`arenaLayout`), and `World`'s substep splits into three band-local + phases — block sums, the strain programs, advection+reaction — separated + by atomics barriers (`beginPhase`). The main thread keeps only the + cross-band work: the war front (hits land at aim targets), the swap, the + rock economy, politics, decay. The mail carries the frame, the decide + flag, warmth, the living bodies and the current swap orders (the + workers' named arrays follow the main's swaps through the mail). + **Measured: 52.3 → 14.7 ms/frame (2 workers) → 8.9 ms/frame (4 workers) + at 41 k organisms — near-linear.** And the invariant that makes it safe: + the phases are EXACT partitions, so a parallel world is the serial world + BIT FOR BIT (pinned by a test across 0/2/4 workers). Node runs it on + `worker_threads` (lazily resolved, so the browser still loads the + module); the browser will run the same pool inside its simulation worker. +3. **The GPU** (first slice landed): the strain programs are uniform over + thousands of blocks — one dispatch per strain covers the whole decision + layer. `src/sim/gpu.js` + `tools/gpu-check.js` (browser-side validation + against the CPU interpreter) await device time. + +**The browser as the server's worker (protocol sketch, next).** Once +rendering exists, the browser becomes a compute provider to the node farm: + +- the farm (node) holds prepared songs (`out/prepared/*.json`) and a job + queue: `(songId, seed, frameFrom, frameTo)`; +- a browser provider connects (WebSocket), registers its capability + (GPU? cores?), receives a prepared artifact + a frame range, runs + `Run` inside its worker pool with the GPU decision layer, and streams + back encoded frames (PNG or raw) keyed by frame number; +- the farm stitches the frames into the film. Sim runs are sequential in + time, so the parallel unit is the TAKE (a song × seed × range), not a + frame — many takes in flight across many providers, with the farm + checkpointing world state at range boundaries so a provider can resume. + +Determinism policy unchanged: node's CPU path is the canonical exporter; +the GPU/preview paths are the same world to within float noise. + ### The decision layer on the GPU (analysis, first slice landed) The profile of a heavy frame (field 128, ~40 000 organisms planted) says: diff --git a/metabolism/src/sim/region-worker.js b/metabolism/src/sim/region-worker.js new file mode 100644 index 0000000..8f32d6b --- /dev/null +++ b/metabolism/src/sim/region-worker.js @@ -0,0 +1,60 @@ +// A region worker: owns a horizontal band of coarse rows and runs the +// band-local phases of the world's substep against the shared arena. +// +// The protocol is a pair of small shared arrays: +// ctrl [0] = phase sequence number (raised by the main thread) +// [1] = done count (raised by each worker; the main waits on it) +// mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount, +// [5..] alive body ids, [20..] pop order, [30..] alleg order, +// [40..] carry order +// +// The worker constructs a bare World whose arrays are views into the SAME +// arena the main thread uses — it is the same world, seen through a band. + +import { World } from './world.js'; +import { parentPort, workerData } from 'node:worker_threads'; + + +const { song, field, seed, fps, substeps, vmEvery, layout, arena, ctrl: ctrlBuf, mail: mailBuf, band } = workerData; + +const w = new World(song, { field, seed, fps, substeps, vmEvery, sab: arena, layout, bare: true }); + +const ctrl = new Int32Array(ctrlBuf); +const mail = new Int32Array(mailBuf); +const warmth = new Float32Array(mailBuf, 12, 1); + + +let seen = 0; +for (;;) { + Atomics.wait(ctrl, 0, seen); + const seq = Atomics.load(ctrl, 0); + if (seq < 0) break; + seen = seq; + const phase = mail[2]; + const decide = mail[1] === 1; + w.warmth = warmth[0]; + const aliveCount = mail[4]; + const alive = []; + for (let k = 0; k < aliveCount; k++) alive.push(mail[5 + k]); + w.aliveBodies = alive; + // follow the main thread's swap orders + const popOrder = []; + for (let k = 0; k < w.m; k++) popOrder.push(mail[20 + k]); + const allegOrder = []; + for (let k = 0; k < w.B; k++) allegOrder.push(mail[40 + k]); + const carryOrder = []; + for (let k = 0; k < w.m; k++) carryOrder.push(mail[60 + k]); + w.pop = popOrder.map((i) => w.popSlots[i]); + w.alleg = allegOrder.map((i) => w.allegSlots[i]); + w.carry = carryOrder.map((i) => w.carrySlots[i]); + + + const regime = w.regimeForFrame(w.frame); + if (phase === 1) w.phaseSums(band.c0, band.c1, regime); + else if (phase === 2) w.phaseStrains(regime, band.c0, band.c1, decide); + else if (phase === 3) w.phaseFields(regime, band.c0, band.c1); + + Atomics.add(ctrl, 1, 1); + Atomics.notify(ctrl, 1); +} +parentPort.close(); diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js index 6c6648f..3260c9e 100644 --- a/metabolism/src/sim/world.js +++ b/metabolism/src/sim/world.js @@ -29,6 +29,20 @@ // the substep order is canonical. import { Rng } from '../rng.js'; + +/** + * The region pool runs on node's worker_threads; the browser gets its + * parallelism through the GPU decision layer (and, later, a sim worker). + * Resolved lazily so the module keeps loading in the browser. + */ +function workerCtor() { + try { + const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads'); + return mod?.Worker ?? null; + } catch { + return null; + } +} import { wellPos, wellEnv, buildKernel } from './wells.js'; import { runProgram, ensureFlat, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS, @@ -89,8 +103,75 @@ function buildBlockKernels(F, FC) { return kernels; } +/** + * The field arena: every simulation array carved from ONE buffer, so a + * region pool can hand the whole world to workers (or a GPU) as a single + * SharedArrayBuffer. The layout is a pure function of the shape. + */ +export function arenaLayout(field, m, B, FC) { + const n = field * field; + const fc = FC * FC; + const A = (name, bytes, Ctor, len) => ({ name, bytes, Ctor, len }); + const items = [ + A('resource', n * 4, Float32Array, n), + A('density', n * 4, Float32Array, n), + A('rock', n * 4, Float32Array, n), + A('garden', n * 4, Float32Array, n), + A('tend', n * 4, Float32Array, n), + A('flora', n * 4, Float32Array, n), + ]; + for (let s = 0; s < m; s++) { + items.push(A(`pop${s}`, n * 4, Float32Array, n)); + items.push(A(`popNext${s}`, n * 4, Float32Array, n)); + items.push(A(`carry${s}`, n * 4, Float32Array, n)); + items.push(A(`carryNext${s}`, n * 4, Float32Array, n)); + } + for (let b = 0; b < B; b++) { + items.push(A(`alleg${b}`, n * 4, Float32Array, n)); + items.push(A(`allegNext${b}`, n * 4, Float32Array, n)); + items.push(A(`trail${b}`, n * 4, Float32Array, n)); + } + items.push(A('noise', n * 4, Float32Array, n)); + items.push(A('noise2', n * 4, Float32Array, n)); + items.push(A('nUp', n * 4, Int32Array, n)); + items.push(A('nDown', n * 4, Int32Array, n)); + items.push(A('nLeft', n * 4, Int32Array, n)); + items.push(A('nRight', n * 4, Int32Array, n)); + items.push(A('coarseOf', n * 4, Int32Array, n)); + items.push(A('rockMask', n, Uint8Array, n)); + // coarse-grid state + const coarse = (name, Ctor, len) => items.push(A(name, len * Ctor.BYTES_PER_ELEMENT, Ctor, len)); + coarse('blkR', Float32Array, fc); + coarse('blkTotal', Float32Array, fc); + coarse('blkRock', Float32Array, fc); + coarse('blkGarden', Float32Array, fc); + for (let s = 0; s < m; s++) coarse(`blkPop${s}`, Float32Array, fc); + for (let b = 0; b < B; b++) { coarse(`blkTrail${b}`, Float32Array, fc); coarse(`blkBody${b}`, Float32Array, fc); } + coarse('domB', Int8Array, fc); + coarse('subB', Int8Array, fc); + for (let s = 0; s < m; s++) { + coarse(`fxX${s}`, Float32Array, fc); coarse(`fxY${s}`, Float32Array, fc); + coarse(`ancX${s}`, Float32Array, fc); coarse(`ancY${s}`, Float32Array, fc); + } + coarse('velX', Float32Array, fc); + coarse('velY', Float32Array, fc); + coarse('wellField', Float32Array, fc); + coarse('rockField', Float32Array, fc); + coarse('hostility', Float32Array, fc); + coarse('hitAt', Float32Array, fc); + coarse('synergy', Float32Array, m * fc); + // mind state + coarse('stMem', Float32Array, m * REGS * fc); + coarse('stAct', Float32Array, m * ACTION_COUNT * fc); + items.push(A('aimX', m * fc, Int8Array, m * fc)); + items.push(A('aimY', m * fc, Int8Array, m * fc)); + items.push(A('activity', 32 * 32 * 4, Float32Array, 32 * 32)); + items.push(A('hungerMass', 4, Float32Array, 1)); + return { items, bytes: items.reduce((a, it) => a + it.bytes, 0) }; +} + export class World { - constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis, vmEvery = VM_EVERY } = {}) { + constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis, vmEvery = VM_EVERY, sab = null, layout = null, bare = false, workers = 0 } = {}) { this.song = song; this.fps = fps; this.dt = 1 / fps; @@ -120,24 +201,43 @@ export class World { const n = field * field; this.n = n; - this.resource = new Float32Array(n); - this.density = new Float32Array(n); // total population per cell + // --- the arena ------------------------------------------------------- + // Every simulation array is carved from ONE buffer, so a region pool + // can hand the whole world to workers as a single SharedArrayBuffer. + const B0 = this.program.bodies.max; + this.B = B0; + const fcSide0 = Math.max(16, Math.round(field / 2)); // the layout needs the coarse shape + const spec = layout ?? arenaLayout(field, this.m, B0, fcSide0); + this.layout = spec; + this.arena = sab ?? new SharedArrayBuffer((spec.bytes + 3) & ~3); + { + let off = 0; + for (const it of spec.items) { it.offset = off; off += (it.bytes + 3) & ~3; } + } + const carve = (name, Ctor, len) => { + const it = spec.items.find((t) => t.name === name); + return new Ctor(this.arena, it.offset, len); + }; + const farr = (name, len) => carve(name, Float32Array, len); + + this.resource = farr('resource', n); + this.density = farr('density', n); // total population per cell // The population, partitioned by personality (strain). - this.pop = Array.from({ length: this.m }, () => new Float32Array(n)); - this.popNext = Array.from({ length: this.m }, () => new Float32Array(n)); + this.pop = Array.from({ length: this.m }, (_, s2) => farr(`pop${s2}`, n)); + this.popNext = Array.from({ length: this.m }, (_, s2) => farr(`popNext${s2}`, n)); // Allegiance: the population partitioned by body. alleg[b][i] is the // DENSITY of body-b organisms at cell i, so Σ_b alleg[b][i] equals // the cell's total population — a concentration, advected frozen-in // with the flux and renormalised to the population every frame. - this.B = this.program.bodies.max; - this.alleg = Array.from({ length: this.B }, () => new Float32Array(n)); - this.allegNext = Array.from({ length: this.B }, () => new Float32Array(n)); + this.alleg = Array.from({ length: this.B }, (_, b2) => farr(`alleg${b2}`, n)); + this.allegNext = Array.from({ length: this.B }, (_, b2) => farr(`allegNext${b2}`, n)); // Trail history, one tinted layer per body — the geography the // audience sees, coloured by who wrote it. - this.trail = Array.from({ length: this.B }, () => new Float32Array(n)); + this.trail = Array.from({ length: this.B }, (_, b2) => farr(`trail${b2}`, n)); + // The macro-organisms themselves. this.bodies = Array.from({ length: this.B }, (_, b) => ({ @@ -150,13 +250,14 @@ export class World { this.bodyAff = Array.from({ length: this.B }, () => new Float32Array(this.B)); this.warmth = 0; - this.activity = new Float32Array(32 * 32); + this.activity = carve('activity', Float32Array, 32 * 32); + this.hungerCell = carve('hungerMass', Float32Array, 1); // shared accumulator // Rock (M2.8): the inert resource. A barrier the organisms cannot // cross, that the song drops and shatters, and that builders quarry, // carry and wall with. rockMask marks the impassable cells. - this.rock = new Float32Array(n); - this.rockMask = new Uint8Array(n); + this.rock = farr('rock', n); + this.rockMask = carve('rockMask', Uint8Array, n); this.rockFallen = 0; // total stone the song has dropped // Flora (M2.9): the wild plants — the world's pioneers, living in // ISLANDS scattered at birth. They ARE food, they regrow on their @@ -164,32 +265,32 @@ export class World { // (seeding it back to life), then recede once the population is // strong and feeding itself again. Never enough to sustain a // large world, and never anywhere but on their own seed ground. - this.flora = new Float32Array(n); - this.floraSeed = new Uint8Array(n); // the islands: where flora may live + this.flora = farr('flora', n); + this.floraSeed = new Uint8Array(n); // the islands: where flora may live (main-thread only) this.famine = 0; // smoothed mean hunger of the population this.peakPop = 0; // recent population high-water mark this.hungerMass = 0; // per-frame accumulator (the reaction fills it) // Cultivated ground: worked stone that slowly yields food, then // petrifies back to rock. garden[i] is the fertility left. - this.garden = new Float32Array(n); + this.garden = farr('garden', n); this.gardenSrc = new Float32Array(n); // Recent tending, per cell: a visited garden yields far more. - this.tend = new Float32Array(n); + this.tend = farr('tend', n); // Deterministic jitter: static signed noise fields from the seeded RNG, // read every substep. The track's flatness sets how loud it is. - this.noise = new Float32Array(n); - this.noise2 = new Float32Array(n); + this.noise = farr('noise', n); + this.noise2 = farr('noise2', n); for (let i = 0; i < n; i++) { this.noise[i] = this.rng.signed(); this.noise2[i] = this.rng.signed(); } // Fine-grid 4-neighbour indices, precomputed once (no bounds checks). - this.nUp = new Int32Array(n); - this.nDown = new Int32Array(n); - this.nLeft = new Int32Array(n); - this.nRight = new Int32Array(n); + this.nUp = carve('nUp', Int32Array, n); + this.nDown = carve('nDown', Int32Array, n); + this.nLeft = carve('nLeft', Int32Array, n); + this.nRight = carve('nRight', Int32Array, n); for (let y = 0; y < field; y++) { for (let x = 0; x < field; x++) { const i = y * field + x; @@ -202,36 +303,36 @@ export class World { // Coarse force grid: block sums of food + strains + bodies, forces per // block, plus a fine→coarse index table. Field 128 → 64² (2×2 blocks). - const fcSide = Math.max(16, Math.round(field / 2)); + const fcSide = fcSide0; const fc = fcSide * fcSide; this.FC = fcSide; this.fc = fc; - this.blkR = new Float32Array(fc); - this.blkPop = Array.from({ length: this.m }, () => new Float32Array(fc)); - this.blkTrail = Array.from({ length: this.B }, () => new Float32Array(fc)); - this.blkBody = Array.from({ length: this.B }, () => new Float32Array(fc)); - this.blkTotal = new Float32Array(fc); - this.blkRock = new Float32Array(fc); - this.blkGarden = new Float32Array(fc); - this.domB = new Int8Array(fc).fill(-1); - this.subB = new Int8Array(fc).fill(-1); - this.fxX = Array.from({ length: this.m }, () => new Float32Array(fc)); - this.fxY = Array.from({ length: this.m }, () => new Float32Array(fc)); - this.velX = new Float32Array(fc); - this.velY = new Float32Array(fc); - this.wellField = new Float32Array(fc); - this.rockField = new Float32Array(fc); // smoothed stone: the far smell - this.ancX = Array.from({ length: this.m }, () => new Float32Array(fc)); - this.ancY = Array.from({ length: this.m }, () => new Float32Array(fc)); - this.synergy = new Float32Array(this.m * fc); + this.blkR = farr('blkR', fc); + this.blkPop = Array.from({ length: this.m }, (_, s2) => farr(`blkPop${s2}`, fc)); + this.blkTrail = Array.from({ length: this.B }, (_, b2) => farr(`blkTrail${b2}`, fc)); + this.blkBody = Array.from({ length: this.B }, (_, b2) => farr(`blkBody${b2}`, fc)); + this.blkTotal = farr('blkTotal', fc); + this.blkRock = farr('blkRock', fc); + this.blkGarden = farr('blkGarden', fc); + this.domB = carve('domB', Int8Array, fc).fill(-1); + this.subB = carve('subB', Int8Array, fc).fill(-1); + this.fxX = Array.from({ length: this.m }, (_, s2) => farr(`fxX${s2}`, fc)); + this.fxY = Array.from({ length: this.m }, (_, s2) => farr(`fxY${s2}`, fc)); + this.velX = farr('velX', fc); + this.velY = farr('velY', fc); + this.wellField = farr('wellField', fc); + this.rockField = farr('rockField', fc); // smoothed stone: the far smell + this.ancX = Array.from({ length: this.m }, (_, s2) => farr(`ancX${s2}`, fc)); + this.ancY = Array.from({ length: this.m }, (_, s2) => farr(`ancY${s2}`, fc)); + this.synergy = farr('synergy', this.m * fc); this.valScratch = new Float64Array(this.m); this.growScratch = new Float64Array(this.m); - this.hostility = new Float32Array(fc); - this.hitAt = new Float32Array(fc); + this.hostility = farr('hostility', fc); + this.hitAt = farr('hitAt', fc); // The last aim per (strain, block): the senses of WORK look where // the work points, one substep back — sight follows intent. - this.aimX = new Int8Array(this.m * fc); - this.aimY = new Int8Array(this.m * fc); + this.aimX = carve('aimX', Int8Array, this.m * fc); + this.aimY = carve('aimY', Int8Array, this.m * fc); this.cUp = new Int32Array(fc); this.cDown = new Int32Array(fc); this.cLeft = new Int32Array(fc); @@ -258,16 +359,21 @@ export class World { // Quarried burden per strain, a fine field advected with the // population flux — stone is genuinely HAULED: dug at the quarry, // carried with the crowd, walled at home. - this.carry = Array.from({ length: this.m }, () => new Float32Array(n)); - this.carryNext = Array.from({ length: this.m }, () => new Float32Array(n)); + this.carry = Array.from({ length: this.m }, (_, s2) => farr(`carry${s2}`, n)); + this.carryNext = Array.from({ length: this.m }, (_, s2) => farr(`carryNext${s2}`, n)); + // the slot lists the pool's mail orders reference: BOTH sides of + // every swap (the current arrays drift among them frame by frame) + this.popSlots = [...this.pop, ...this.popNext]; + this.allegSlots = [...this.alleg, ...this.allegNext]; + this.carrySlots = [...this.carry, ...this.carryNext]; // Rounded build footprints: a Gaussian kernel per coarse block, so // walls rise organically instead of as square tiles. this.buildKernel = buildBlockKernels(field, Math.max(16, Math.round(field / 2))); // Strain programs run on this decision grid: registers are the // strains' persistent memory, the action slots carry each decision. - this.stMem = new Float32Array(this.m * REGS * fc); - this.stAct = new Float32Array(this.m * ACTION_COUNT * fc); + this.stMem = farr('stMem', this.m * REGS * fc); + this.stAct = farr('stAct', this.m * ACTION_COUNT * fc); this.ss = new Float32Array(SENSE_COUNT); this.gg = new Float32Array(GRAD_COUNT); this.scratch = new Float32Array(ACTION_COUNT); @@ -311,12 +417,52 @@ export class World { this.birthsThisFrame = 0; this.deathsThisFrame = 0; + if (bare) { + // A region worker attaches to the main thread's arena: it must + // not re-seed, re-scatter or re-simulate any of it. + return; + } this.seedRocks(); this.seedFlora(); this.refreshRockMask(); this.foundOpeningBodies(); this.seedInitial(); this.runGenesis(); + + // The region pool: worker_threads (node) split the field into + // horizontal coarse-row bands; the phases are band-local, so the + // parallel world is the serial world to within float order. + const WorkerC = workerCtor(); + if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial + if (!bare && workers > 1) { + this.ctrl = new Int32Array(new SharedArrayBuffer(32)); + this.mail = new Int32Array(new SharedArrayBuffer(512)); + this.pool = []; + const bands = []; + for (let k = 0; k < workers; k++) { + const c0 = Math.round((this.FC * k) / workers); + const c1 = Math.round((this.FC * (k + 1)) / workers); + bands.push({ c0, c1 }); + const worker = new WorkerC(new URL('./region-worker.js', import.meta.url), { + workerData: { + song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery, + layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes }, + arena: this.arena, ctrl: this.ctrl.buffer, + mail: this.mail.buffer, band: bands[k], + }, + }); + worker.unref(); // the pool never holds the process open + this.pool.push(worker); + } + this.bands = bands; + } + } + + /** Stop the region pool (tests and tools call this before exiting). */ + dispose() { + if (!this.pool) return; + for (const worker of this.pool) worker.terminate(); + this.pool = null; } lawForFrame(frame) { @@ -726,7 +872,7 @@ export class World { this.birthsThisFrame = 0; this.deathsThisFrame = 0; this.moveAccum = 0; - this.hungerMass = 0; + this.hungerCell[0] = 0; const living = this.framePrep(regime); this.updateRockField(); @@ -876,15 +1022,15 @@ export class World { * and the section's PEACE. The MOVE actions become the advection forces; * the others are stored for the reaction phase. */ - runStrains(regime) { + phaseStrains(regime, c0 = 0, c1 = this.FC, decide = true) { const FC = this.FC; const fc = FC * FC; const blkRock = this.blkRock; const blkGarden = this.blkGarden; // The minds think slower than the world moves: programs run on a // fraction of the substeps (their decisions persist in the action - // buffer), and empty ground thinks not at all. - const decide = (this.substepCount % this.vmEvery) === 0; + // buffer), and empty ground thinks not at all. `decide` is computed + // by the caller — region workers receive it in the frame mail. const m = this.m; const blkPop = this.blkPop; const blkTrail = this.blkTrail; @@ -915,7 +1061,7 @@ export class World { const actFC = FC * FC; const actBufAll = this.stAct; - for (let b = 0; b < fc; b++) { + for (let b = c0 * FC; b < c1 * FC; b++) { const tot = blkTotal[b]; if (tot < 1e-4) { // Empty ground: no mind, no force — just make sure no stale @@ -1062,36 +1208,70 @@ export class World { */ substep(regime) { this.substepCount++; - const F = this.field; - const FC = this.FC; - const fc = FC * FC; - const sub = 1 / (this.fps * this.substeps); - const ops = regime.operators; - const prog = this.program; - const strains = prog.strains; - const m = this.m; - const diff = prog.memory.diffusion; - const jitterAmt = prog.memory.jitter * (0.4 + 1.35 * ops.turbulence); - const capMul = 0.7 + 0.6 * ops.split; + if (this.pool) { + // Region pool: three band-parallel phases; the main thread does + // only the cross-band work between them (war front, swap, the + // rock economy). + if (this.dbgPool) console.log('ph1 start'); + this.beginPhase(1); // block sums + if (this.dbgPool) console.log('ph2 start'); + this.beginPhase(2); // the strain programs decide + if (this.dbgPool) console.log('war start'); + this.afterDecisions(regime); // the war front (hits cross bands) + if (this.dbgPool) console.log('ph3 start'); + this.beginPhase(3); // advection + reaction + if (this.dbgPool) console.log('after fields'); + this.afterFields(regime); // swap + the rock economy + return; + } + this.phaseSums(0, this.FC, regime); + this.phaseStrains(regime, 0, this.FC, (this.substepCount % this.vmEvery) === 0); + this.afterDecisions(regime); + this.phaseFields(regime, 0, this.FC); + this.afterFields(regime); + } + /** Raise a phase flag and wait until every region worker has done it. */ + beginPhase(id) { + const ctrl = this.ctrl; + const mail = this.mail; + // the frame mail: the workers read it when they wake + Atomics.store(mail, 0, this.frame); + Atomics.store(mail, 1, (this.substepCount % this.vmEvery) === 0 ? 1 : 0); + new Float32Array(mail.buffer, 12, 1)[0] = this.warmth; + Atomics.store(mail, 2, id); + Atomics.store(mail, 4, this.aliveBodies.length); + for (let k = 0; k < this.aliveBodies.length; k++) Atomics.store(mail, 5 + k, this.aliveBodies[k]); + // the current swap orders (the workers' named arrays follow the main's) + for (let s = 0; s < this.m; s++) Atomics.store(mail, 20 + s, this.popSlots.indexOf(this.pop[s])); + for (let b = 0; b < this.B; b++) Atomics.store(mail, 40 + b, this.allegSlots.indexOf(this.alleg[b])); + for (let s = 0; s < this.m; s++) Atomics.store(mail, 60 + s, this.carrySlots.indexOf(this.carry[s])); + // raise the phase sequence and wait for every done-flag + Atomics.store(ctrl, 1, 0); + this.phaseSeq = (this.phaseSeq ?? 0) + 1; + const seq = this.phaseSeq; + Atomics.store(ctrl, 0, seq); + Atomics.notify(ctrl, 0, this.pool.length); + let polls = 0; + while (Atomics.load(ctrl, 1) < this.pool.length) { + if (++polls > 2000) { + throw new Error(`region pool stalled: phase ${id}, done ${Atomics.load(ctrl, 1)}/${this.pool.length}, workerBoot ${Atomics.load(mail, 63)}`); + } + Atomics.wait(ctrl, 1, Atomics.load(ctrl, 1), 25); + } + } + + // ---- phase 1: block sums + dominant bodies (band-local) ---------------- + phaseSums(c0, c1, regime) { + void regime; + const F = this.field; + const m = this.m; const pop = this.pop; - const next = this.popNext; - const alleg = this.alleg; - const allegNext = this.allegNext; - const trail = this.trail; const res = this.resource; const density = this.density; - const nUp = this.nUp; - const nDown = this.nDown; - const nLeft = this.nLeft; - const nRight = this.nRight; - const coarseOf = this.coarseOf; - const noise = this.noise; - const noise2 = this.noise2; - const n = this.n; const rock = this.rock; - - // ---- 1. block sums ---------------------------------------------------- + const trail = this.trail; + const alleg = this.alleg; const blkR = this.blkR; const blkPop = this.blkPop; const blkTrail = this.blkTrail; @@ -1099,16 +1279,20 @@ export class World { const blkTotal = this.blkTotal; const blkRock = this.blkRock; const blkGarden = this.blkGarden; - blkR.fill(0); - blkTotal.fill(0); - blkRock.fill(0); - blkGarden.fill(0); - for (let s = 0; s < m; s++) blkPop[s].fill(0); - for (let b = 0; b < this.B; b++) { blkTrail[b].fill(0); blkBody[b].fill(0); } - for (let i = 0; i < n; i++) { - const b = coarseOf[i]; + const flora = this.flora; + const FCs = this.FC; + for (let b = c0 * FCs; b < c1 * FCs; b++) { + blkR[b] = 0; blkTotal[b] = 0; blkRock[b] = 0; blkGarden[b] = 0; + for (let s = 0; s < m; s++) blkPop[s][b] = 0; + for (let c = 0; c < this.B; c++) { blkTrail[c][b] = 0; blkBody[c][b] = 0; } + } + const f0 = c0 * (F / FCs); + const f1 = c1 * (F / FCs); + for (let i = f0 * F; i < f1 * F; i++) { + const b = this.coarseOf[i]; + if (b < c0 * FCs || b >= c1 * FCs) continue; // straddling block: its owner sums it const d = density[i]; - blkR[b] += res[i] + this.flora[i] * 0.5; + blkR[b] += res[i] + flora[i] * 0.5; blkRock[b] += rock[i]; blkGarden[b] += this.garden[i]; if (d > 1e-6) { @@ -1126,7 +1310,7 @@ export class World { // Dominant + second body per block (canonical: ties → lowest id). const domB = this.domB; const subB = this.subB; - for (let b = 0; b < fc; b++) { + for (let b = c0 * FCs; b < c1 * FCs; b++) { let d0 = -1; let d1 = -1; let v0 = 0; @@ -1139,18 +1323,28 @@ export class World { domB[b] = d0; subB[b] = d1; } + } - // ---- 2. the strain programs decide the forces -------------------------- - this.runStrains(regime); + // ---- phase 3: advection + reaction (band-local) ------------------------ + // ---- after the decisions: the war front (main thread; hits cross bands) + afterDecisions(regime) { + void regime; + const FC = this.FC; + const fc = FC * FC; + const actFC = fc; + const m = this.m; + const actBuf = this.stAct; + const blkBody = this.blkBody; + const blkTotal = this.blkTotal; + const domB = this.domB; + const subB = this.subB; + const hostility = this.hostility; + const hitAt = this.hitAt; // The per-block war pressure: hits land at their AIM target — where // the striker is heading, i.e. where the foe actually stands — and // siege saps the stone there. War has a front line now. - const actBuf = this.stAct; - const actFC = FC * FC; - const hostility = this.hostility; hostility.fill(0); - const hitAt = this.hitAt; hitAt.fill(0); const aimTarget = (b, s) => { const base = s * ACTION_COUNT * actFC + b; @@ -1182,150 +1376,39 @@ export class World { hostility[b] = Math.min(1, hitAt[b] * 8) * (-dip) * foeShare; } - // ---- 3. advection + diffusion ------------------------------------------ - // Allegiance rides the population flux: the density-weighted mean of - // the strains' velocities. - const velX = this.velX; - const velY = this.velY; - for (let b = 0; b < fc; b++) { - let sx = 0; - let sy = 0; - let w = 0; - for (let s = 0; s < m; s++) { - const p = blkPop[s][b]; - sx += this.fxX[s][b] * p; - sy += this.fxY[s][b] * p; - w += p; - } - velX[b] = w > 1e-6 ? sx / w : 0; - velY[b] = w > 1e-6 ? sy / w : 0; - } + } - const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240); - for (let s = 0; s < m; s++) { - this.advect(pop[s], next[s], this.fxX[s], this.fxY[s], - advFrac, jitterAmt, diff, sub, true); - } + + // ---- after the fields: swap + the rock economy (main; aim targets cross bands) + afterFields(regime) { + const aimTarget = (b, s) => { + const actFC = this.fc; + const actBuf = this.stAct; + const base = s * ACTION_COUNT * actFC + b; + let ax = actBuf[base + 12 * actFC]; + let ay = actBuf[base + 13 * actFC]; + if (ax === 0) ax = actBuf[base + 0 * actFC]; + if (ay === 0) ay = actBuf[base + 1 * actFC]; + const FC = this.FC; + const nx = Math.min(FC - 1, Math.max(0, (b % FC) + Math.sign(ax))); + const ny = Math.min(FC - 1, Math.max(0, ((b / FC) | 0) + Math.sign(ay))); + return ny * FC + nx; + }; + const m = this.m; + const pop = this.pop; + const next = this.popNext; + const alleg = this.alleg; + const allegNext = this.allegNext; const carry = this.carry; - const allegDiff = diff; // allegiance is frozen-in: it travels exactly - // with the population's own spreading - for (let c = 0; c < this.B; c++) { - this.advect(alleg[c], allegNext[c], velX, velY, advFrac, 0, allegDiff, sub, false); - } - // The quarried burden rides the same flux — this is the hauling. - for (let s = 0; s < m; s++) { - this.advect(carry[s], this.carryNext[s], velX, velY, advFrac, 0, diff * 0.15, sub, false); - } - - // ---- 4. react ----------------------------------------------------------- - const societyCeil = prog.society.ceiling; - const globalCap = prog.society.global; - const synScratch = this.synergy; - const vals = this.valScratch; - const grown = this.growScratch; - // The dish's carrying capacity: as the whole world approaches it, - // breeding eases off everywhere (soft, so it never bites hard). - const globalDamp = Math.max(0, 1 - Math.max(0, this.totalPop - globalCap) / globalCap); - for (let i = 0; i < n; i++) { - const r0 = res[i]; - const b = coarseOf[i]; - const dom = domB[b]; - if (r0 < 1e-6 && density[i] < 1e-6) continue; - - let total = 0; - for (let s = 0; s < m; s++) vals[s] = next[s][i]; - - // Saturated food factor (rational approximation, no transcendentals). - const foodFactor = (r0 * 5) / (1 + r0 * 5); - this.hungerMass += density[i] * (1 - foodFactor); - // The gate is nearly shut on bare ground: food is the substrate, not a - // catalyst. A colony that outgrows its ground starves back and must - // wander — the world tracks its wells instead of congealing. - const gate = 0.03 + 0.97 * foodFactor; - - // War: the block's contest bleeds the losing body's share here. - const host = hostility[b]; - let casualty = 0; - if (host > 1e-9 && dom >= 0) { - const other = subB[b] >= 0 ? subB[b] : dom; - const loseShare = dom !== other - ? clamp01(allegNext[other][i] / Math.max(1e-6, density[i])) - : 0; - casualty = Math.min(0.35, host * 2.5 * sub * loseShare); - } - - let demand = 0; - for (let s = 0; s < m; s++) demand += vals[s] * this.eatR[s] * actBuf[s * ACTION_COUNT * actFC + 3 * actFC + b]; - demand *= sub; - // The store runs out → the crowd mows the wild flora (half a - // stand at a time; it regrows if left alone). - const fromRes = Math.min(r0, demand); - // Grazing can only take a whisker of the stand per bite — a - // crowd mows a meadow over seconds, never in an instant. - const bite = Math.min(this.flora[i] * 0.004, demand - fromRes); - this.flora[i] -= bite; - res[i] = demand > 0 - ? Math.max(0, r0 - fromRes) + bite * 0.55 - : r0; - - const starve = (1 - foodFactor) * (0.28 + 0.9 * ops.spawn) * sub; - const society = Math.max(0, 1 - density[i] / societyCeil); - for (let s = 0; s < m; s++) { - const p = vals[s]; - const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b]; - const synB = synScratch[s * FC + b]; - let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate; - g *= (1 - casualty * 8); - // Starvation is the body's metabolic floor: bare ground kills, - // slowly but surely — a colony that sits still on exhausted - // ground dissolves, and the survivors must wander. This is - // what keeps the world working instead of congealing. (The - // eat action drives depletion above; it is not the price of - // eating.) - g = Math.max(0, p + g * society) - p * starve * this.eatR[s]; - g = Math.max(0, g); - if (casualty > 0) g = Math.max(0, g - g * casualty * 6); // war kills; it does not convert - grown[s] = g; - total += g; - } - - // Deposit into the DOMINANT body's trail: each cell's organisms - // write the geography of the body they belong to. - if (total > 1e-9 && dom >= 0) { - const tB = trail[dom]; - for (let s = 0; s < m; s++) { - const exc = actBuf[s * ACTION_COUNT * actFC + 2 * actFC + b]; - tB[i] += grown[s] * this.depositR[s] * ops.deposit * (1 + exc) * sub * DEPOSIT_SCALE; - } - } - - let dNew = 0; - for (let s = 0; s < m; s++) { - const before = pop[s][i]; - const after = grown[s]; - next[s][i] = after; - dNew += after; - if (after > before) this.birthsThisFrame += after - before; - else if (after < before) this.deathsThisFrame += before - after; - } - density[i] = dNew; - - // The composition grows with the cell: newborn organisms inherit - // the standing mix; an allegiance-less cell joins the dominant - // body wholesale. - if (dNew > 1e-6) { - let S = 0; - for (let c = 0; c < this.B; c++) S += allegNext[c][i]; - if (S > 1e-4) { - const k = dNew / S; - for (let c = 0; c < this.B; c++) allegNext[c][i] *= k; - } else if (dom >= 0) { - for (let c = 0; c < this.B; c++) allegNext[c][i] = 0; - allegNext[dom][i] = dNew; - } - } - } - + const n = this.n; + const F = this.field; + const FC = this.FC; + const fc = FC * FC; + const actFC = fc; + const actBuf = this.stAct; + const prog = this.program; + const rock = this.rock; + const sub = 1 / (this.fps * this.substeps); // ---- swap --------------------------------------------------------------- let totalPop = 0; for (let s = 0; s < m; s++) { @@ -1442,12 +1525,205 @@ export class World { } + + phaseFields(regime, c0 = 0, c1 = this.FC) { + const FC = this.FC; + const fc = FC * FC; + const sub = 1 / (this.fps * this.substeps); + const ops = regime.operators; + const prog = this.program; + const strains = prog.strains; + const m = this.m; + const diff = prog.memory.diffusion; + const jitterAmt = prog.memory.jitter * (0.4 + 1.35 * ops.turbulence); + const capMul = 0.7 + 0.6 * ops.split; + + const pop = this.pop; + const next = this.popNext; + const alleg = this.alleg; + const allegNext = this.allegNext; + const trail = this.trail; + const res = this.resource; + const density = this.density; + const nUp = this.nUp; + const nDown = this.nDown; + const nLeft = this.nLeft; + const nRight = this.nRight; + const coarseOf = this.coarseOf; + const noise = this.noise; + const noise2 = this.noise2; + const n = this.n; + const rock = this.rock; + const blkPop = this.blkPop; + const blkTotal = this.blkTotal; + const domB = this.domB; + const subB = this.subB; + const actBuf = this.stAct; + const actFC = fc; + const hostility = this.hostility; + + // ---- advection + reaction (band rows [c0, c1)) ------------------------- + const f0 = c0 * (this.field / FC); + const f1 = c1 * (this.field / FC); + const lo = f0 * this.field; + const hi = f1 * this.field; + // Allegiance rides the population flux: the density-weighted mean of + // the strains' velocities. + const velX = this.velX; + const velY = this.velY; + for (let b = c0 * FC; b < c1 * FC; b++) { + let sx = 0; + let sy = 0; + let w = 0; + for (let s = 0; s < m; s++) { + const p = blkPop[s][b]; + sx += this.fxX[s][b] * p; + sy += this.fxY[s][b] * p; + w += p; + } + velX[b] = w > 1e-6 ? sx / w : 0; + velY[b] = w > 1e-6 ? sy / w : 0; + } + + const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240); + for (let s = 0; s < m; s++) { + this.advect(pop[s], next[s], this.fxX[s], this.fxY[s], + advFrac, jitterAmt, diff, sub, true, lo, hi); + } + const carry = this.carry; + const allegDiff = diff; // allegiance is frozen-in: it travels exactly + // with the population's own spreading + for (let c = 0; c < this.B; c++) { + this.advect(alleg[c], allegNext[c], velX, velY, advFrac, 0, allegDiff, sub, false, lo, hi); + } + // The quarried burden rides the same flux — this is the hauling. + for (let s = 0; s < m; s++) { + this.advect(carry[s], this.carryNext[s], velX, velY, advFrac, 0, diff * 0.15, sub, false, lo, hi); + } + + // ---- 4. react ----------------------------------------------------------- + const societyCeil = prog.society.ceiling; + const globalCap = prog.society.global; + const synScratch = this.synergy; + const vals = this.valScratch; + const grown = this.growScratch; + // The dish's carrying capacity: as the whole world approaches it, + // breeding eases off everywhere (soft, so it never bites hard). + const globalDamp = Math.max(0, 1 - Math.max(0, this.totalPop - globalCap) / globalCap); + for (let i = lo; i < hi; i++) { + const r0 = res[i]; + const b = coarseOf[i]; + const dom = domB[b]; + if (r0 < 1e-6 && density[i] < 1e-6) continue; + + let total = 0; + for (let s = 0; s < m; s++) vals[s] = next[s][i]; + + // Saturated food factor (rational approximation, no transcendentals). + const foodFactor = (r0 * 5) / (1 + r0 * 5); + this.hungerCell[0] += density[i] * (1 - foodFactor); + // The gate is nearly shut on bare ground: food is the substrate, not a + // catalyst. A colony that outgrows its ground starves back and must + // wander — the world tracks its wells instead of congealing. + const gate = 0.03 + 0.97 * foodFactor; + + // War: the block's contest bleeds the losing body's share here. + const host = hostility[b]; + let casualty = 0; + if (host > 1e-9 && dom >= 0) { + const other = subB[b] >= 0 ? subB[b] : dom; + const loseShare = dom !== other + ? clamp01(allegNext[other][i] / Math.max(1e-6, density[i])) + : 0; + casualty = Math.min(0.35, host * 2.5 * sub * loseShare); + } + + let demand = 0; + for (let s = 0; s < m; s++) demand += vals[s] * this.eatR[s] * actBuf[s * ACTION_COUNT * actFC + 3 * actFC + b]; + demand *= sub; + // The store runs out → the crowd mows the wild flora (half a + // stand at a time; it regrows if left alone). + const fromRes = Math.min(r0, demand); + // Grazing can only take a whisker of the stand per bite — a + // crowd mows a meadow over seconds, never in an instant. + const bite = Math.min(this.flora[i] * 0.004, demand - fromRes); + this.flora[i] -= bite; + res[i] = demand > 0 + ? Math.max(0, r0 - fromRes) + bite * 0.55 + : r0; + + const starve = (1 - foodFactor) * (0.28 + 0.9 * ops.spawn) * sub; + const society = Math.max(0, 1 - density[i] / societyCeil); + for (let s = 0; s < m; s++) { + const p = vals[s]; + const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b]; + const synB = synScratch[s * FC + b]; + let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate; + g *= (1 - casualty * 8); + // Starvation is the body's metabolic floor: bare ground kills, + // slowly but surely — a colony that sits still on exhausted + // ground dissolves, and the survivors must wander. This is + // what keeps the world working instead of congealing. (The + // eat action drives depletion above; it is not the price of + // eating.) + g = Math.max(0, p + g * society) - p * starve * this.eatR[s]; + g = Math.max(0, g); + if (casualty > 0) g = Math.max(0, g - g * casualty * 6); // war kills; it does not convert + grown[s] = g; + total += g; + } + + // Deposit into the DOMINANT body's trail: each cell's organisms + // write the geography of the body they belong to. + if (total > 1e-9 && dom >= 0) { + const tB = trail[dom]; + for (let s = 0; s < m; s++) { + const exc = actBuf[s * ACTION_COUNT * actFC + 2 * actFC + b]; + tB[i] += grown[s] * this.depositR[s] * ops.deposit * (1 + exc) * sub * DEPOSIT_SCALE; + } + } + + let dNew = 0; + for (let s = 0; s < m; s++) { + const before = pop[s][i]; + const after = grown[s]; + next[s][i] = after; + dNew += after; + if (after > before) this.birthsThisFrame += after - before; + else if (after < before) this.deathsThisFrame += before - after; + } + density[i] = dNew; + + // The composition grows with the cell: newborn organisms inherit + // the standing mix; an allegiance-less cell joins the dominant + // body wholesale. + if (dNew > 1e-6) { + let S = 0; + for (let c = 0; c < this.B; c++) S += allegNext[c][i]; + if (S > 1e-4) { + const k = dNew / S; + for (let c = 0; c < this.B; c++) allegNext[c][i] *= k; + } else if (dom >= 0) { + for (let c = 0; c < this.B; c++) allegNext[c][i] = 0; + allegNext[dom][i] = dNew; + } + } + } + } + /** * Conservative L1 advection + diffusion of one field, shared by the * strains (which track activity) and the allegiance composition. `frac` * is the fraction of a cell that departs this substep. */ - advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track) { + /** + * Conservative L1 advection + diffusion, in GATHER form: every + * destination cell computes its own inflow from src (self terms plus + * the diffusion flux of its open neighbours). No cross-cell writes — + * which is what makes the field phases band-parallel EXACTLY, whatever + * the region count. A 2-row halo of src covers every read. + */ + advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track, lo = 0, hi = this.n) { const F = this.field; const nUp = this.nUp; const nDown = this.nDown; @@ -1457,20 +1733,29 @@ export class World { const noise = this.noise; const noise2 = this.noise2; const mask = this.rockMask; - const n = this.n; - dst.fill(0); - for (let i = 0; i < n; i++) { + dst.fill(0, lo, hi); + // diffusion flux d_j for every cell j this pass may touch (the cell + // itself and its four neighbours); computed read-only from src. + const flux = (j) => { + const vj = src[j]; + if (vj <= 1e-6) return 0; + let open = 0; + let nb = 0; + if (!mask[nUp[j]]) { nb += src[nUp[j]]; open++; } + if (!mask[nDown[j]]) { nb += src[nDown[j]]; open++; } + if (!mask[nLeft[j]]) { nb += src[nLeft[j]]; open++; } + if (!mask[nRight[j]]) { nb += src[nRight[j]]; open++; } + if (open === 0) return 0; + return (nb - open * vj) * diff * sub; + }; + for (let i = lo; i < hi; i++) { const v = src[i]; - if (v <= 1e-6) { - continue; - } const b = coarseOf[i]; const vx = fxArr[b] + (track ? noise[i] * jitterAmt : 0); const vy = fyArr[b] + (track ? noise2[i] * jitterAmt : 0); const w = Math.abs(vx) + Math.abs(vy); - const leave = v * frac; - if (leave > 0 && w > 1e-6) { + if (v > 1e-6 && leave > 0 && w > 1e-6) { const mx = (leave * Math.abs(vx)) / w; const my = (leave * Math.abs(vy)) / w; dst[i] += v - leave; @@ -1487,27 +1772,27 @@ export class World { this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6; this.moveAccum += leave; } - } else { + } else if (v > 1e-6) { dst[i] += v; } - // Diffusion through the OPEN neighbours only: rock reflects the - // haze instead of drinking it (still conservative overall). - let open = 0; - let nb = 0; - if (!mask[nUp[i]]) { nb += src[nUp[i]]; open++; } - if (!mask[nDown[i]]) { nb += src[nDown[i]]; open++; } - if (!mask[nLeft[i]]) { nb += src[nLeft[i]]; open++; } - if (!mask[nRight[i]]) { nb += src[nRight[i]]; open++; } - if (open > 0) { - const d = (nb - open * v) * diff * sub; - dst[i] += d * 0.5; - const share = d * 0.5 / open; - if (!mask[nUp[i]]) dst[nUp[i]] -= share; - if (!mask[nDown[i]]) dst[nDown[i]] -= share; - if (!mask[nLeft[i]]) dst[nLeft[i]] -= share; - if (!mask[nRight[i]]) dst[nRight[i]] -= share; - } + // Diffusion, gathered: my own flux half stays here; each open + // neighbour sheds its flux equally on its open neighbours. + if (v > 1e-6) dst[i] += flux(i) * 0.5; + const shed = (j) => { + const d = flux(j); + if (d === 0) return 0; + let open = 0; + if (!mask[nUp[j]]) open++; + if (!mask[nDown[j]]) open++; + if (!mask[nLeft[j]]) open++; + if (!mask[nRight[j]]) open++; + return open > 0 ? d * 0.5 / open : 0; + }; + if (!mask[nUp[i]] && src[nUp[i]] > 1e-6) dst[i] -= shed(nUp[i]); + if (!mask[nDown[i]] && src[nDown[i]] > 1e-6) dst[i] -= shed(nDown[i]); + if (!mask[nLeft[i]] && src[nLeft[i]] > 1e-6) dst[i] -= shed(nLeft[i]); + if (!mask[nRight[i]] && src[nRight[i]] > 1e-6) dst[i] -= shed(nRight[i]); } } @@ -1769,7 +2054,7 @@ export class World { // The famine signal: mean hunger of the living, smoothed; and the // population's high-water mark (slowly forgotten, so a crash can // ask the pioneers for help again). - const meanHunger = this.totalPop > 1 ? Math.min(1, this.hungerMass / this.totalPop) : 1; + const meanHunger = this.totalPop > 1 ? Math.min(1, this.hungerCell[0] / this.totalPop) : 1; this.famine += (meanHunger - this.famine) * 0.03; this.peakPop = Math.max(this.totalPop, this.peakPop * 0.9995); diff --git a/metabolism/test/world.test.js b/metabolism/test/world.test.js index 0155adc..ef79dd8 100644 --- a/metabolism/test/world.test.js +++ b/metabolism/test/world.test.js @@ -96,6 +96,25 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', () assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0'); }); +test('the region pool is exact: parallel == serial, bit for bit', () => { + const grow = (workers) => { + const song = load('drone'); + const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0, workers }); + for (let f = 0; f < 150; f++) { run.world.step(); run.camera.update(f); } + const history = Array.from(run.world.history.alive.slice(0, 150)); + const trail = Array.from(run.world.trail[0].slice(0, 4000)); + run.world.dispose?.(); + return { history, trail }; + }; + const serial = grow(0); + const two = grow(2); + const four = grow(4); + const eq = (a, b) => a.history.every((v, i) => v === b.history[i]) && + a.trail.every((v, i) => v === b.trail[i]); + assert.ok(eq(serial, two), '2 regions must match the serial world exactly'); + assert.ok(eq(serial, four), '4 regions must match the serial world exactly'); +}); + test('genesis is deterministic and song-scaled', () => { const a = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 }); const b = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 }); diff --git a/metabolism/tools/bench.js b/metabolism/tools/bench.js index 615a9c8..5586dbc 100644 --- a/metabolism/tools/bench.js +++ b/metabolism/tools/bench.js @@ -23,8 +23,14 @@ function plant(w) { w.recount(); } -for (const vmEvery of [1, 2, 3]) { - const w = new World(song, { fps: 30, field: 128, genesis: 0, vmEvery }); +const configs = []; +for (const a of process.argv.slice(4)) { + if (a.startsWith('w')) configs.push({ workers: Number(a.slice(1)) }); + else if (a.startsWith('v')) configs.push({ vmEvery: Number(a.slice(1)) }); +} +if (!configs.length) configs = [{ workers: 0, vmEvery: 2 }, { workers: 2 }, { workers: 4 }]; +for (const cfg of configs) { + const w = new World(song, { fps: 30, field: 128, genesis: 0, vmEvery: cfg.vmEvery ?? 2, workers: cfg.workers ?? 0 }); plant(w); const start = w.count; // warm-up @@ -32,5 +38,6 @@ for (const vmEvery of [1, 2, 3]) { const t0 = process.hrtime.bigint(); for (let f = 0; f < frames; f++) w.step(); const ms = Number(process.hrtime.bigint() - t0) / 1e6 / frames; - console.log(`vmEvery=${vmEvery} sim ${ms.toFixed(1)} ms/frame (start ${Math.round(start)}, now ${Math.round(w.count)}, peak-ish budget ${Math.round(w.cap).toLocaleString()})`); + console.log(`workers=${w.pool?.length ?? 0} vmEvery=${w.vmEvery} sim ${ms.toFixed(1)} ms/frame (start ${Math.round(start)}, now ${Math.round(w.count)})`); + w.dispose?.(); }