frost.js
95 lines · in natura-src-1.0.0.zip as natura/frost.js · a module · its entry in the module reference · its test: frost-test.js
// frost.js -- Natura MICRO / FROST (SYSTEMS.md #12, ALGORITHMS.md): the small pole of the scale ladder,
// "the branching of rime on a leaf in a storm." Ice grows on a cold surface by vapour deposition, and it
// grows in DENDRITES -- feathery branches -- because the process is DIFFUSION-LIMITED: the tips stick out
// into the undepleted vapour and grow fastest, while the hollows are starved, so the pattern forks and
// forks again. This is the dielectric-breakdown / DLA model (the eta-exponent controls how feathery): a
// Laplacian VAPOUR field (1 at the far edges, 0 on the ice) relaxes each step, and the frost adds cells on
// its frontier with probability proportional to the local vapour^eta. Standalone it grows a snowflake; in
// a world it ACTIVATES only where the weather is below freezing and humid -- a micro system woken by the
// macro one. The same recipe draws lightning and river deltas; here it is rime.
//
// A Natura system spec (init/step/fields/events), deterministic (seeded rng). UMD (node test / window.Frost).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Frost = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// make a Frost system. config: { N, eta(branchiness), growthPerStep, relaxIters, maxFill, weather,
// freezeTemp, seedAt('center'|'bottom'), name }.
function makeFrost(config) {
config = config || {};
const N = config.N || 96;
const eta = config.eta != null ? config.eta : 1.0; // 0 = blobby, 1 = frost, >2 = spidery
const growthPerStep = config.growthPerStep != null ? config.growthPerStep : 2;
const relaxIters = config.relaxIters != null ? config.relaxIters : 4; // Jacobi sweeps of the vapour field / step
const maxFill = config.maxFill != null ? config.maxFill : 0.12; // stop at this fraction of the surface iced
const weatherName = config.weather || 'weather';
const freezeTemp = config.freezeTemp != null ? config.freezeTemp : 0; // grows only where the surface is below this
const seedAt = config.seedAt || 'center';
const nuclei = config.nuclei != null ? config.nuclei : 1; // >1: many nucleation points, so rime spreads OVER a whole surface
return {
name: config.name || 'frost',
domain: 'physical',
scale: 'micro',
params: config,
init(rng) {
// NO seeds at init: a warm world must show ZERO rime. The nucleation points are placed LAZILY, the
// first step the surface actually goes below freezing (below), so frost simply does not exist until
// it is cold enough to form -- exactly as ice does.
const ice = new Uint8Array(N * N), V = new Float32Array(N * N).fill(1);
return { N, rng, ice, V, count: 0, done: false, bloomed: false, seeded: false };
},
step(st, ctx, dt) {
if (st.done) return st;
// ACTIVATION: in a world, rime forms only on a below-freezing surface (compose with weather)
if (ctx && ctx.fields) {
const w = ctx.fields[weatherName];
if (w && w.temp) { const cT = w.temp[((N >> 1) * N) + (N >> 1)]; if (cT > freezeTemp) return st; }
}
const { ice, V, rng } = st;
// it is cold: place the nucleation seeds NOW, the first cold step (not at init), then grow from them
if (!st.seeded) {
if (nuclei > 1) { for (let n = 0; n < nuclei; n++) { const i = 1 + ((rng() * (N - 2)) | 0), j = 1 + ((rng() * (N - 2)) | 0), k = j * N + i; if (!ice[k]) { ice[k] = 1; V[k] = 0; st.count++; } } }
else if (seedAt === 'bottom') { for (let i = 0; i < N; i++) { ice[(N - 1) * N + i] = 1; V[(N - 1) * N + i] = 0; } st.count = N; }
else { const c = ((N >> 1) * N) + (N >> 1); ice[c] = 1; V[c] = 0; st.count = 1; }
st.seeded = true;
}
// relax the VAPOUR field toward Laplace: 1 at the edges (source), 0 on the ice, smooth between
for (let it = 0; it < relaxIters; it++) {
for (let j = 1; j < N - 1; j++) for (let i = 1; i < N - 1; i++) {
const k = j * N + i; if (ice[k]) { V[k] = 0; continue; }
V[k] = 0.25 * (V[k - 1] + V[k + 1] + V[k - N] + V[k + N]);
}
}
// GROW: add frontier cells with probability ~ vapour^eta (the tips, in high vapour, win)
for (let g = 0; g < growthPerStep; g++) {
let total = 0; const cand = [], wt = [];
for (let j = 1; j < N - 1; j++) for (let i = 1; i < N - 1; i++) {
const k = j * N + i; if (ice[k]) continue;
if (!(ice[k - 1] || ice[k + 1] || ice[k - N] || ice[k + N])) continue; // must touch the ice
const p = Math.pow(Math.max(0, V[k]), eta) + 1e-6; cand.push(k); wt.push(p); total += p;
}
if (!cand.length) { st.done = true; break; }
let r = rng() * total, pick = cand[cand.length - 1];
for (let m = 0; m < cand.length; m++) { r -= wt[m]; if (r <= 0) { pick = cand[m]; break; } }
ice[pick] = 1; V[pick] = 0; st.count++;
if (st.count > maxFill * N * N) { st.done = true; break; }
}
return st;
},
// OUT: ice (the frost pattern, 0/1), count. Render it as the rime on the glass.
fields(st) { return { N: st.N, ice: st.ice, count: st.count }; },
events(st) {
if (st.done && !st.bloomed) { st.bloomed = true; return [{ kind: 'frostBloom', cells: st.count }]; }
return [];
}
};
}
return { makeFrost };
});