This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+686
View File
@@ -0,0 +1,686 @@
// 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;
}