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,
|
playing: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
fps: null, // achieved film rate, measured by the circuit breaker
|
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) ------------------
|
// ---- audio pipeline (browser side of the shared pipeline) ------------------
|
||||||
@@ -99,6 +100,7 @@ async function loadSongData(id, seed, res) {
|
|||||||
const song = buildSong(analysis, seed);
|
const song = buildSong(analysis, seed);
|
||||||
const player = new Player(els.view, w, h);
|
const player = new Player(els.view, w, h);
|
||||||
player.build(song);
|
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 });
|
const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
|
||||||
return { entry: state.byId.get(id), song, analysis, player, audio };
|
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_FPS = 25; // the minimum sustained film rate we accept
|
||||||
const BREAKER_WIN = 2.0; // seconds per measurement window
|
const BREAKER_WIN = 2.0; // seconds per measurement window
|
||||||
const BREAKER_STRIKES = 2; // consecutive slow windows before we stop
|
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() {
|
function breakerReset() {
|
||||||
breaker.frames = 0;
|
breaker.frames = 0;
|
||||||
breaker.dt = 0;
|
breaker.dt = 0;
|
||||||
breaker.last = 0;
|
breaker.last = 0;
|
||||||
breaker.strikes = 0;
|
breaker.strikes = 0;
|
||||||
|
breaker.lagSince = 0;
|
||||||
state.fps = null;
|
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();
|
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; }
|
if (!breaker.last) { breaker.last = now; return; }
|
||||||
breaker.dt += (now - breaker.last) / 1000;
|
breaker.dt += (now - breaker.last) / 1000;
|
||||||
breaker.last = now;
|
breaker.last = now;
|
||||||
@@ -238,18 +267,7 @@ function breakerTick(steps) {
|
|||||||
breaker.strikes = 0;
|
breaker.strikes = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (++breaker.strikes >= BREAKER_STRIKES) tripBreaker(fps);
|
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>`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let hudTick = 0;
|
let hudTick = 0;
|
||||||
@@ -261,7 +279,7 @@ function loop() {
|
|||||||
const before = c.player.run.world.frame;
|
const before = c.player.run.world.frame;
|
||||||
c.player.stepTo(target);
|
c.player.stepTo(target);
|
||||||
c.player.renderCurrent();
|
c.player.renderCurrent();
|
||||||
breakerTick(target - before);
|
breakerTick(target - before, Math.max(0, target - c.player.run.world.frame));
|
||||||
updateSeekUI(pos);
|
updateSeekUI(pos);
|
||||||
if (++hudTick % 4 === 0) updateHUD();
|
if (++hudTick % 4 === 0) updateHUD();
|
||||||
} else {
|
} else {
|
||||||
@@ -339,7 +357,8 @@ function updateHUD() {
|
|||||||
const s = w.stats();
|
const s = w.stats();
|
||||||
els.hud.textContent =
|
els.hud.textContent =
|
||||||
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}` +
|
`${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 cam = c.player.run.camera;
|
||||||
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close';
|
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)}`;
|
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;
|
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 {
|
export class Player {
|
||||||
/**
|
/**
|
||||||
* @param {HTMLCanvasElement} canvas
|
* @param {HTMLCanvasElement} canvas
|
||||||
@@ -21,6 +33,7 @@ export class Player {
|
|||||||
this.ctx = canvas.getContext('2d');
|
this.ctx = canvas.getContext('2d');
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
|
this.workers = demoWorkers(); // fixed per page load, reused on rebuilds
|
||||||
this.run = null;
|
this.run = null;
|
||||||
this.song = null;
|
this.song = null;
|
||||||
this.img = null;
|
this.img = null;
|
||||||
@@ -31,7 +44,10 @@ export class Player {
|
|||||||
this.song = song;
|
this.song = song;
|
||||||
this.canvas.width = this.width;
|
this.canvas.width = this.width;
|
||||||
this.canvas.height = this.height;
|
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
|
// The renderer's framebuffer aliases the canvas ImageData so there is
|
||||||
// exactly one copy per frame: render() draws straight into img.data and
|
// exactly one copy per frame: render() draws straight into img.data and
|
||||||
// putImageData() uploads it. Same bitmap the renderer paints into node.
|
// putImageData() uploads it. Same bitmap the renderer paints into node.
|
||||||
@@ -44,14 +60,21 @@ export class Player {
|
|||||||
this.run.stepTo(frame);
|
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. */
|
/** Step the world to a time in seconds, rebuilding on a backwards seek. */
|
||||||
seekTo(seconds) {
|
seekTo(seconds) {
|
||||||
const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1);
|
const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1);
|
||||||
if (target < this.run.world.frame) {
|
if (target < this.run.world.frame) {
|
||||||
// The world and the camera are stateful; a backwards seek re-grows
|
// The world and the camera are stateful; a backwards seek re-grows
|
||||||
// from frame 0. Forward stepping is cheap (pure sim), so only a
|
// from frame 0. Forward stepping is cheap (pure sim), so only a
|
||||||
// rewind pays the replay cost.
|
// rewind pays the replay cost. The fresh pool boots cold (web);
|
||||||
this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed });
|
// 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.renderer.bitmap.data = this.img.data;
|
||||||
}
|
}
|
||||||
this.run.stepTo(target);
|
this.run.stepTo(target);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Camera } from './camera.js';
|
|||||||
import { Renderer } from './render/view.js';
|
import { Renderer } from './render/view.js';
|
||||||
|
|
||||||
export class Run {
|
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.song = song;
|
||||||
this.fps = fps;
|
this.fps = fps;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
@@ -17,6 +17,7 @@ export class Run {
|
|||||||
seed,
|
seed,
|
||||||
...(field ? { field } : {}),
|
...(field ? { field } : {}),
|
||||||
...(genesis !== undefined ? { genesis } : {}),
|
...(genesis !== undefined ? { genesis } : {}),
|
||||||
|
...(workers ? { workers } : {}),
|
||||||
});
|
});
|
||||||
this.camera = new Camera(song, this.world, { fps, width, height });
|
this.camera = new Camera(song, this.world, { fps, width, height });
|
||||||
this.renderer = new Renderer(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. */
|
/** Advance to a frame, updating the camera on every step. */
|
||||||
stepTo(frame) {
|
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) {
|
while (this.world.frame < frame) {
|
||||||
this.world.step();
|
this.world.step();
|
||||||
this.camera.update(this.world.frame);
|
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
|
// A region worker (node variant): owns a horizontal band of coarse rows and
|
||||||
// band-local phases of the world's substep against the shared arena.
|
// runs the band-local phases of the world's substep against the shared arena.
|
||||||
//
|
//
|
||||||
// The protocol is a pair of small shared arrays:
|
// The protocol is a pair of small shared arrays:
|
||||||
// ctrl [0] = phase sequence number (raised by the main thread)
|
// ctrl [0] = phase sequence number (raised by the main thread)
|
||||||
// [1] = done count (raised by each worker; the main waits on it)
|
// [1] = done count (raised by each worker; the main waits on it)
|
||||||
// mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount,
|
// mail [0] frame, [1] decide flag, [3] warmth (f32), [4] aliveCount,
|
||||||
// [5..] alive body ids, [20..] pop order, [30..] alleg order,
|
// [5..] alive body ids, [20..] pop order, [40..] alleg order,
|
||||||
// [40..] carry order
|
// [60..] carry order
|
||||||
//
|
//
|
||||||
// The worker constructs a bare World whose arrays are views into the SAME
|
// 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.
|
// 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';
|
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 (;;) {
|
for (;;) {
|
||||||
Atomics.wait(ctrl, 0, seen);
|
if (!regionPhase(region)) break;
|
||||||
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();
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
+289
-78
@@ -31,17 +31,20 @@
|
|||||||
import { Rng } from '../rng.js';
|
import { Rng } from '../rng.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The region pool runs on node's worker_threads; the browser gets its
|
* How the region pool's workers are built: worker_threads on node, ES module
|
||||||
* parallelism through the GPU decision layer (and, later, a sim worker).
|
* workers in the browser (the demo server already sends the COOP/COEP headers
|
||||||
* Resolved lazily so the module keeps loading in the browser.
|
* that expose SharedArrayBuffer under cross-origin isolation). Resolved
|
||||||
|
* lazily so the module keeps loading anywhere.
|
||||||
*/
|
*/
|
||||||
function workerCtor() {
|
function workerCtor() {
|
||||||
try {
|
try {
|
||||||
const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads');
|
const mod = globalThis.process?.getBuiltinModule?.('node:worker_threads');
|
||||||
return mod?.Worker ?? null;
|
if (mod?.Worker) return { ctor: mod.Worker, kind: 'node' };
|
||||||
} catch {
|
} catch { /* fall through */ }
|
||||||
return null;
|
if (typeof globalThis.Worker === 'function') {
|
||||||
|
return { ctor: globalThis.Worker, kind: 'web' };
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
import { wellPos, wellEnv, buildKernel } from './wells.js';
|
import { wellPos, wellEnv, buildKernel } from './wells.js';
|
||||||
import {
|
import {
|
||||||
@@ -167,6 +170,11 @@ export function arenaLayout(field, m, B, FC) {
|
|||||||
items.push(A('aimY', 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('activity', 32 * 32 * 4, Float32Array, 32 * 32));
|
||||||
items.push(A('hungerMass', 4, Float32Array, 1));
|
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) };
|
return { items, bytes: items.reduce((a, it) => a + it.bytes, 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +194,7 @@ export class World {
|
|||||||
this.palette = song.palette;
|
this.palette = song.palette;
|
||||||
this.wellTimeline = song.wells.wells;
|
this.wellTimeline = song.wells.wells;
|
||||||
this.rng = new Rng(seed >>> 0).fork('world');
|
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,
|
// 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
|
// 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.activity = carve('activity', Float32Array, 32 * 32);
|
||||||
this.hungerCell = carve('hungerMass', Float32Array, 1); // shared accumulator
|
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
|
// Rock (M2.8): the inert resource. A barrier the organisms cannot
|
||||||
// cross, that the song drops and shatters, and that builders quarry,
|
// cross, that the song drops and shatters, and that builders quarry,
|
||||||
@@ -418,6 +439,7 @@ export class World {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.frame = -1;
|
this.frame = -1;
|
||||||
|
this.poolWarm = true; // web pools go cold at boot; see step()/warmPool()
|
||||||
this.substepCount = 0;
|
this.substepCount = 0;
|
||||||
this.pulses = [];
|
this.pulses = [];
|
||||||
this.totalPop = 0;
|
this.totalPop = 0;
|
||||||
@@ -443,26 +465,80 @@ export class World {
|
|||||||
const WorkerC = workerCtor();
|
const WorkerC = workerCtor();
|
||||||
if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial
|
if (!bare && workers > 1 && !WorkerC) workers = 0; // no worker system: stay serial
|
||||||
if (!bare && workers > 1) {
|
if (!bare && workers > 1) {
|
||||||
|
this.workerKind = WorkerC.kind;
|
||||||
this.ctrl = new Int32Array(new SharedArrayBuffer(32));
|
this.ctrl = new Int32Array(new SharedArrayBuffer(32));
|
||||||
this.mail = new Int32Array(new SharedArrayBuffer(512));
|
this.mail = new Int32Array(new SharedArrayBuffer(512));
|
||||||
this.pool = [];
|
this.pool = [];
|
||||||
const bands = [];
|
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++) {
|
for (let k = 0; k < workers; k++) {
|
||||||
const c0 = Math.round((this.FC * k) / workers);
|
const c0 = Math.round((this.FC * k) / workers);
|
||||||
const c1 = Math.round((this.FC * (k + 1)) / workers);
|
const c1 = Math.round((this.FC * (k + 1)) / workers);
|
||||||
bands.push({ c0, c1 });
|
bands.push({ c0, c1 });
|
||||||
const worker = new WorkerC(new URL('./region-worker.js', import.meta.url), {
|
const init = {
|
||||||
workerData: {
|
|
||||||
song, field, seed: this.seed, fps, substeps, vmEvery: this.vmEvery,
|
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 },
|
layout: { items: spec.items.map(({ name, bytes, offset, len }) => ({ name, bytes, offset, len })), bytes: spec.bytes },
|
||||||
arena: this.arena, ctrl: this.ctrl.buffer,
|
arena: this.arena, ctrl: this.ctrl.buffer,
|
||||||
mail: this.mail.buffer, band: bands[k],
|
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
|
worker.unref(); // the pool never holds the process open
|
||||||
|
}
|
||||||
this.pool.push(worker);
|
this.pool.push(worker);
|
||||||
}
|
}
|
||||||
this.bands = bands;
|
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;
|
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() {
|
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++;
|
this.frame++;
|
||||||
const f = this.frame;
|
const f = this.frame;
|
||||||
const regime = this.regimeForFrame(f);
|
const regime = this.regimeForFrame(f);
|
||||||
@@ -1109,7 +1202,7 @@ export class World {
|
|||||||
// forces already sit in fxX/fxY, actions in the buffer.
|
// forces already sit in fxX/fxY, actions in the buffer.
|
||||||
const syn = this.syn[s];
|
const syn = this.syn[s];
|
||||||
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4;
|
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]);
|
gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]);
|
||||||
|
|
||||||
const flat = ensureFlat(strains[s]);
|
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.
|
// 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 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]);
|
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.
|
// crowd — the share of the block's population that is kin.
|
||||||
const syn = this.syn[s];
|
const syn = this.syn[s];
|
||||||
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
|
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]); }
|
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 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 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]));
|
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);
|
Atomics.store(ctrl, 1, 0);
|
||||||
this.phaseSeq = (this.phaseSeq ?? 0) + 1;
|
this.phaseSeq = (this.phaseSeq ?? 0) + 1;
|
||||||
const seq = this.phaseSeq;
|
const seq = this.phaseSeq;
|
||||||
Atomics.store(ctrl, 0, seq);
|
Atomics.store(ctrl, 0, seq);
|
||||||
Atomics.notify(ctrl, 0, this.pool.length);
|
Atomics.notify(ctrl, 0, this.pool.length);
|
||||||
|
const deadline = performance.now() + 30000;
|
||||||
let polls = 0;
|
let polls = 0;
|
||||||
while (Atomics.load(ctrl, 1) < this.pool.length) {
|
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)}`);
|
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)
|
// ---- after the fields: swap + the rock economy (main; aim targets cross bands)
|
||||||
afterFields(regime) {
|
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 aimTarget = (b, s) => {
|
||||||
const actFC = this.fc;
|
const actFC = this.fc;
|
||||||
const actBuf = this.stAct;
|
const actBuf = this.stAct;
|
||||||
@@ -1561,6 +1717,8 @@ export class World {
|
|||||||
const noise = this.noise;
|
const noise = this.noise;
|
||||||
const noise2 = this.noise2;
|
const noise2 = this.noise2;
|
||||||
const n = this.n;
|
const n = this.n;
|
||||||
|
const F = this.field;
|
||||||
|
const stats = this.bandStats;
|
||||||
const rock = this.rock;
|
const rock = this.rock;
|
||||||
const blkPop = this.blkPop;
|
const blkPop = this.blkPop;
|
||||||
const blkTotal = this.blkTotal;
|
const blkTotal = this.blkTotal;
|
||||||
@@ -1569,29 +1727,16 @@ export class World {
|
|||||||
const actBuf = this.stAct;
|
const actBuf = this.stAct;
|
||||||
const actFC = fc;
|
const actFC = fc;
|
||||||
const hostility = this.hostility;
|
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)) -------------------------
|
// ---- advection + reaction (band rows [c0, c1)) -------------------------
|
||||||
const f0 = c0 * (this.field / FC);
|
const f0 = c0 * (this.field / FC);
|
||||||
const f1 = c1 * (this.field / FC);
|
const f1 = c1 * (this.field / FC);
|
||||||
const lo = f0 * this.field;
|
const lo = f0 * this.field;
|
||||||
const hi = f1 * 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);
|
const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240);
|
||||||
for (let s = 0; s < m; s++) {
|
for (let s = 0; s < m; s++) {
|
||||||
@@ -1621,6 +1766,7 @@ export class World {
|
|||||||
for (let i = lo; i < hi; i++) {
|
for (let i = lo; i < hi; i++) {
|
||||||
const r0 = res[i];
|
const r0 = res[i];
|
||||||
const b = coarseOf[i];
|
const b = coarseOf[i];
|
||||||
|
const statsRow = (i / F) | 0;
|
||||||
const dom = domB[b];
|
const dom = domB[b];
|
||||||
if (r0 < 1e-6 && density[i] < 1e-6) continue;
|
if (r0 < 1e-6 && density[i] < 1e-6) continue;
|
||||||
|
|
||||||
@@ -1629,7 +1775,10 @@ export class World {
|
|||||||
|
|
||||||
// Saturated food factor (rational approximation, no transcendentals).
|
// Saturated food factor (rational approximation, no transcendentals).
|
||||||
const foodFactor = (r0 * 5) / (1 + r0 * 5);
|
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
|
// 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
|
// catalyst. A colony that outgrows its ground starves back and must
|
||||||
// wander — the world tracks its wells instead of congealing.
|
// wander — the world tracks its wells instead of congealing.
|
||||||
@@ -1665,7 +1814,7 @@ export class World {
|
|||||||
for (let s = 0; s < m; s++) {
|
for (let s = 0; s < m; s++) {
|
||||||
const p = vals[s];
|
const p = vals[s];
|
||||||
const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b];
|
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;
|
let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate;
|
||||||
g *= (1 - casualty * 8);
|
g *= (1 - casualty * 8);
|
||||||
// Starvation is the body's metabolic floor: bare ground kills,
|
// Starvation is the body's metabolic floor: bare ground kills,
|
||||||
@@ -1697,8 +1846,8 @@ export class World {
|
|||||||
const after = grown[s];
|
const after = grown[s];
|
||||||
next[s][i] = after;
|
next[s][i] = after;
|
||||||
dNew += after;
|
dNew += after;
|
||||||
if (after > before) this.birthsThisFrame += after - before;
|
if (after > before) stats[statsRow * 4 + 2] += after - before;
|
||||||
else if (after < before) this.deathsThisFrame += before - after;
|
else if (after < before) stats[statsRow * 4 + 3] += before - after;
|
||||||
}
|
}
|
||||||
density[i] = dNew;
|
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
|
* Conservative L1 advection + diffusion, in GATHER form: every
|
||||||
* destination cell computes its own inflow from src (self terms plus
|
* destination cell sums its own inflow from src — its own stay-home
|
||||||
* the diffusion flux of its open neighbours). No cross-cell writes —
|
* terms, the four edge arrivals it now receives, its diffusion share
|
||||||
* which is what makes the field phases band-parallel EXACTLY, whatever
|
* and the sheds of its neighbours — and writes ONLY to itself. A band
|
||||||
* the region count. A 2-row halo of src covers every read.
|
* 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) {
|
advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track, lo = 0, hi = this.n) {
|
||||||
const F = this.field;
|
const F = this.field;
|
||||||
@@ -1741,7 +1890,14 @@ export class World {
|
|||||||
const noise = this.noise;
|
const noise = this.noise;
|
||||||
const noise2 = this.noise2;
|
const noise2 = this.noise2;
|
||||||
const mask = this.rockMask;
|
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
|
// diffusion flux d_j for every cell j this pass may touch (the cell
|
||||||
// itself and its four neighbours); computed read-only from src.
|
// itself and its four neighbours); computed read-only from src.
|
||||||
const flux = (j) => {
|
const flux = (j) => {
|
||||||
@@ -1756,37 +1912,6 @@ export class World {
|
|||||||
if (open === 0) return 0;
|
if (open === 0) return 0;
|
||||||
return (nb - open * vj) * diff * sub;
|
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;
|
|
||||||
}
|
|
||||||
} else if (v > 1e-6) {
|
|
||||||
dst[i] += v;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 shed = (j) => {
|
||||||
const d = flux(j);
|
const d = flux(j);
|
||||||
if (d === 0) return 0;
|
if (d === 0) return 0;
|
||||||
@@ -1797,10 +1922,96 @@ export class World {
|
|||||||
if (!mask[nRight[j]]) open++;
|
if (!mask[nRight[j]]) open++;
|
||||||
return open > 0 ? d * 0.5 / open : 0;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
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[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[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[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]);
|
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', () => {
|
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 song = load('drone');
|
||||||
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0, workers });
|
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers });
|
||||||
for (let f = 0; f < 150; f++) { run.world.step(); run.camera.update(f); }
|
for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); }
|
||||||
const history = Array.from(run.world.history.alive.slice(0, 150));
|
const history = Array.from(run.world.history.alive.slice(0, frames));
|
||||||
const trail = Array.from(run.world.trail[0].slice(0, 4000));
|
const trail = Array.from(run.world.trail[0].slice(0, 4000));
|
||||||
run.world.dispose?.();
|
run.world.dispose?.();
|
||||||
return { history, trail };
|
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]);
|
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, two), '2 regions must match the serial world exactly');
|
||||||
assert.ok(eq(serial, four), '4 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', () => {
|
test('genesis is deterministic and song-scaled', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user