Files
2026-09-25 17:02:24 +03:00

687 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Precipitation simulation: moisture advection driven by wind, ocean warmth,
// orographic effects, ITCZ uplift, frontal convergence, and polar fronts.
// Computes per-region precipitation for summer and winter seasons.
import { smoothstep } from './wind.js';
import { computeGradients } from './wind.js';
import { elevToHeightKm } from './color-map.js';
import { computeHeuristicPrecipitation, computeHeuristicWindField } from './heuristic-precip.js';
import { smoothField, makeItczLookup, percentile } from './climate-util.js';
const DEG = Math.PI / 180;
// ── Wind convergence ─────────────────────────────────────────────────────────
// Compute per-region convergence of the wind field. Negative divergence means
// winds are piling into a region (frontal zone / ITCZ-like uplift). We measure
// this as net inward flux: for each neighbor pair, how much does the neighbor's
// wind point toward us vs. our wind point toward the neighbor?
function computeWindConvergence(mesh, r_xyz,
r_wind3dX, r_wind3dY, r_wind3dZ) {
const { adjOffset, adjList, numRegions } = mesh;
const convergence = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
// Wind at r in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
let conv = 0;
let count = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
// Direction from r to nb
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
// inFlux - outFlux = -(nw·d) - (w·d) = -((nw + w)·d)
conv -= (r_wind3dX[nb] + wdx) * dx
+ (r_wind3dY[nb] + wdy) * dy
+ (r_wind3dZ[nb] + wdz) * dz;
count++;
}
// Normalize by neighbor count; positive = converging, negative = diverging
convergence[r] = count > 0 ? conv / count : 0;
}
return convergence;
}
// ── Upwind moisture advection ────────────────────────────────────────────────
// For each land cell, accumulate moisture from upwind neighbors.
// Moisture originates at coast cells proportional to ocean warmth and
// depletes with distance and elevation gain.
function advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
r_windE, r_windN,
r_wind3dX, r_wind3dY, r_wind3dZ,
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm) {
const { adjOffset, adjList, numRegions } = mesh;
const moisture = new Float32Array(numRegions);
// Initialize moisture: coastal land cells from adjacent ocean warmth,
// ocean cells from their own warmth
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) {
// Ocean cells: base moisture proportional to warmth
const warmth = r_oceanWarmth ? r_oceanWarmth[r] : 0;
moisture[r] = 0.4 + 0.35 * Math.max(0, warmth);
continue;
}
if (r_coastDistLand[r] !== 0) continue; // not a coast cell
// Coastal land cell — check for onshore wind
let warmthSum = 0;
let oceanCount = 0;
let oceanDirX = 0, oceanDirY = 0, oceanDirZ = 0;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
if (!r_isLand[nb]) {
oceanCount++;
if (r_oceanWarmth) warmthSum += r_oceanWarmth[nb];
oceanDirX += r_xyz[3 * nb] - r_xyz[3 * r];
oceanDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
oceanDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
}
}
if (oceanCount === 0) continue;
const avgWarmth = warmthSum / oceanCount;
// Wind direction in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
// Onshore = wind blows FROM ocean toward land = wind dot (ocean→region) < 0
const windDotOcean = wdx * oceanDirX + wdy * oceanDirY + wdz * oceanDirZ;
const onshore = windDotOcean < 0 ? 1.0 : 0.25;
// Base moisture: warm currents provide more, cold currents less
const warmthFactor = 0.5 + 0.5 * Math.max(-0.8, Math.min(1, avgWarmth));
moisture[r] = onshore * warmthFactor;
}
// Base friction: ~78% moisture survives the full maxHops
// distance over flat terrain. Per-hop retention = 0.78^(1/maxHops).
const depletionBase = 1 - Math.pow(0.78, 1 / maxHops);
// Iterative downwind propagation (ping-pong double-buffering)
let src = moisture;
let dst = new Float32Array(numRegions);
for (let iter = 0; iter < maxHops; iter++) {
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) { dst[r] = src[r]; continue; }
const we = r_windE[r], wn = r_windN[r];
if (we * we + wn * wn < 1e-6) { dst[r] = src[r]; continue; }
// Wind direction in 3D (pre-computed)
const wdx = r_wind3dX[r];
const wdy = r_wind3dY[r];
const wdz = r_wind3dZ[r];
// Find upwind neighbors (those where wind at neighbor points toward us)
// Track weighted-average upwind elevation for gradient-based depletion
let upwindMoisture = 0;
let upwindWeight = 0;
let upwindHeightSum = 0;
const heightHere = r_heightKm[r];
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
// Direction from nb to r
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
// Alignment: how much does wind at nb point toward r?
const dot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
if (dot > 0) {
upwindMoisture += src[nb] * dot;
upwindHeightSum += r_heightKm[nb] * dot;
upwindWeight += dot;
}
}
if (upwindWeight > 0) {
const incoming = upwindMoisture / upwindWeight;
const upwindHeight = upwindHeightSum / upwindWeight;
// Depletion depends on physical height GAIN (km) from upwind.
const heightGain = Math.max(0, heightHere - upwindHeight);
// Height gain per hop (km) shrinks at higher resolution.
// Multiply by maxHops to get total rise over the advection
// distance. A ~1 km total rise dumps significant moisture,
// ~2 km near-total.
const normalizedGain = heightGain * maxHops;
const elevDepletion = Math.min(0.8, normalizedGain * 0.55);
const depletion = depletionBase + elevDepletion;
const carried = incoming * Math.max(0, 1 - depletion);
dst[r] = Math.max(src[r], carried);
} else {
dst[r] = src[r];
}
}
// Swap buffers
const swap = src;
src = dst;
dst = swap;
}
return src;
}
// ── Main entry point ─────────────────────────────────────────────────────────
/**
* Compute seasonal precipitation fields.
*
* @param {SphereMesh} mesh
* @param {Float32Array} r_xyz - per-region 3D positions
* @param {Float32Array} r_elevation - per-region elevation
* @param {object} windResult - output from computeWind()
* @param {object} oceanResult - output from computeOceanCurrents()
* @returns {{ r_precip_summer, r_precip_winter }} normalized 0–1 arrays
*/
export function computePrecipitation(mesh, r_xyz, r_elevation, windResult, oceanResult, precipitationOffset = 0, landCoverage = 0.3) {
console.log('[precipitation.js] computePrecipitation called, numRegions:', mesh.numRegions);
const numRegions = mesh.numRegions;
const timing = [];
const { r_lat, r_lon, r_isLand, r_continentality,
r_eastX, r_eastY, r_eastZ,
r_northX, r_northY, r_northZ } = windResult;
// Scale-dependent hop count: ~2000 km reach.
// Average edge length ≈ π / sqrt(numRegions) radians ≈ (π * 6371) / sqrt(N) km
// hops ≈ 2000 / edgeLengthKm
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
const avgEdgeRad = Math.PI / Math.sqrt(numRegions);
const maxHops = Math.max(8, Math.min(20, Math.round(2000 / avgEdgeKm)));
// Coast distance through land — reuse BFS already computed by wind.js
const r_coastDistLand = windResult.r_coastDistLand;
// Elevation gradient for orographic detection (shared).
// Use a smoothed copy of elevation so local noise/crags don't fragment
// the large-scale windward/leeward signal at high resolutions.
// Target ~200 km smoothing radius — enough to average out terrain noise
// while preserving the broad mountain-range slope.
let t0 = performance.now();
const elevSmoothPasses = Math.max(2, Math.round(200 / avgEdgeKm));
const r_elevSmoothed = new Float32Array(r_elevation);
smoothField(mesh, r_elevSmoothed, elevSmoothPasses);
// Blend smoothed with actual: keeps broad slope signal but retains some local detail
for (let r = 0; r < numRegions; r++) {
r_elevSmoothed[r] = r_elevSmoothed[r] * 0.6 + r_elevation[r] * 0.4;
}
const r_elevGradE = new Float32Array(numRegions);
const r_elevGradN = new Float32Array(numRegions);
computeGradients(mesh, r_xyz, r_elevSmoothed,
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
r_elevGradE, r_elevGradN);
timing.push({ stage: 'Precip: elevation gradients (smoothed)', ms: performance.now() - t0 });
// Pre-compute height in km for advection and mechanisms (elevation is constant across seasons)
const r_heightKm = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_heightKm[r] = elevToHeightKm(Math.max(0, r_elevation[r]));
}
const result = {};
const seasons = [
{ name: 'summer', shift: 5 },
{ name: 'winter', shift: -5 }
];
for (const { name, shift } of seasons) {
t0 = performance.now();
const r_windE_raw = windResult[`r_wind_east_${name}`];
const r_windN_raw = windResult[`r_wind_north_${name}`];
const r_windSpeed = windResult[`r_wind_speed_${name}`];
const r_pressure = windResult[`r_pressure_${name}`];
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
const itczLookup = makeItczLookup(windResult.itczLons,
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
// ── Blend complex wind with heuristic zonal wind (50-50) ──
// Smooths out noisy pressure-derived wind patterns, strengthens
// zonal consistency for advection and orographic effects.
const { hWindE, hWindN } = computeHeuristicWindField(
numRegions, r_lat, r_lon, itczLookup);
const r_windE = new Float32Array(numRegions);
const r_windN = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
r_windE[r] = 0.5 * r_windE_raw[r] + 0.5 * hWindE[r];
r_windN[r] = 0.5 * r_windN_raw[r] + 0.5 * hWindN[r];
}
// Pre-compute 3D wind vectors for convergence and advection
const r_wind3dX = new Float32Array(numRegions);
const r_wind3dY = new Float32Array(numRegions);
const r_wind3dZ = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const we = r_windE[r], wn = r_windN[r];
r_wind3dX[r] = we * r_eastX[r] + wn * r_northX[r];
r_wind3dY[r] = we * r_eastY[r] + wn * r_northY[r];
r_wind3dZ[r] = we * r_eastZ[r] + wn * r_northZ[r];
}
// ── Step 1a: Wind convergence field ──
// Compute raw convergence then smooth heavily — real fronts are
// messy, mobile bands, not sharp lines. The smoothing spreads the
// signal over a wide area representing the zone where frontal
// weather systems wander over a season.
const r_convergence = computeWindConvergence(mesh, r_xyz,
r_wind3dX, r_wind3dY, r_wind3dZ);
// Smooth ~400 km worth of hops so frontal zones are broad bands
const convSmoothPasses = Math.max(3, Math.round(400 / avgEdgeKm));
smoothField(mesh, r_convergence, convSmoothPasses);
// ── Step 1b: Moisture advection from coasts ──
const moisture = advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
r_windE, r_windN,
r_wind3dX, r_wind3dY, r_wind3dZ,
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm);
const tAdvect = performance.now() - t0;
// ── Step 2: Apply precipitation mechanisms ──
t0 = performance.now();
const precip = new Float32Array(numRegions);
const rainShadow = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
const lat = r_lat[r];
const lon = r_lon[r];
const absLatDeg = Math.abs(lat) / DEG;
const elev = r_elevation[r];
const isLand = r_isLand[r];
let p = moisture[r];
// (a) ITCZ uplift: boost moisture within ±15° of ITCZ
const itczLat = itczLookup(lon);
const distFromItcz = Math.abs(lat - itczLat) / DEG;
const cont = (isLand && r_continentality) ? r_continentality[r] : 0;
if (distFromItcz < 15) {
const itczStrength = smoothstep(15, 0, distFromItcz);
// Core ITCZ (within 5°): strong uplift and convective rain
const coreBoost = distFromItcz < 5 ? 1.5 : 1.0;
p = p * (1 + itczStrength * coreBoost) + itczStrength * 0.3;
}
// (b) Frontal precipitation: actual wind convergence
// Where winds collide (convergence > 0) air is forced upward,
// creating turbulence and wringing out whatever moisture is present.
// This naturally finds frontal zones, ITCZ-like convergence,
// and any other place where air masses meet.
const conv = r_convergence[r];
if (conv > 0) {
// Scale convergence: gentle convergence gives mild boost,
// strong convergence (opposing air masses) gives large boost.
// Only amplifies existing moisture — dry converging air
// doesn't produce rain.
// Raw convergence ∝ avgEdgeRad (neighbor displacements shrink
// at higher resolution), so normalize to make scale-invariant.
const convStrength = Math.min(1, (conv / avgEdgeRad) * 0.055);
p = p * (1 + convStrength * 1.2) + convStrength * moisture[r] * 0.4;
}
// (c) Orographic effects (land only)
// The advection step already handles gradient-based moisture loss
// per hop. This step adds the *local* precipitation boost on windward
// slopes (forced uplift squeezes out extra rain at that cell) and a
// moderate leeward shadow for any remaining moisture.
if (isLand && elev > 0) {
const we = r_windE[r], wn = r_windN[r];
// Windward uplift: wind dot elevation gradient
// Positive = wind blows upslope (windward), negative = downslope (leeward)
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
if (windDotGrad > 0) {
// Windward: orographic enhancement — the steeper the slope
// the wind is pushing up, the more rain wrung out.
// gradient strength matters more than absolute height.
const uplift = Math.min(1, windDotGrad * 15);
p += uplift * 1.0;
} else {
// Leeward: rain shadow. The advection step already depleted
// moisture crossing the ridge; this is the *extra* suppression
// from descending/warming air (foehn drying) on the lee side.
const shadow = Math.min(1, -windDotGrad * 18);
p *= Math.max(0.02, 1 - shadow * 0.95);
}
}
// (d) Pressure-driven suppression/enhancement (hybrid)
// Start with a gentle latitude-band expectation for subtropical
// highs, then let the actual pressure field shift it — so the
// effect tracks real geography without being too aggressive.
const pDev = r_pressure[r]; // deviation from 1013 hPa
// Seasonal subtropical suppression: the subtropical high shifts
// poleward in local summer (creating Mediterranean dry summers)
// and retreats equatorward in local winter (allowing westerly rain).
const inLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
const subtropCenter = inLocalSummer ? 30 : 24;
const subtropWidth = inLocalSummer ? 16 : 12;
let subtropPeak = inLocalSummer ? 0.50 : 0.30;
// East-coast monsoon relief: reduce summer drying where
// poleward winds bring tropical moisture onshore. On Earth
// this produces humid subtropical (Cfa) on east coasts
// while west coasts keep Mediterranean (Cs) dry summers.
if (isLand && inLocalSummer) {
const polewardWind = lat >= 0 ? r_windN[r] : -r_windN[r];
if (polewardWind > 0) {
const coastDist = r_coastDistLand[r] >= 0 ? r_coastDistLand[r] : maxHops;
const coastProximity = 1 - smoothstep(0, maxHops * 0.4, coastDist);
const monsoonRelief = smoothstep(0, 0.15, polewardWind) * coastProximity;
subtropPeak *= (1 - monsoonRelief * 0.7);
}
}
const subtropDist = Math.abs(absLatDeg - subtropCenter);
const latBandSuppression = subtropDist < subtropWidth
? smoothstep(subtropWidth, 0, subtropDist) * subtropPeak : 0;
// Pressure modifier: high pressure adds suppression, low reduces it
// Kept gentle — pressure nudges the baseline, doesn't overwhelm it.
let pressureMod = 0;
if (pDev > 0) {
pressureMod = smoothstep(0, 12, pDev) * 0.25; // extra suppression
} else {
pressureMod = -smoothstep(0, 15, -pDev) * 0.2; // relief / enhancement
}
const totalSuppression = Math.max(0, latBandSuppression + pressureMod);
if (totalSuppression > 0) {
p *= Math.max(0.05, 1 - totalSuppression);
} else {
// Net enhancement from low pressure outside subtropical belt
p *= (1 - totalSuppression); // totalSuppression is negative here
}
// (e) Polar front: diffuse precipitation at high latitudes
// The polar front is broad and pushes moisture deep inland —
// the blog cites ~2000 km downwind, ~1500 km crosswind from
// any coast, including coasts with offshore winds.
// It always brings *some* precipitation from its own cyclonic
// activity, even deep inland, plus a stronger coastal component.
if (absLatDeg > 40) {
const polarStrength = smoothstep(40, 70, absLatDeg);
const coastDist = r_coastDistLand[r] < 0 ? maxHops : r_coastDistLand[r];
const inlandFade = 1 - smoothstep(0, maxHops, coastDist);
// Base: always present regardless of coast distance
const polarBase = polarStrength * 0.10;
// Coastal enhancement: fades inland
const polarCoastal = polarStrength * 0.20 * inlandFade;
// Mostly enhances existing moisture, but adds some regardless
p += polarBase + polarCoastal;
p *= (1 + polarStrength * 0.15); // gentle multiplicative boost
}
// (f) Continental interior dryness
// Now that continentality is BFS-based (0 at coast, 0.5 at ~1000km,
// 1.0 at ~2000km), we can use it directly. Squared curve keeps
// near-coast areas gentle while ramping for deep interiors.
if (isLand && cont > 0) {
const dryness = cont * cont * 0.55;
p *= Math.max(0.03, 1 - dryness);
}
// (g) Lee cyclogenesis: localized wet zone on leeward side of high mountains
// when ocean is nearby downwind (~200 km)
const heightKm = r_heightKm[r];
if (isLand && heightKm > 1.5) {
const we = r_windE[r], wn = r_windN[r];
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
// ~200 km in hops (scale-invariant)
const leeCoastHops = Math.max(2, Math.round(200 / avgEdgeKm));
if (windDotGrad < -0.01 && r_coastDistLand[r] >= 0 && r_coastDistLand[r] < leeCoastHops) {
p += 0.15 * Math.min(1, heightKm / 5);
}
}
// Ocean cells: precipitation over ocean (for visual completeness)
if (!isLand) {
// ITCZ and frontal zones already contribute above.
// Add baseline ocean precipitation, suppressed under high pressure
const highPressureFade = pDev > 0 ? smoothstep(0, 12, pDev) : 0;
const oceanBase = 0.15 * (1 - highPressureFade);
p = Math.max(p, oceanBase);
}
// (h) Hard distance-from-coast moisture cutoff
// Beyond ~2000 km from any coast, moisture drops off steeply.
// By 3000 km almost nothing remains.
if (isLand && r_coastDistLand[r] > 0) {
const distKm = r_coastDistLand[r] * avgEdgeKm;
if (distKm > 2000) {
const fade = 1 - smoothstep(2000, 3000, distKm);
p *= Math.max(0.03, fade);
}
}
const precipMult = 1 + precipitationOffset * 0.5;
let finalPrecip = p * precipMult;
if (landCoverage > 0.4) {
const t = (landCoverage - 0.4) / 0.6;
finalPrecip *= 1 - t * t * 0.98;
}
precip[r] = Math.max(0, finalPrecip);
}
const tMechanisms = performance.now() - t0;
// ── Step 2b: Rain shadow diagnostic — local source + bidirectional propagation ──
// Seed leeward slopes with negative shadow strength and windward slopes
// with positive orographic rain. Then propagate each in the correct
// direction: shadow travels DOWNWIND (foehn drying), windward rain
// extends UPWIND (rising air condenses approaching the mountains).
{
const { adjOffset, adjList } = mesh;
// Seed: local orographic effect at each cell
// Only significant terrain (≥0.8 km) seeds shadows — small hills
// shouldn't cast continent-scale rain shadows.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r] || r_elevation[r] <= 0) continue;
const we = r_windE[r], wn = r_windN[r];
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
const heightKm = r_heightKm[r];
if (heightKm < 0.8) continue; // skip low terrain
const heightScale = Math.min(1, (heightKm - 0.5) / 2.5);
if (windDotGrad > 0) {
rainShadow[r] = Math.min(1, windDotGrad * 20) * heightScale;
} else if (windDotGrad < 0) {
rainShadow[r] = -Math.min(1, -windDotGrad * 18) * heightScale;
}
}
// Pre-compute wind-aligned neighbor lists once — avoids
// redundant dot-product calculations inside every propagation
// iteration. Two sets: "upwind" (nb's wind points toward r,
// for shadow propagation) and "downwind" (r's wind points
// toward nb, for windward propagation).
const maxNbTotal = adjList.length;
const upNb = new Int32Array(maxNbTotal);
const upWt = new Float32Array(maxNbTotal);
const upOff = new Int32Array(numRegions + 1);
const dnNb = new Int32Array(maxNbTotal);
const dnWt = new Float32Array(maxNbTotal);
const dnOff = new Int32Array(numRegions + 1);
let upCount = 0, dnCount = 0;
for (let r = 0; r < numRegions; r++) {
upOff[r] = upCount;
dnOff[r] = dnCount;
if (!r_isLand[r]) continue;
const end = adjOffset[r + 1];
for (let ni = adjOffset[r]; ni < end; ni++) {
const nb = adjList[ni];
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
// Upwind: wind at nb points toward r
const upDot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
if (upDot > 0) { upNb[upCount] = nb; upWt[upCount] = upDot; upCount++; }
// Downwind: wind at r points toward nb (direction is -dx,-dy,-dz)
const dnDot = -(r_wind3dX[r] * dx + r_wind3dY[r] * dy + r_wind3dZ[r] * dz);
if (dnDot > 0) { dnNb[dnCount] = nb; dnWt[dnCount] = dnDot; dnCount++; }
}
}
upOff[numRegions] = upCount;
dnOff[numRegions] = dnCount;
// --- Pass 1: Propagate shadow DOWNWIND (~2500 km, 15% survives) ---
const shadowHops = Math.max(8, Math.round(2500 / avgEdgeKm));
const shadowDecay = 1 - Math.pow(0.15, 1 / shadowHops);
const shadowField = new Float32Array(rainShadow);
// Reusable ping-pong buffers for both shadow and windward passes
let src = new Float32Array(shadowField);
let dst = new Float32Array(numRegions);
for (let iter = 0; iter < shadowHops; iter++) {
for (let r = 0; r < numRegions; r++) {
let upVal = 0, upW = 0;
const uEnd = upOff[r + 1];
for (let ui = upOff[r]; ui < uEnd; ui++) {
const val = src[upNb[ui]];
if (val < 0) { upVal += val * upWt[ui]; upW += upWt[ui]; }
}
if (upW > 0) {
const carried = (upVal / upW) * (1 - shadowDecay);
dst[r] = Math.min(src[r], carried);
} else {
dst[r] = src[r];
}
}
const swap = src; src = dst; dst = swap;
}
for (let r = 0; r < numRegions; r++) {
if (src[r] < shadowField[r]) shadowField[r] = src[r];
}
// --- Pass 2: Propagate windward rain UPWIND (~1500 km, 25% survives) ---
const windwardHops = Math.max(6, Math.round(1500 / avgEdgeKm));
const windwardDecay = 1 - Math.pow(0.25, 1 / windwardHops);
const windwardField = new Float32Array(rainShadow);
// Reuse ping-pong buffers from shadow pass
src.set(windwardField);
dst.fill(0);
for (let iter = 0; iter < windwardHops; iter++) {
for (let r = 0; r < numRegions; r++) {
let dnVal = 0, dnW = 0;
const dEnd = dnOff[r + 1];
for (let di = dnOff[r]; di < dEnd; di++) {
const val = src[dnNb[di]];
if (val > 0) { dnVal += val * dnWt[di]; dnW += dnWt[di]; }
}
if (dnW > 0) {
const carried = (dnVal / dnW) * (1 - windwardDecay);
dst[r] = Math.max(src[r], carried);
} else {
dst[r] = src[r];
}
}
const swap = src; src = dst; dst = swap;
}
for (let r = 0; r < numRegions; r++) {
if (src[r] > windwardField[r]) windwardField[r] = src[r];
}
// Merge: shadow dominates if present, otherwise take windward
for (let r = 0; r < numRegions; r++) {
rainShadow[r] = shadowField[r] < 0 ? shadowField[r] : windwardField[r];
}
}
// Smooth ~150 km so the zones read clearly
const rsSmoothPasses = Math.max(2, Math.round(150 / avgEdgeKm));
smoothField(mesh, rainShadow, rsSmoothPasses);
// ── Step 2c: Apply propagated rain shadow to actual precipitation ──
// The local orographic effect in (c) only touches the mountain slopes
// themselves. This step extends the shadow hundreds of km downwind and
// boosts windward rain upwind, using the propagated field from 2b.
for (let r = 0; r < numRegions; r++) {
if (!r_isLand[r]) continue;
const rs = rainShadow[r];
if (rs < -0.01) {
// Shadow zone: precipitation suppression behind mountains
const strength = Math.min(1, -rs * 2.25);
precip[r] *= Math.max(0.02, 1 - strength * 0.92);
} else if (rs > 0.01) {
// Windward zone: strong orographic precipitation enhancement
precip[r] += rs * 1.2;
}
}
// ── Step 3: Smooth (normalization deferred to blending step) ──
t0 = performance.now();
// Light smoothing ~100 km to blend cell-to-cell noise
const precipSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
smoothField(mesh, precip, precipSmoothPasses);
const tSmooth = performance.now() - t0;
timing.push({ stage: `Precip: advection (${name})`, ms: tAdvect });
timing.push({ stage: `Precip: mechanisms (${name})`, ms: tMechanisms });
timing.push({ stage: `Precip: smooth (${name})`, ms: tSmooth });
result[`r_precip_${name}`] = precip;
result[`r_rainshadow_${name}`] = rainShadow;
}
// ── Step 4: Blend with heuristic model and normalize ──
t0 = performance.now();
const heuristic = computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand);
for (const seasonName of ['summer', 'winter']) {
const complex = result[`r_precip_${seasonName}`];
const heur = heuristic[`r_precip_${seasonName}`];
const blended = new Float32Array(numRegions);
for (let r = 0; r < numRegions; r++) {
blended[r] = 0.5 * complex[r] + 0.5 * heur[r];
}
// 95th-percentile normalization on blended result
const maxPrecip = percentile(blended, 0.95);
for (let r = 0; r < numRegions; r++) {
blended[r] = Math.min(1, blended[r] / maxPrecip);
}
// Continental interior cap: interior regions can't exceed steppe-level
// precipitation. At cont=1.0, cap is 0.20 per season (≈ 200mm
// half-year → 400mm annual — solidly in steppe territory). Fades in
// from cont 0.5 so the transition is gradual. Other factors (desert
// factory, rain shadows, distance cutoff) can still push lower.
const r_continentality = windResult.r_continentality;
if (r_continentality) {
for (let r = 0; r < numRegions; r++) {
if (r_isLand[r] && r_continentality[r] > 0.5) {
const t = smoothstep(0.5, 1.0, r_continentality[r]);
const cap = 1.0 - t * 0.80; // 1.0 at cont=0.5, 0.20 at cont=1.0
blended[r] = Math.min(blended[r], cap);
}
}
}
result[`r_precip_${seasonName}`] = blended;
}
timing.push({ stage: 'Precip: heuristic blend+normalize', ms: performance.now() - t0 });
result._precipTiming = timing;
return result;
}