metabolism: region pool exactness + browser boot — deterministic velocity pass, pool warm-gate for the web worker boot, breaker
Two root causes fixed end to end:
1. Exactness race: phaseFields' velocity pass wrote its own coarse blocks
and then read halo blocks no neighbour had committed yet — pool runs
diverged at random frames (2/4 runs). The velocity pass now runs as a
block-owner section at the END of phaseStrains; phaseFields only reads
committed velocities. 8/8 pool runs bit-exact through f1239; tests 9/9
(including 1600-frame x 2-worker runs across the section boundary).
2. Browser boot stall: beginPhase's hot spin (Atomics.wait throws on a
window) froze pending web-worker script loads — Chromium routes worker
startup through the busy main thread, so the show stalled 30s and
threw 'region pool stalled'. The web shell now acks ('pool') after
makeRegion (mail[63] is a real boot counter), the world runs cold until
every worker acks (step/stepTo skip frames; post-warm catch-up is
unbounded), and loadSongData awaits player.warm() before the show.
Node pools are untouched (native threads answer barriers directly).
Also: Player.build/rewind dispose the old pool (no more leaked idle
worlds); region-core.js is the shared worker body; region-worker.web.js
the browser shell (vite bundles it via the literal new Worker(new URL)
spelling — dist emits a self-contained worker asset instead of a broken
base64 data URL).
Circuit breaker in the demo: >=2 consecutive sub-25fps windows trip;
>=30 frames behind the audio clock for >=750ms trips (dormant while
stepTo catches up unconditionally — the fps path is live). 12/12 mirror
scenarios pass.
Bench: node serial 74.2ms/f, 2w 38.9 (1.91x), 4w 24.6 (3.02x). Browser:
steady 30fps including a 64s soak to 20k organisms at 640x360.
This commit is contained in:
+35
-16
@@ -52,6 +52,7 @@ const state = {
|
||||
playing: false,
|
||||
loading: false,
|
||||
fps: null, // achieved film rate, measured by the circuit breaker
|
||||
lagS: null, // seconds the picture runs behind the audio clock
|
||||
};
|
||||
|
||||
// ---- audio pipeline (browser side of the shared pipeline) ------------------
|
||||
@@ -99,6 +100,7 @@ async function loadSongData(id, seed, res) {
|
||||
const song = buildSong(analysis, seed);
|
||||
const player = new Player(els.view, w, h);
|
||||
player.build(song);
|
||||
await player.warm(); // the pool answers its boot acks before the show begins
|
||||
const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
|
||||
return { entry: state.byId.get(id), song, analysis, player, audio };
|
||||
}
|
||||
@@ -212,19 +214,46 @@ function doSeek(seconds) {
|
||||
const BREAKER_FPS = 25; // the minimum sustained film rate we accept
|
||||
const BREAKER_WIN = 2.0; // seconds per measurement window
|
||||
const BREAKER_STRIKES = 2; // consecutive slow windows before we stop
|
||||
const BREAKER_LAG = 30; // frames behind the audio clock that count as stranded
|
||||
const BREAKER_LAG_HOLD = 750; // ms of stranding before we stop
|
||||
|
||||
const breaker = { frames: 0, dt: 0, last: 0, strikes: 0 };
|
||||
const breaker = { frames: 0, dt: 0, last: 0, strikes: 0, lagSince: 0 };
|
||||
|
||||
function breakerReset() {
|
||||
breaker.frames = 0;
|
||||
breaker.dt = 0;
|
||||
breaker.last = 0;
|
||||
breaker.strikes = 0;
|
||||
breaker.lagSince = 0;
|
||||
state.fps = null;
|
||||
state.lagS = null;
|
||||
}
|
||||
|
||||
function breakerTick(steps) {
|
||||
function tripBreaker(why) {
|
||||
console.warn('[metabolism] circuit breaker:', JSON.stringify(why));
|
||||
breakerReset();
|
||||
pauseCurrent();
|
||||
const detail = why.lagS != null
|
||||
? `the picture fell ${why.lagS.toFixed(1)}s behind the song — playback stopped`
|
||||
: `sustained ${Math.round(why.fps)} fps < ${BREAKER_FPS} — playback stopped`;
|
||||
setOverlay(
|
||||
`<b>the world outgrew this machine</b>` +
|
||||
`<small>${detail}. Try a lower resolution (res) or another seed.</small>`
|
||||
);
|
||||
}
|
||||
|
||||
function breakerTick(steps, behind) {
|
||||
const now = performance.now();
|
||||
// The lag trip: a rate that still clears BREAKER_FPS but never reaches
|
||||
// the audio clock means the picture drifts (the deficit grows every
|
||||
// tick) — that is what chugging looks like when the rate looks fine.
|
||||
if (behind >= BREAKER_LAG) {
|
||||
if (!breaker.lagSince) breaker.lagSince = now;
|
||||
else if (now - breaker.lagSince >= BREAKER_LAG_HOLD) { tripBreaker({ lagS: behind / FPS }); return; }
|
||||
} else {
|
||||
breaker.lagSince = 0;
|
||||
state.lagS = behind > 0 ? behind / FPS : null;
|
||||
}
|
||||
if (!breaker.last) { breaker.last = now; return; }
|
||||
breaker.dt += (now - breaker.last) / 1000;
|
||||
breaker.last = now;
|
||||
@@ -238,18 +267,7 @@ function breakerTick(steps) {
|
||||
breaker.strikes = 0;
|
||||
return;
|
||||
}
|
||||
if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker(fps);
|
||||
}
|
||||
|
||||
function tripBreaker(fps) {
|
||||
console.warn(`[metabolism] circuit breaker: sustained ${fps.toFixed(1)} fps < ${BREAKER_FPS} — stopping`);
|
||||
breakerReset();
|
||||
pauseCurrent();
|
||||
setOverlay(
|
||||
`<b>the world outgrew this machine</b>` +
|
||||
`<small>sustained ${Math.round(fps)} fps < ${BREAKER_FPS} — playback stopped. ` +
|
||||
`Try a lower resolution (res) or another seed.</small>`
|
||||
);
|
||||
if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker({ fps });
|
||||
}
|
||||
|
||||
let hudTick = 0;
|
||||
@@ -261,7 +279,7 @@ function loop() {
|
||||
const before = c.player.run.world.frame;
|
||||
c.player.stepTo(target);
|
||||
c.player.renderCurrent();
|
||||
breakerTick(target - before);
|
||||
breakerTick(target - before, Math.max(0, target - c.player.run.world.frame));
|
||||
updateSeekUI(pos);
|
||||
if (++hudTick % 4 === 0) updateHUD();
|
||||
} else {
|
||||
@@ -339,7 +357,8 @@ function updateHUD() {
|
||||
const s = w.stats();
|
||||
els.hud.textContent =
|
||||
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` +
|
||||
(state.fps != null ? ` · ${Math.round(state.fps)} fps` : '');
|
||||
(state.fps != null ? ` · ${Math.round(state.fps)} fps` : '') +
|
||||
(state.lagS != null && state.lagS >= 0.5 ? ` · ${(state.lagS).toFixed(1)}s behind` : '');
|
||||
const cam = c.player.run.camera;
|
||||
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close';
|
||||
els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`;
|
||||
|
||||
@@ -10,6 +10,18 @@ import { Run } from '../film.js';
|
||||
|
||||
export const FPS = 30;
|
||||
|
||||
/**
|
||||
* The demo's pooled parallelism. The main thread keeps the render and the
|
||||
* page; the spare cores grow the world. Only when cross-origin isolation
|
||||
* exposes SharedArrayBuffer and the hardware has cores beyond
|
||||
* (main thread + one) — otherwise stay serial and let the breaker decide.
|
||||
*/
|
||||
export function demoWorkers() {
|
||||
if (typeof SharedArrayBuffer === 'undefined' || self.crossOriginIsolated === false) return 0;
|
||||
const hc = navigator.hardwareConcurrency || 4;
|
||||
return hc > 2 ? Math.min(4, hc - 2) : 0;
|
||||
}
|
||||
|
||||
export class Player {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
@@ -21,6 +33,7 @@ export class Player {
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.workers = demoWorkers(); // fixed per page load, reused on rebuilds
|
||||
this.run = null;
|
||||
this.song = null;
|
||||
this.img = null;
|
||||
@@ -31,7 +44,10 @@ export class Player {
|
||||
this.song = song;
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed });
|
||||
// The old pool (if any) never shows another audio clock: stop it
|
||||
// before it silently accumulates one idle world per re-grow.
|
||||
if (this.run?.world?.pool) this.run.world.dispose();
|
||||
this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed, workers: this.workers });
|
||||
// The renderer's framebuffer aliases the canvas ImageData so there is
|
||||
// exactly one copy per frame: render() draws straight into img.data and
|
||||
// putImageData() uploads it. Same bitmap the renderer paints into node.
|
||||
@@ -44,14 +60,21 @@ export class Player {
|
||||
this.run.stepTo(frame);
|
||||
}
|
||||
|
||||
/** Resolves once the world's pool can run phases (serial: immediately). */
|
||||
warm() {
|
||||
return this.run.world.warm();
|
||||
}
|
||||
|
||||
/** Step the world to a time in seconds, rebuilding on a backwards seek. */
|
||||
seekTo(seconds) {
|
||||
const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1);
|
||||
if (target < this.run.world.frame) {
|
||||
// The world and the camera are stateful; a backwards seek re-grows
|
||||
// from frame 0. Forward stepping is cheap (pure sim), so only a
|
||||
// rewind pays the replay cost.
|
||||
this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed });
|
||||
// rewind pays the replay cost. The fresh pool boots cold (web);
|
||||
// the demo loop skips steps until it answers.
|
||||
this.run.world.dispose();
|
||||
this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed, workers: this.workers });
|
||||
this.run.renderer.bitmap.data = this.img.data;
|
||||
}
|
||||
this.run.stepTo(target);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Camera } from './camera.js';
|
||||
import { Renderer } from './render/view.js';
|
||||
|
||||
export class Run {
|
||||
constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis } = {}) {
|
||||
constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis, workers } = {}) {
|
||||
this.song = song;
|
||||
this.fps = fps;
|
||||
this.width = width;
|
||||
@@ -17,6 +17,7 @@ export class Run {
|
||||
seed,
|
||||
...(field ? { field } : {}),
|
||||
...(genesis !== undefined ? { genesis } : {}),
|
||||
...(workers ? { workers } : {}),
|
||||
});
|
||||
this.camera = new Camera(song, this.world, { fps, width, height });
|
||||
this.renderer = new Renderer(width, height);
|
||||
@@ -24,6 +25,10 @@ export class Run {
|
||||
|
||||
/** Advance to a frame, updating the camera on every step. */
|
||||
stepTo(frame) {
|
||||
// A cold web pool (workers still booting) cannot advance the world:
|
||||
// calling step() would no-op forever inside this loop. The next
|
||||
// stepTo after warm-up catches up to the audio clock.
|
||||
if (!this.world.poolWarm) return this;
|
||||
while (this.world.frame < frame) {
|
||||
this.world.step();
|
||||
this.camera.update(this.world.frame);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// The shared body of the region pool's workers.
|
||||
//
|
||||
// Both shells — node's worker_threads (region-worker.js) and the browser's
|
||||
// module worker (region-worker.web.js) — receive the same init object, build
|
||||
// the same bare World over the shared arena, and run the same phase body
|
||||
// below. The protocol is written down in region-worker.js.
|
||||
|
||||
import { World } from './world.js';
|
||||
|
||||
/**
|
||||
* Attach a region to the shared arena. `init` carries the song and geometry
|
||||
* (factory references live in world.js); the typed-array views are carved
|
||||
* from the same buffers the main thread owns, so the worker is the same
|
||||
* world seen through its band.
|
||||
*/
|
||||
export function makeRegion(init) {
|
||||
const w = new World(init.song, {
|
||||
field: init.field,
|
||||
seed: init.seed,
|
||||
fps: init.fps,
|
||||
substeps: init.substeps,
|
||||
vmEvery: init.vmEvery,
|
||||
sab: init.arena,
|
||||
layout: init.layout,
|
||||
bare: true,
|
||||
});
|
||||
const region = {
|
||||
w,
|
||||
band: init.band,
|
||||
ctrl: new Int32Array(init.ctrl),
|
||||
mail: new Int32Array(init.mail),
|
||||
warmth: new Float32Array(init.mail, 12, 1),
|
||||
seen: 0,
|
||||
};
|
||||
// boot counter (mail[63]): beginPhase's stall diagnostic reports it; the
|
||||
// web shell also posts a one-shot ack so the main can await warmth.
|
||||
Atomics.add(region.mail, 63, 1);
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a phase and run it. False only when the main thread shut the
|
||||
* pool down (negative sequence); otherwise the worker sleeps again.
|
||||
*/
|
||||
export function regionPhase(region) {
|
||||
const { w, band, ctrl, mail, warmth } = region;
|
||||
Atomics.wait(ctrl, 0, region.seen);
|
||||
const seq = Atomics.load(ctrl, 0);
|
||||
if (seq < 0) return false;
|
||||
region.seen = seq;
|
||||
// The frame and the decide cadence are mailed per phase; the worker's
|
||||
// own state is otherwise derived from the shared arena. Without the
|
||||
// frame the worker's regime would be frozen at the song's first section.
|
||||
w.frame = mail[0];
|
||||
w.warmth = warmth[0];
|
||||
const phase = mail[2];
|
||||
const decide = mail[1] === 1;
|
||||
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 (the named arrays drift
|
||||
// frame by frame; both sides must address the same storage)
|
||||
w.pop = [];
|
||||
for (let k = 0; k < w.m; k++) w.pop.push(w.popSlots[mail[20 + k]]);
|
||||
w.alleg = [];
|
||||
for (let k = 0; k < w.B; k++) w.alleg.push(w.allegSlots[mail[40 + k]]);
|
||||
w.carry = [];
|
||||
for (let k = 0; k < w.m; k++) w.carry.push(w.carrySlots[mail[60 + k]]);
|
||||
// and the NEXT-slot orders: the main's afterFields swaps pop<->popNext
|
||||
// every substep, so the workers' `next` views must point at the same
|
||||
// storage the main is about to swap in.
|
||||
w.popNext = [];
|
||||
for (let k = 0; k < w.m; k++) w.popNext.push(w.popSlots[mail[80 + k]]);
|
||||
w.allegNext = [];
|
||||
for (let k = 0; k < w.B; k++) w.allegNext.push(w.allegSlots[mail[100 + k]]);
|
||||
w.carryNext = [];
|
||||
for (let k = 0; k < w.m; k++) w.carryNext.push(w.carrySlots[mail[120 + k]]);
|
||||
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
@@ -1,60 +1,24 @@
|
||||
// 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.
|
||||
// A region worker (node variant): 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
|
||||
// [5..] alive body ids, [20..] pop order, [40..] alleg order,
|
||||
// [60..] 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.
|
||||
// The phase body itself lives in region-core.js, shared with the browser
|
||||
// worker so the two runtimes cannot drift apart.
|
||||
|
||||
import { World } from './world.js';
|
||||
import { makeRegion, regionPhase } from './region-core.js';
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
|
||||
const region = makeRegion(workerData);
|
||||
|
||||
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);
|
||||
if (!regionPhase(region)) break;
|
||||
}
|
||||
parentPort.close();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// A region worker (browser variant): the same band body as the node worker,
|
||||
// booted from a postMessage'd init rather than workerData. Runs as an ES
|
||||
// module worker (Vite bundles the graph the same way as the page's own).
|
||||
//
|
||||
// Booting is asynchronous: the module evaluates (and processes this init
|
||||
// message) a beat after the main thread spawns the pool. The shell's first
|
||||
// act inside onmessage is to ack (`pool`) — the main's warm gate (see
|
||||
// World.warmPool/step) skips stepping until every worker has acked, which
|
||||
// matters because a pending worker's script load is frozen by a hot main
|
||||
// thread (Chromium routes worker startup through the window's loop).
|
||||
|
||||
import { makeRegion, regionPhase } from './region-core.js';
|
||||
|
||||
const host = globalThis.self ?? globalThis;
|
||||
|
||||
host.onmessage = ({ data: init }) => {
|
||||
const region = makeRegion(init);
|
||||
// Boot ack: the pool's warm gate on the main counts these — one per
|
||||
// worker. The counter also lives in mail[63] (raised in makeRegion).
|
||||
postMessage('pool');
|
||||
// The phase loop blocks on Atomics.wait between phases; the worker's
|
||||
// event loop never returns to this handler. Shutdown is terminate().
|
||||
for (;;) {
|
||||
if (!regionPhase(region)) return;
|
||||
}
|
||||
};
|
||||
+301
-90
@@ -31,17 +31,20 @@
|
||||
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.
|
||||
* How the region pool's workers are built: worker_threads on node, ES module
|
||||
* workers in the browser (the demo server already sends the COOP/COEP headers
|
||||
* that expose SharedArrayBuffer under cross-origin isolation). Resolved
|
||||
* lazily so the module keeps loading anywhere.
|
||||
*/
|
||||
function workerCtor() {
|
||||
try {
|
||||
const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads');
|
||||
return mod?.Worker ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
if (mod?.Worker) return { ctor: mod.Worker, kind: 'node' };
|
||||
} catch { /* fall through */ }
|
||||
if (typeof globalThis.Worker === 'function') {
|
||||
return { ctor: globalThis.Worker, kind: 'web' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import { wellPos, wellEnv, buildKernel } from './wells.js';
|
||||
import {
|
||||
@@ -167,6 +170,11 @@ export function arenaLayout(field, m, B, 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));
|
||||
// Per-row accumulation partials for the frame counters that scattered
|
||||
// writes used to race across bands (hunger, move, births, deaths).
|
||||
// Bands always cover whole rows, and the merge in afterFields runs in a
|
||||
// fixed row order, so the folded sum is identical for any worker count.
|
||||
items.push(A('bandStats', field * 4 * 4, Float32Array, field * 4));
|
||||
return { items, bytes: items.reduce((a, it) => a + it.bytes, 0) };
|
||||
}
|
||||
|
||||
@@ -186,6 +194,7 @@ export class World {
|
||||
this.palette = song.palette;
|
||||
this.wellTimeline = song.wells.wells;
|
||||
this.rng = new Rng(seed >>> 0).fork('world');
|
||||
this.seed = seed >>> 0; // the workers must seed the SAME noise field
|
||||
|
||||
// Pre-simulation (genesis): how long the world evolves before frame 0,
|
||||
// so the opening is an old ecosystem, not a blank plate. The amount is
|
||||
@@ -260,6 +269,18 @@ export class World {
|
||||
|
||||
this.activity = carve('activity', Float32Array, 32 * 32);
|
||||
this.hungerCell = carve('hungerMass', Float32Array, 1); // shared accumulator
|
||||
this.bandStats = carve('bandStats', Float32Array, field * 4); // per-row partials
|
||||
|
||||
// The gather advection's motion cache: computed once per source cell
|
||||
// and shared by every destination it feeds. PRIVATE to this world
|
||||
// instance (serial main or one region worker) — never in the arena,
|
||||
// so concurrent regions cannot touch each other's cache.
|
||||
this._advSeen = new Uint8Array(n);
|
||||
this._advVx = new Float64Array(n);
|
||||
this._advVy = new Float64Array(n);
|
||||
this._advmx = new Float64Array(n);
|
||||
this._advmy = new Float64Array(n);
|
||||
this._advLeave = new Float64Array(n);
|
||||
|
||||
// Rock (M2.8): the inert resource. A barrier the organisms cannot
|
||||
// cross, that the song drops and shatters, and that builders quarry,
|
||||
@@ -418,6 +439,7 @@ export class World {
|
||||
};
|
||||
|
||||
this.frame = -1;
|
||||
this.poolWarm = true; // web pools go cold at boot; see step()/warmPool()
|
||||
this.substepCount = 0;
|
||||
this.pulses = [];
|
||||
this.totalPop = 0;
|
||||
@@ -443,26 +465,80 @@ export class World {
|
||||
const WorkerC = workerCtor();
|
||||
if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial
|
||||
if (!bare && workers > 1) {
|
||||
this.workerKind = WorkerC.kind;
|
||||
this.ctrl = new Int32Array(new SharedArrayBuffer(32));
|
||||
this.mail = new Int32Array(new SharedArrayBuffer(512));
|
||||
this.pool = [];
|
||||
const bands = [];
|
||||
let ready = 0; // 'pool' boot acks (web only; node answers barriers directly)
|
||||
if (WorkerC.kind === 'web') {
|
||||
// Web pools go cold at boot; step()/Run.stepTo skip frames
|
||||
// until every worker answers its boot ack (see below).
|
||||
this.poolWarm = false;
|
||||
this.poolReady = new Promise((res) => { this._warmResolve = res; });
|
||||
} else {
|
||||
// Node workers are native threads; they answer the first
|
||||
// phase barrier without any main-thread involvement.
|
||||
this.poolReady = Promise.resolve();
|
||||
}
|
||||
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
|
||||
const init = {
|
||||
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],
|
||||
};
|
||||
let worker;
|
||||
if (WorkerC.kind === 'web') {
|
||||
// Module workers boot asynchronously (vite serves the
|
||||
// script through a transform; the module evaluates and
|
||||
// processes the init message a beat later). Each worker
|
||||
// posts a single `pool` boot ack after makeRegion; the
|
||||
// pool is warm (see step()) only once every worker has
|
||||
// answered. A beginPhase spin must never be entered on a
|
||||
// cold pool: Chromium routes a pending worker's script
|
||||
// load through the busy main thread, so a hot spin while
|
||||
// the pool boots would freeze the boot itself.
|
||||
// (The literal `new Worker(new URL(...))` matters: vite
|
||||
// only recognizes the canonical spelling when it bundles
|
||||
// the worker's graph for the dist build.)
|
||||
worker = new Worker(new URL('./region-worker.web.js', import.meta.url), { type: 'module' });
|
||||
worker.onerror = (e) => {
|
||||
// A failed worker never answers phase barriers; warm
|
||||
// anyway so the world keeps stepping into the existing
|
||||
// `region pool stalled` diagnostic (done N/4) instead
|
||||
// of freezing silently behind the cold gate.
|
||||
console.error(`region worker ${k} failed: ${e.message} @${e.filename}:${e.lineno}`);
|
||||
this.warmPool();
|
||||
};
|
||||
worker.onmessage = () => {
|
||||
ready += 1; // 'pool' boot ack; mail[63] raised in makeRegion
|
||||
if (ready >= this.pool.length) this.warmPool();
|
||||
};
|
||||
worker.postMessage(init);
|
||||
} else {
|
||||
const NodeWorker = WorkerC.ctor;
|
||||
worker = new NodeWorker(new URL('./region-worker.js', import.meta.url), { workerData: init });
|
||||
worker.unref(); // the pool never holds the process open
|
||||
}
|
||||
this.pool.push(worker);
|
||||
}
|
||||
this.bands = bands;
|
||||
// The phase barrier. Browsers forbid a blocking Atomics.wait on a
|
||||
// window's main thread; the probe decides between blocking waits
|
||||
// (node, worker hosts) and plain spinning (Chrome's main thread).
|
||||
this.blockingWait = (() => {
|
||||
try {
|
||||
const probe = new Int32Array(new SharedArrayBuffer(4));
|
||||
Atomics.wait(probe, 0, 0, 1);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,7 +946,24 @@ export class World {
|
||||
return living;
|
||||
}
|
||||
|
||||
/** The pool's boot handshake (web workers only): warm once every worker answered. */
|
||||
warmPool() {
|
||||
if (this.poolWarm) return;
|
||||
this.poolWarm = true;
|
||||
this._warmResolve?.();
|
||||
}
|
||||
|
||||
/** Resolves when the pool can run phases (immediately for serial/node). */
|
||||
warm() {
|
||||
return this.poolReady ?? Promise.resolve();
|
||||
}
|
||||
|
||||
step() {
|
||||
// Cold pool: the workers are still booting their script (vite serves
|
||||
// it through a transform; a spin here would freeze that boot —
|
||||
// Chromium routes a pending worker's script load through this
|
||||
// thread). Skip frames until warm; post-warm catch-up is unbounded.
|
||||
if (!this.poolWarm) return null;
|
||||
this.frame++;
|
||||
const f = this.frame;
|
||||
const regime = this.regimeForFrame(f);
|
||||
@@ -1109,7 +1202,7 @@ export class World {
|
||||
// forces already sit in fxX/fxY, actions in the buffer.
|
||||
const syn = this.syn[s];
|
||||
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
|
||||
synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare;
|
||||
synScratch[s * actFC + b] = syn.base + (1 - syn.base) * kinShare;
|
||||
continue;
|
||||
}
|
||||
const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4;
|
||||
@@ -1170,7 +1263,10 @@ export class World {
|
||||
gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]);
|
||||
|
||||
const flat = ensureFlat(strains[s]);
|
||||
runProgram(this.stMem, s * REGS * FC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN);
|
||||
// Memory layout: [strain][block][REGS] — the base of this
|
||||
// (strain, block) is REGS registers into the strain's slab
|
||||
// of REGS×fc cells (the arena sizes stMem m*REGS*fc).
|
||||
runProgram(this.stMem, s * REGS * actFC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN);
|
||||
// Remember the aim: the next substep's work-senses look there.
|
||||
let nx = act[12] !== 0 ? Math.sign(act[12]) : Math.sign(act[0]);
|
||||
let ny = act[13] !== 0 ? Math.sign(act[13]) : Math.sign(act[1]);
|
||||
@@ -1196,9 +1292,32 @@ export class World {
|
||||
// crowd — the share of the block's population that is kin.
|
||||
const syn = this.syn[s];
|
||||
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
|
||||
synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare;
|
||||
synScratch[s * actFC + b] = syn.base + (1 - syn.base) * kinShare;
|
||||
}
|
||||
}
|
||||
// Block-owner velocity pass: the density-weighted mean strain force
|
||||
// per block, a pure function of this block's phase-1 sums (blkPop)
|
||||
// and its own just-written fxX/fxY. Nothing here reads a neighbour
|
||||
// block, so whichever band owns the block computes the same bits —
|
||||
// and phase 3 (allegiance/carry advection) reads velocities that are
|
||||
// FULLY written once the phase-2 barrier passes. (Computing this at
|
||||
// the top of phase 3 instead left band-edge reads racing against the
|
||||
// neighbouring worker's writes.)
|
||||
const velX = this.velX;
|
||||
const velY = this.velY;
|
||||
for (let b = c0 * FC; b < c1 * FC; b++) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let wPop = 0;
|
||||
for (let s = 0; s < m; s++) {
|
||||
const p = blkPop[s][b];
|
||||
sx += fxX[s][b] * p;
|
||||
sy += fxY[s][b] * p;
|
||||
wPop += p;
|
||||
}
|
||||
velX[b] = wPop > 1e-6 ? sx / wPop : 0;
|
||||
velY[b] = wPop > 1e-6 ? sy / wPop : 0;
|
||||
}
|
||||
}
|
||||
|
||||
gradOf(arr, b, ug, dg, lg, rg) { return Math.sign(arr[rg] - arr[lg]); }
|
||||
@@ -1254,18 +1373,31 @@ export class World {
|
||||
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
|
||||
// ...and the NEXT-slot orders: afterFields swaps pop<->popNext (and
|
||||
// the alleg/carry pairs) on the main every substep, so the workers'
|
||||
// `next` views must follow the same storage the main will swap —
|
||||
// otherwise every substep after the first lands in stale space.
|
||||
for (let s = 0; s < this.m; s++) Atomics.store(mail, 80 + s, this.popSlots.indexOf(this.popNext[s]));
|
||||
for (let b = 0; b < this.B; b++) Atomics.store(mail, 100 + b, this.allegSlots.indexOf(this.allegNext[b]));
|
||||
for (let s = 0; s < this.m; s++) Atomics.store(mail, 120 + s, this.carrySlots.indexOf(this.carryNext[s]));
|
||||
// raise the phase sequence and wait until 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);
|
||||
const deadline = performance.now() + 30000;
|
||||
let polls = 0;
|
||||
while (Atomics.load(ctrl, 1) < this.pool.length) {
|
||||
if (++polls > 2000) {
|
||||
if (this.blockingWait) {
|
||||
Atomics.wait(ctrl, 1, Atomics.load(ctrl, 1), 25);
|
||||
}
|
||||
// The spin path must poll generously (a module worker's first
|
||||
// boot can take hundreds of ms); the clock only polices waits,
|
||||
// it never touches world state.
|
||||
if (++polls > 64 && performance.now() > deadline) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,6 +1521,30 @@ export class World {
|
||||
|
||||
// ---- after the fields: swap + the rock economy (main; aim targets cross bands)
|
||||
afterFields(regime) {
|
||||
// Fold the per-row accumulator partials into the frame counters.
|
||||
// Only the main thread runs this, and the fold visits rows in the
|
||||
// SAME fixed order whatever the band layout (a band always covers
|
||||
// whole rows of fine cells) — so serial, 2 regions and 4 regions
|
||||
// add up the same bits. The slots zero as they fold: the next
|
||||
// substep starts from a clean partial set.
|
||||
const Fm = this.field;
|
||||
const rowStats = this.bandStats;
|
||||
let hunger = this.hungerCell[0];
|
||||
let move = this.moveAccum;
|
||||
let births = this.birthsThisFrame;
|
||||
let deaths = this.deathsThisFrame;
|
||||
for (let r = 0; r < Fm; r++) {
|
||||
const k = r * 4;
|
||||
hunger += rowStats[k];
|
||||
move += rowStats[k + 1];
|
||||
births += rowStats[k + 2];
|
||||
deaths += rowStats[k + 3];
|
||||
rowStats[k] = 0; rowStats[k + 1] = 0; rowStats[k + 2] = 0; rowStats[k + 3] = 0;
|
||||
}
|
||||
this.hungerCell[0] = hunger;
|
||||
this.moveAccum = move;
|
||||
this.birthsThisFrame = births;
|
||||
this.deathsThisFrame = deaths;
|
||||
const aimTarget = (b, s) => {
|
||||
const actFC = this.fc;
|
||||
const actBuf = this.stAct;
|
||||
@@ -1561,6 +1717,8 @@ export class World {
|
||||
const noise = this.noise;
|
||||
const noise2 = this.noise2;
|
||||
const n = this.n;
|
||||
const F = this.field;
|
||||
const stats = this.bandStats;
|
||||
const rock = this.rock;
|
||||
const blkPop = this.blkPop;
|
||||
const blkTotal = this.blkTotal;
|
||||
@@ -1569,29 +1727,16 @@ export class World {
|
||||
const actBuf = this.stAct;
|
||||
const actFC = fc;
|
||||
const hostility = this.hostility;
|
||||
// Block velocities committed by phaseStrains before the phase-2
|
||||
// barrier (see the block-owner velocity pass there); only read here.
|
||||
const velX = this.velX;
|
||||
const velY = this.velY;
|
||||
|
||||
// ---- 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++) {
|
||||
@@ -1621,6 +1766,7 @@ export class World {
|
||||
for (let i = lo; i < hi; i++) {
|
||||
const r0 = res[i];
|
||||
const b = coarseOf[i];
|
||||
const statsRow = (i / F) | 0;
|
||||
const dom = domB[b];
|
||||
if (r0 < 1e-6 && density[i] < 1e-6) continue;
|
||||
|
||||
@@ -1629,7 +1775,10 @@ export class World {
|
||||
|
||||
// Saturated food factor (rational approximation, no transcendentals).
|
||||
const foodFactor = (r0 * 5) / (1 + r0 * 5);
|
||||
this.hungerCell[0] += density[i] * (1 - foodFactor);
|
||||
// The frame counters accumulate per-row partials (bandStats):
|
||||
// rows are never split across bands and afterFields folds them
|
||||
// in a fixed row order, so serial and any pool agree on the bit.
|
||||
stats[statsRow * 4] += 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.
|
||||
@@ -1665,7 +1814,7 @@ export class World {
|
||||
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];
|
||||
const synB = synScratch[s * actFC + 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,
|
||||
@@ -1697,8 +1846,8 @@ export class World {
|
||||
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;
|
||||
if (after > before) stats[statsRow * 4 + 2] += after - before;
|
||||
else if (after < before) stats[statsRow * 4 + 3] += before - after;
|
||||
}
|
||||
density[i] = dNew;
|
||||
|
||||
@@ -1719,17 +1868,17 @@ export class World {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
* destination cell sums its own inflow from src — its own stay-home
|
||||
* terms, the four edge arrivals it now receives, its diffusion share
|
||||
* and the sheds of its neighbours — and writes ONLY to itself. A band
|
||||
* reads a one-cell halo and never writes outside its rows, which is
|
||||
* what makes the field phase band-parallel EXACTLY, whatever the
|
||||
* region count. The per-destination arrival order replicates the old
|
||||
* scatter's per-cell accumulation order bit for bit: arrivals from
|
||||
* i-F and i-1 (processed earlier in the old loop), then i's own
|
||||
* terms in full, then the arrivals from i+1 and i+F.
|
||||
*/
|
||||
advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track, lo = 0, hi = this.n) {
|
||||
const F = this.field;
|
||||
@@ -1741,7 +1890,14 @@ export class World {
|
||||
const noise = this.noise;
|
||||
const noise2 = this.noise2;
|
||||
const mask = this.rockMask;
|
||||
dst.fill(0, lo, hi);
|
||||
const seen = this._advSeen;
|
||||
const vxC = this._advVx;
|
||||
const vyC = this._advVy;
|
||||
const mxC = this._advmx;
|
||||
const myC = this._advmy;
|
||||
const leaveC = this._advLeave;
|
||||
const n = this.n;
|
||||
seen.fill(0, 0, n);
|
||||
// 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) => {
|
||||
@@ -1756,51 +1912,106 @@ export class World {
|
||||
if (open === 0) return 0;
|
||||
return (nb - open * vj) * diff * sub;
|
||||
};
|
||||
for (let i = lo; i < hi; i++) {
|
||||
const v = src[i];
|
||||
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 (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;
|
||||
// Rock is a wall: mass aimed at a stone cell stays put and
|
||||
// piles against it.
|
||||
const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i;
|
||||
const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i;
|
||||
dst[mask[tx] ? i : tx] += mx;
|
||||
dst[mask[ty] ? i : ty] += my;
|
||||
|
||||
if (track) {
|
||||
const ax = ((i % F) * 32 / F) | 0;
|
||||
const ay = ((i / F) * 32 / F) | 0;
|
||||
this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6;
|
||||
this.moveAccum += leave;
|
||||
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;
|
||||
};
|
||||
// One motion solve per source cell, shared by every destination cell
|
||||
// it feeds (halo rows included). Each world instance — the serial
|
||||
// main or one region worker — walks its own calls with its own
|
||||
// cache, so concurrent bands never collide on it.
|
||||
const motion = (k) => {
|
||||
if (seen[k]) return;
|
||||
seen[k] = 1;
|
||||
let vx = 0, vy = 0, mx = 0, my = 0, leave = 0;
|
||||
const v = src[k];
|
||||
if (v > 1e-6) {
|
||||
const b = coarseOf[k];
|
||||
vx = fxArr[b] + (track ? noise[k] * jitterAmt : 0);
|
||||
vy = fyArr[b] + (track ? noise2[k] * jitterAmt : 0);
|
||||
const w = Math.abs(vx) + Math.abs(vy);
|
||||
leave = v * frac;
|
||||
if (leave > 0 && w > 1e-6) {
|
||||
mx = (leave * Math.abs(vx)) / w;
|
||||
my = (leave * Math.abs(vy)) / w;
|
||||
}
|
||||
} else if (v > 1e-6) {
|
||||
dst[i] += v;
|
||||
}
|
||||
vxC[k] = vx;
|
||||
vyC[k] = vy;
|
||||
mxC[k] = mx;
|
||||
myC[k] = my;
|
||||
leaveC[k] = leave;
|
||||
};
|
||||
dst.fill(0, lo, hi);
|
||||
const stats = this.bandStats;
|
||||
for (let i = lo; i < hi; i++) {
|
||||
// ---- arrivals from cells the old loop processed earlier ----
|
||||
// from the row above (old loop visited i-F first):
|
||||
const up = i - F;
|
||||
if (up >= 0 && !mask[i]) {
|
||||
motion(up);
|
||||
if (vyC[up] > 0.02) dst[i] += myC[up]; // its down-flow landed here
|
||||
}
|
||||
// from the left:
|
||||
if (i % F !== 0 && !mask[i]) {
|
||||
motion(i - 1);
|
||||
if (vxC[i - 1] > 0.02) dst[i] += mxC[i - 1]; // its right-flow landed here
|
||||
}
|
||||
// ---- the cell's own terms, exactly as the old body issued them
|
||||
const v = src[i];
|
||||
if (v > 1e-6) {
|
||||
motion(i);
|
||||
const leave = leaveC[i];
|
||||
const vx = vxC[i];
|
||||
const vy = vyC[i];
|
||||
if (leave > 0 && Math.abs(vx) + Math.abs(vy) > 1e-6) {
|
||||
const mx = mxC[i];
|
||||
const my = myC[i];
|
||||
dst[i] += v - leave;
|
||||
// Rock is a wall: mass aimed at a stone cell stays put
|
||||
// (and a slow drift has nowhere to go: it stays too —
|
||||
// the edge-clamped neighbour arrays make tx/ty === i).
|
||||
const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i;
|
||||
const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i;
|
||||
if (mask[tx] || tx === i) dst[i] += mx;
|
||||
if (mask[ty] || ty === i) dst[i] += my;
|
||||
|
||||
// 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 (track) {
|
||||
const ax = ((i % F) * 32 / F) | 0;
|
||||
const ay = ((i / F) * 32 / F) | 0;
|
||||
this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6;
|
||||
stats[((i / F) | 0) * 4 + 1] += leave;
|
||||
}
|
||||
} else {
|
||||
// too slow or too spent to move: everything stays
|
||||
dst[i] += v;
|
||||
}
|
||||
if (v > 1e-6) dst[i] += flux(i) * 0.5;
|
||||
}
|
||||
// The neighbour's diffusion sheds its equal share this way —
|
||||
// independent of whether this cell holds anything.
|
||||
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]);
|
||||
// ---- arrivals from cells the old loop processed later ----
|
||||
// from the right:
|
||||
if (i % F !== F - 1 && !mask[i]) {
|
||||
motion(i + 1);
|
||||
if (vxC[i + 1] < -0.02) dst[i] += mxC[i + 1]; // its left-flow landed here
|
||||
}
|
||||
// from the row below:
|
||||
const down = i + F;
|
||||
if (down < n && !mask[i]) {
|
||||
motion(down);
|
||||
if (vyC[down] < -0.02) dst[i] += myC[down]; // its up-flow landed here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,11 +97,11 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', ()
|
||||
});
|
||||
|
||||
test('the region pool is exact: parallel == serial, bit for bit', () => {
|
||||
const grow = (workers) => {
|
||||
const grow = (workers, { frames = 150, seed = 12345 } = {}) => {
|
||||
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 run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers });
|
||||
for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); }
|
||||
const history = Array.from(run.world.history.alive.slice(0, frames));
|
||||
const trail = Array.from(run.world.trail[0].slice(0, 4000));
|
||||
run.world.dispose?.();
|
||||
return { history, trail };
|
||||
@@ -113,6 +113,17 @@ test('the region pool is exact: parallel == serial, bit for bit', () => {
|
||||
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');
|
||||
|
||||
// LONG: across a section boundary (drone's drop lands at frame 1545) and
|
||||
// with a seed distinct from song.seed (the workers must be told the same
|
||||
// seed — they seed their own jitter-noise fields). A 150-frame run inside
|
||||
// one section with the default seed passes even with both bugs present.
|
||||
const longSerial = grow(0, { frames: 1600, seed: 999 });
|
||||
const longTwo = grow(2, { frames: 1600, seed: 999 });
|
||||
const longEq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
|
||||
a.trail.every((v, i) => v === b.trail[i]);
|
||||
assert.ok(longEq(longSerial, longTwo),
|
||||
'2 regions must match the serial world exactly across the section boundary');
|
||||
});
|
||||
|
||||
test('genesis is deterministic and song-scaled', () => {
|
||||
|
||||
Reference in New Issue
Block a user