caustics.js
85 lines · in natura-src-1.0.0.zip as natura/caustics.js · a module · its entry in the module reference · its test: caustics-test.js
// caustics.js -- Natura OPTICS: CAUSTICS (the net of light on the seafloor). The framework simulated heat and
// light as fields but never LIGHT PROPAGATION -- how a wavy water surface REFRACTS sunlight and piles it into the
// shifting bright web on the bottom of a pool. Parallel sunlight strikes the surface; at each point the surface
// tilt bends the ray (Snell, air->water), and the bent rays travel down to the floor and LAND -- converging into
// bright fold-lines where the surface is concave, thinning where it is convex. The light is CONSERVED (every ray
// lands somewhere) but REDISTRIBUTED, which is exactly what a caustic is: not more light, but gathered light.
// Couples to ocean/hydro (drive it from the live water-surface height field). Deterministic, node-testable. UMD.
// The reusable core of the August light pieces (Where the Light Gathers, The Net of Light, The Light in Your Cup).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Caustics = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
// config: { N, surface(x,y,t)|surfaceFrom+surfaceKey, amp, depth, ior, waveSpeed, maxDtDays, name }
function makeCaustics(config) {
config = config || {};
const N = config.N || 64;
const amp = config.amp != null ? config.amp : 1.2; // wave height in CELL units (sets the per-cell slope)
const depth = config.depth != null ? config.depth : 14; // water depth in cells (bend x depth = floor offset)
const ior = config.ior != null ? config.ior : 1.333; // water refractive index
const eta = 1 / ior; // air -> water
const waveSpeed = config.waveSpeed != null ? config.waveSpeed : 0.6;
const surfaceFrom = config.surfaceFrom || null, surfaceKey = config.surfaceKey || 'waterTable';
// a default moving surface: a few crossed swells at GRID-CELL frequencies (so the per-cell slope is real)
const defSurf = (i, j, t) => amp * (Math.sin(0.55 * i + 0.7 * t) + Math.cos(0.48 * j - 0.5 * t) + 0.7 * Math.sin(0.33 * (i + j) + 0.3 * t));
const surfFn = config.surface || defSurf;
function refractDown(nx, ny, nz) {
// incoming ray straight down d=(0,0,-1); normal n=(nx,ny,nz) points UP toward the air
const cosi = nz; // -(d . n) = nz
const k = 1 - eta * eta * (1 - cosi * cosi);
if (k < 0) return null; // (won't happen air->water)
const c2 = eta * cosi - Math.sqrt(k);
return { x: eta * 0 + c2 * nx, y: eta * 0 + c2 * ny, z: eta * -1 + c2 * nz };
}
return {
name: config.name || 'caustics',
domain: 'physical',
scale: 'landscape',
params: config,
init() { return { N, t: 0, H: new Float32Array(N * N), floor: new Float32Array(N * N), peakToMean: 0 }; },
step(st, ctx, dt) {
const dd = clamp(dt / 86400, 0, config.maxDtDays != null ? config.maxDtDays : 4);
st.t += dd * waveSpeed;
const H = st.H, floor = st.floor, t = st.t;
// 1. the surface height field (from a coupled water surface, else the built-in swells)
if (surfaceFrom && ctx && ctx.fields && ctx.fields[surfaceFrom] && ctx.fields[surfaceFrom][surfaceKey]) {
const src = ctx.fields[surfaceFrom][surfaceKey]; for (let i = 0; i < N * N; i++) H[i] = src[i] * amp;
} else { for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) H[j * N + i] = surfFn(i, j, t); }
// 2. refract each ray through the surface tilt and LAND it on the floor (bilinear splat -> conserved)
floor.fill(0);
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const k = j * N + i;
const hx = (H[Math.min(N - 1, i + 1) + j * N] - H[Math.max(0, i - 1) + j * N]) * 0.5;
const hy = (H[i + Math.min(N - 1, j + 1) * N] - H[i + Math.max(0, j - 1) * N]) * 0.5;
let nx = -hx, ny = -hy, nz = 1; const nl = Math.hypot(nx, ny, nz); nx /= nl; ny /= nl; nz /= nl;
const d = refractDown(nx, ny, nz); if (!d) { floor[k] += 1; continue; }
const tt = depth / Math.max(1e-3, -d.z); // travel to the floor
let fx = ((i + d.x * tt) % N + N) % N, fy = ((j + d.y * tt) % N + N) % N; // WRAP the floor (no edge pile-up)
const x0 = fx | 0, y0 = fy | 0, x1 = (x0 + 1) % N, y1 = (y0 + 1) % N, ax = fx - x0, ay = fy - y0; // bilinear splat (energy-conserving)
floor[y0 * N + x0] += (1 - ax) * (1 - ay); floor[y0 * N + x1] += ax * (1 - ay);
floor[y1 * N + x0] += (1 - ax) * ay; floor[y1 * N + x1] += ax * ay;
}
let mx = 0, sum = 0; for (let q = 0; q < N * N; q++) { const v = floor[q]; if (v > mx) mx = v; sum += v; }
st.peakToMean = sum > 0 ? (mx / (sum / (N * N))) : 0;
return st;
},
// OUT: caustic (floor brightness -- the net of light), surface (the water height), peakToMean (how gathered
// the light is: ~1 flat, high = sharp caustics). totalLight is conserved. Other systems read `caustic` to
// dapple a scene, or drive `surfaceFrom` from ocean/hydro's live surface.
fields(st) {
let sum = 0; for (let q = 0; q < st.N * st.N; q++) sum += st.floor[q];
return { N: st.N, caustic: st.floor, surface: st.H, peakToMean: +st.peakToMean.toFixed(3), totalLight: +sum.toFixed(1) };
},
events(st) { return st.peakToMean > 4 ? [{ type: 'caustic', peakToMean: +st.peakToMean.toFixed(2) }] : []; }
};
}
return { makeCaustics };
});