canopy.js
60 lines · in natura-src-1.0.0.zip as natura/canopy.js · a module · its entry in the module reference · its test: canopy-test.js
// canopy.js -- a small Natura STRUCTURE system: a rainforest canopy as a LIGHT FIELD over height AND region.
//
// One concern, render-free: it exposes the forest's light structure as fields() the way weather exposes moisture --
// sunAt(x,y) : the LOCAL sun elevation [-1,1] at a place -- the world's day phase shifted by the place's REGION
// (longitude, east dawns earlier) and dimmed by its canopy SHADE (depth). Organisms read this to wear
// the day/night verb per their layer + region: the floor keeps the shortest, latest day of all.
// lightAt(y) : light intensity [0,1] reaching height y (ambient daylight * Beer-Lambert transmission down the leaves).
// shadeAt(y) : the canopy light-debt at height y (gentle-capped, fed to the verb's {shade}).
// gaps, layers: the sky-gaps (where shafts fall) and the layer bands -- for the renderer.
// The sun comes from the world CLOCK (day phase), so region-phase is a real time-shift; a celestial coupling can refine
// amplitude later. Static structure (gaps/transmission are seeded once); nothing here touches a canvas. UMD.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Canopy = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const TAU = Math.PI * 2, clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const LAYERS = { emergent: [0.00, 0.14], canopy: [0.14, 0.40], understory: [0.40, 0.72], floor: [0.72, 1.00] };
function makeCanopy(config) {
config = config || {};
const K = config.extinction != null ? config.extinction : 2.02; // Beer-Lambert extinction down the leaf mass
const regionPhase = config.regionPhase != null ? config.regionPhase : 0.08;
const shadeScale = config.shadeScale != null ? config.shadeScale : 0.62; // gentler than raw transmission so noon reads as day
const shadeCap = config.shadeCap != null ? config.shadeCap : 0.6;
const nGapCfg = config.nGap;
return {
name: config.name || 'canopy', domain: 'physical', scale: 'local', params: config,
init(rng, params, clock) {
const nGap = nGapCfg != null ? nGapCfg : (4 + Math.floor(rng() * 3));
const gaps = []; for (let i = 0; i < nGap; i++) gaps.push(0.05 + 0.9 * rng());
return { clock: clock, gaps: gaps };
},
step(state, ctx) { const wx = ctx && ctx.fields ? ctx.fields.weather : null; if (wx && wx.cloud != null) state.cloud = wx.cloud; return state; }, // read the sky's cloud cover; the structure is otherwise static and the sun is read live in fields()
fields(state) {
const canopyMass = y => 1.0 * smooth(0.10, 0.46, y) + 0.28 * smooth(0.46, 1.0, y); // leaf mass ABOVE height y
const transmission = y => Math.exp(-K * canopyMass(clamp(y, 0, 1))); // ~1 at top, ~0.08 at floor
const t06 = transmission(0.06);
const shadeAt = y => clamp((1 - transmission(clamp(y, 0, 1)) / t06) * shadeScale, 0, shadeCap);
const dayPhase = (state.clock && state.clock.dayPhase) ? state.clock.dayPhase() : 0.5;
const sunDP = p => -Math.cos(p * TAU); // 0 midnight->-1, 0.5 noon->+1
const cloud = state.cloud || 0; // cloud cover blocks the sun...
const daylight = Math.max(0, sunDP(dayPhase)) * (1 - cloud * 0.7); // ...so less light reaches the canopy (the shafts weaken, the life dims)
return {
gaps: state.gaps.slice(), layers: LAYERS,
transmission: transmission, shadeAt: shadeAt,
lightAt: y => daylight * transmission(clamp(y, 0, 1)),
sunAt: (x, y) => sunDP(dayPhase + regionPhase * ((x || 0) - 0.5)) - shadeAt(y), // sun POSITION (day/night timing) is unchanged by cloud
daylight: daylight, dayPhase: dayPhase, cloud: cloud
};
}
};
}
return { makeCanopy, LAYERS };
});