← Natura·Docs/Modules/Source

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 };
});