diff --git a/README.md b/README.md
index 64354f1..f2991b7 100644
--- a/README.md
+++ b/README.md
@@ -108,7 +108,7 @@ them without reworking the data model.
| `GET /api/worlds/{id}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry |
-| `GET /api/worlds/{id}/weather` | The weather field over the map: an 8×8 grid of samples, row-major from the south-west corner |
+| `GET /api/worlds/{id}/weather` | The weather field over the map: a 12×12 grid of samples, row-major from the south-west corner |
| `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 1–4). Body: `{ paused?, timeScale? }` |
| `DELETE /api/worlds/{id}` | Remove a world and its chunks |
| `GET /api/climates` | The climate catalogue for the create form, with the latitude band each preset is the default for |
@@ -191,13 +191,21 @@ floor so hairlines stay visible. At street level the map picks up the things tha
- gentle bends in roads and watercourses are rounded off by Chaikin corner cutting; corners sharper than 50°
are left alone, because a gridded town is full of genuine right angles
-`WeatherLayer` sits over the map in screen space, so the weather does not slide about when you pan. Below the
-place names goes a wash: a colour for the time of day, interpolated from the sun's elevation through golden
-hour, dusk and night, greyed down by cloud while the sun is up; then white for lying snow; then a pale haze
-for fog, blizzards and sandstorms. Above the names falls the precipitation — slanted streaks for rain,
-drifting dots for snow — leaning downwind at a slant taken from the local wind and capped so a gale still
-looks like weather rather than a barcode. The whole thing is read from the server grid under the middle of
-the screen, so panning towards a front walks into the rain.
+`WeatherLayer` sits over the map. **What it draws is about the ground, not about the viewport.** Only the
+light of the time of day covers the screen evenly — the sun sets on a whole town at once — and that is the
+one flat rectangle: a colour interpolated from the sun's elevation through golden hour, dusk and night.
+
+Everything else is read from the field under each patch of screen and painted where it is actually
+happening: lying snow, the dimming under cloud, and the precipitation map itself, which darkens the ground a
+shower is standing over so the shape of a front is legible from any zoom. The patches are a coarse grid,
+drawn oversized and blurred, which turns ten steps into a gradient. Falling rain and snow are drawn above
+the place names, and each drop is only drawn if the ground beneath it is wet, so the fall thins out across
+the edge of a front instead of the whole screen raining together.
+
+Pressure systems are sized to be cells on the map rather than the whole sky. A real depression spans a
+thousand kilometres and would sit over a town as one flat value with no edge at all — the same trade already
+made for drift speed. Their lower bound is set by the export grid: a system narrower than about two node
+spacings aliases into it and the client interpolates a lie.
The maths lives in `sky.ts` and `weatherField.ts`, which import no PixiJS and are unit-tested; `weatherLayer.ts`
only knows how to paint the result. A dark theme pulls the night wash back rather than switching it off,
diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs
index 799bc2f..bd7dc9d 100644
--- a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs
+++ b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs
@@ -307,8 +307,11 @@ public sealed class WorldSimulation : IDisposable
}
}
- /// Nodes per side of the weather grid served to the renderer.
- public const int WeatherGridSize = 8;
+ ///
+ /// Nodes per side of the weather grid served to the renderer. Fine enough to carry the shape of the
+ /// smallest pressure system without aliasing, and still only a couple of hundred numbers on the wire.
+ ///
+ public const int WeatherGridSize = 12;
/// Weather at the middle of the map - what the HUD and the world list show.
public WeatherDto SnapshotWeather()
diff --git a/src/TheLivingWorld.Core/Simulation/WeatherModel.cs b/src/TheLivingWorld.Core/Simulation/WeatherModel.cs
index cf7de36..a4aed80 100644
--- a/src/TheLivingWorld.Core/Simulation/WeatherModel.cs
+++ b/src/TheLivingWorld.Core/Simulation/WeatherModel.cs
@@ -45,6 +45,9 @@ public static class WeatherModel
private const float DaysPerYear = 365.2425f;
+ /// How far the wind may push the temperature either way, in °C.
+ private const float MaxAdvectionC = 2.5f;
+
/// -1 at midwinter, +1 at midsummer, flipped below the equator.
public static float SeasonPhase(DateTime gameTime, double latitude)
{
@@ -96,8 +99,11 @@ public static class WeatherModel
var (windSpeed, windVectorX, windVectorY) = Wind(climate, latitude, gradientX, gradientY);
// Wind off the equator is warm, wind off the pole is cold; which is which flips by hemisphere.
+ // Kept deliberately weak and capped: this is a few kilometres of map, and a front that swung the
+ // temperature by ten degrees across it would be nonsense - and steep enough to alias the grid the
+ // field is exported on.
var poleward = latitude < 0 ? -windVectorY : windVectorY;
- var advection = 0.35f * poleward;
+ var advection = Math.Clamp(0.12f * poleward, -MaxAdvectionC, MaxAdvectionC);
var temperature =
climate.MeanTemperatureC
diff --git a/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs b/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs
index 716a5ac..5aeef9d 100644
--- a/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs
+++ b/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs
@@ -200,7 +200,11 @@ public static class WeatherSystem
VelocityX: velocityX,
VelocityY: velocityY,
IntensityHpa: cyclone ? -magnitude : magnitude * 0.7f,
- Radius: random.Range(0.3f, 0.95f),
+ // Small enough that a system is a cell on the map rather than the whole sky - a real depression
+ // spans a thousand kilometres and would sit over a town as one flat value with no edge to it,
+ // the same trade already made for drift speed. The floor is set by the export grid: a system
+ // narrower than about two node spacings aliases into it and the client interpolates a lie.
+ Radius: random.Range(0.15f, 0.42f),
AgeHours: 0f,
LifetimeHours: random.Range(18f, 72f));
}
diff --git a/src/TheLivingWorld.Web/src/map/mapView.ts b/src/TheLivingWorld.Web/src/map/mapView.ts
index 5e98f34..f313d80 100644
--- a/src/TheLivingWorld.Web/src/map/mapView.ts
+++ b/src/TheLivingWorld.Web/src/map/mapView.ts
@@ -7,8 +7,16 @@ import { LabelLayer } from './labelLayer';
import { LAYER_ORDER, type MapLayers } from './layers';
import { precipitationSpec, skyState } from './sky';
import { THEMES, type Theme, type ThemeName } from './theme';
-import { CALM, sampleWeatherField, type LocalWeather } from './weatherField';
-import { WeatherLayer } from './weatherLayer';
+import {
+ CALM,
+ CLOUD_COVER,
+ PRECIPITATION,
+ SNOW_DEPTH,
+ sampleValue,
+ sampleWeatherField,
+ type LocalWeather,
+} from './weatherField';
+import { NO_WEATHER, WeatherLayer, type WeatherProbe } from './weatherLayer';
/**
* Builds the container per layer. This lives here rather than in `layers.ts` so that module stays free of
@@ -28,6 +36,9 @@ function createLayers(): MapLayers {
/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
const CHUNK_UPDATE_INTERVAL_MS = 90;
+/** Screen fractions probed when sizing the particle pool: the four corners of the view. */
+const CORNERS = [[0.08, 0.08], [0.92, 0.08], [0.08, 0.92], [0.92, 0.92]] as const;
+
export interface MapStatus {
zoom: number;
metersPerPixel: number;
@@ -235,17 +246,20 @@ export class MapView {
this.weather.resize(viewport.width, viewport.height);
if (this.gameTime) {
- const local = this.localWeather();
+ // The light of the day is read at the middle of the screen because it covers the map evenly; what is
+ // falling is sized from the wettest ground in view, so a shower in one corner still gets its drops.
+ const centre = this.localWeather();
this.weather.apply(
- skyState(this.gameTime, this.latitude, local, this.theme.dark),
- precipitationSpec(local),
+ skyState(this.gameTime, this.latitude, centre, this.theme.dark),
+ precipitationSpec(this.wettestInView(viewport, centre)),
+ this.buildProbe(viewport),
);
}
this.weather.advance(deltaMs);
}
- /** The field read under the middle of the screen, so what falls is what is overhead right now. */
+ /** The field read under the middle of the screen. */
private localWeather(): LocalWeather {
if (!this.weatherField || this.worldSizeMeters === 0) return CALM;
@@ -257,6 +271,51 @@ export class MapView {
);
}
+ /**
+ * Lets the weather layer read the field under any pixel. The projection has no rotation, so a screen
+ * position maps straight onto the map with two divisions - cheap enough to call per particle, per frame.
+ */
+ private buildProbe(viewport: Viewport): WeatherProbe {
+ const field = this.weatherField;
+ if (!field || this.worldSizeMeters === 0) return NO_WEATHER;
+
+ const size = this.worldSizeMeters;
+ const half = size / 2;
+ const camera = this.camera;
+
+ const u = (screenX: number) =>
+ (camera.x + ((screenX - (viewport.width / 2)) / camera.zoom) + half) / size;
+ const v = (screenY: number) =>
+ (camera.y - ((screenY - (viewport.height / 2)) / camera.zoom) + half) / size;
+
+ return {
+ precipitation: (x, y) => sampleValue(field, u(x), v(y), PRECIPITATION),
+ cloud: (x, y) => sampleValue(field, u(x), v(y), CLOUD_COVER),
+ snow: (x, y) => sampleValue(field, u(x), v(y), SNOW_DEPTH),
+ };
+ }
+
+ /**
+ * The heaviest precipitation anywhere on screen, which is what the particle pool has to be sized for. Take
+ * the middle instead and a front covering half the view would arrive with half the drops it needs.
+ */
+ private wettestInView(viewport: Viewport, centre: LocalWeather): LocalWeather {
+ const field = this.weatherField;
+ if (!field || this.worldSizeMeters === 0) return centre;
+
+ const size = this.worldSizeMeters;
+ const half = size / 2;
+ let wettest = centre;
+
+ for (const [fx, fy] of CORNERS) {
+ const world = this.camera.screenToWorld(fx * viewport.width, fy * viewport.height, viewport);
+ const sample = sampleWeatherField(field, (world.x + half) / size, (world.y + half) / size);
+ if (sample.precipitationMmH > wettest.precipitationMmH) wettest = sample;
+ }
+
+ return wettest;
+ }
+
private drawBorder(): void {
const half = this.worldSizeMeters / 2;
this.border
diff --git a/src/TheLivingWorld.Web/src/map/sky.test.ts b/src/TheLivingWorld.Web/src/map/sky.test.ts
index c9d067c..ca59666 100644
--- a/src/TheLivingWorld.Web/src/map/sky.test.ts
+++ b/src/TheLivingWorld.Web/src/map/sky.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { FULL_SNOW_COVER_MM, precipitationSpec, skyState, sunElevationDeg } from './sky';
+import { precipitationSpec, skyState, sunElevationDeg } from './sky';
import { CALM, type LocalWeather } from './weatherField';
const WARSAW = 52.23;
@@ -74,24 +74,19 @@ describe('skyState', () => {
expect(onDark).toBeLessThan(onLight);
});
- it('greys the light down under cloud, but only while the sun is up', () => {
- const clearNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
- const cloudyNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather({ cloudCover: 1 }), false);
- expect(cloudyNoon.tintAlpha).toBeGreaterThan(clearNoon.tintAlpha);
+ it('leaves cloud, rain and snow out of the global wash', () => {
+ // These belong to particular ground, so the layer paints them patch by patch from the field. Folding
+ // them in here would smear a shower standing over one corner of the town across the whole of it.
+ const clear = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
+ const filthy = skyState(
+ at(2012, 6, 21, 12),
+ WARSAW,
+ weather({ cloudCover: 1, precipitationMmH: 9, snowDepthMm: 400 }),
+ false,
+ );
- const clearNight = skyState(at(2012, 12, 21, 0), WARSAW, weather(), false);
- const cloudyNight = skyState(at(2012, 12, 21, 0), WARSAW, weather({ cloudCover: 1 }), false);
- expect(cloudyNight.tintAlpha).toBeCloseTo(clearNight.tintAlpha, 5);
- });
-
- it('reports snow cover as a fraction that saturates', () => {
- expect(skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: 0 }), false).snowCover).toBe(0);
- expect(
- skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: FULL_SNOW_COVER_MM / 2 }), false).snowCover,
- ).toBeCloseTo(0.5, 5);
- expect(
- skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: 900 }), false).snowCover,
- ).toBe(1);
+ expect(filthy.tintAlpha).toBeCloseTo(clear.tintAlpha, 6);
+ expect(filthy.tint).toBe(clear.tint);
});
it('hazes over for fog and a blizzard, but not for plain rain', () => {
@@ -120,7 +115,7 @@ describe('skyState', () => {
expect(state.tintAlpha).toBeGreaterThanOrEqual(0);
expect(state.tintAlpha).toBeLessThanOrEqual(1);
- expect(state.snowCover).toBeLessThanOrEqual(1);
+ expect(state.hazeAlpha).toBeLessThanOrEqual(1);
}
}
});
diff --git a/src/TheLivingWorld.Web/src/map/sky.ts b/src/TheLivingWorld.Web/src/map/sky.ts
index e5180a4..17081e8 100644
--- a/src/TheLivingWorld.Web/src/map/sky.ts
+++ b/src/TheLivingWorld.Web/src/map/sky.ts
@@ -13,8 +13,6 @@ export interface SkyState {
tintAlpha: number;
/** A separate pale layer for fog and driving snow, which lighten rather than darken. */
hazeAlpha: number;
- /** How thoroughly the ground is covered, 0..1. Drives the white over roofs and streets. */
- snowCover: number;
/** True during a thunderstorm, which is the only thing that separates one from plain heavy rain. */
lightning: boolean;
}
@@ -80,7 +78,10 @@ export function sunElevationDeg(gameTime: Date, latitude: number): number {
}
/**
- * Builds the wash over the map from the sun, the cloud and what is on the ground.
+ * Builds the part of the wash that covers the whole map evenly: the light of the time of day. Cloud, rain
+ * and lying snow are deliberately NOT here - they sit over particular ground, so the layer paints them patch
+ * by patch from the field instead. Mixing them in would both double-count them and smear a shower that is
+ * over one corner of the town across all of it.
*
* `alreadyDark` is the night theme: the map is drawn dark to begin with, so piling a full night wash on top
* of it would leave the streets unreadable. The wash is pulled back rather than switched off, because dusk
@@ -95,20 +96,11 @@ export function skyState(
const elevation = sunElevationDeg(gameTime, latitude);
const base = interpolateStops(elevation);
- // Cloud greys the light down by day and holds a little warmth in at night, so it never simply adds up.
- const daylight = clamp((elevation + 6) / 18, 0, 1);
- const cloud = clamp01(weather.cloudCover);
- const tint = mix(base.tint, 0x8d95a0, cloud * 0.55 * daylight);
- const cloudAlpha = cloud * 0.16 * daylight;
-
- const alpha = (base.alpha + cloudAlpha) * (alreadyDark ? 0.45 : 1);
-
return {
sunElevationDeg: elevation,
- tint,
- tintAlpha: clamp01(alpha),
+ tint: base.tint,
+ tintAlpha: clamp01(base.alpha * (alreadyDark ? 0.45 : 1)),
hazeAlpha: hazeFor(weather),
- snowCover: clamp01(weather.snowDepthMm / FULL_SNOW_COVER_MM),
lightning: weather.condition === 'thunderstorm',
};
}
diff --git a/src/TheLivingWorld.Web/src/map/weatherField.test.ts b/src/TheLivingWorld.Web/src/map/weatherField.test.ts
index 1e4f537..906afa7 100644
--- a/src/TheLivingWorld.Web/src/map/weatherField.test.ts
+++ b/src/TheLivingWorld.Web/src/map/weatherField.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import type { Weather, WeatherCondition, WeatherField } from '../api/types';
-import { sampleWeatherField } from './weatherField';
+import {
+ CLOUD_COVER,
+ PRECIPITATION,
+ SNOW_DEPTH,
+ sampleValue,
+ sampleWeatherField,
+} from './weatherField';
function node(overrides: Partial = {}): Weather {
return {
@@ -109,6 +115,43 @@ describe('sampleWeatherField', () => {
expect(sampleWeatherField(field([node()], 8), 0.5, 0.5).precipitationMmH).toBe(0);
});
+ it('reads a single value without building a whole reading', () => {
+ const grid = field([
+ node({ precipitationMmH: 0 }),
+ node({ precipitationMmH: 0 }),
+ node({ precipitationMmH: 8 }),
+ node({ precipitationMmH: 8 }),
+ ]);
+
+ // Dry along the southern edge, pouring along the northern one, with the front in between.
+ expect(sampleValue(grid, 0.5, 0, PRECIPITATION)).toBe(0);
+ expect(sampleValue(grid, 0.5, 1, PRECIPITATION)).toBe(8);
+ expect(sampleValue(grid, 0.5, 0.5, PRECIPITATION)).toBeCloseTo(4, 5);
+ });
+
+ it('agrees with the full reading it is a shortcut for', () => {
+ const grid = field([
+ node({ precipitationMmH: 1, cloudCover: 0.1, snowDepthMm: 5 }),
+ node({ precipitationMmH: 4, cloudCover: 0.4, snowDepthMm: 15 }),
+ node({ precipitationMmH: 7, cloudCover: 0.7, snowDepthMm: 25 }),
+ node({ precipitationMmH: 9, cloudCover: 0.9, snowDepthMm: 40 }),
+ ]);
+
+ for (const u of [0, 0.3, 0.75, 1]) {
+ for (const v of [0, 0.4, 1]) {
+ const full = sampleWeatherField(grid, u, v);
+ expect(sampleValue(grid, u, v, PRECIPITATION)).toBeCloseTo(full.precipitationMmH, 6);
+ expect(sampleValue(grid, u, v, CLOUD_COVER)).toBeCloseTo(full.cloudCover, 6);
+ expect(sampleValue(grid, u, v, SNOW_DEPTH)).toBeCloseTo(full.snowDepthMm, 6);
+ }
+ }
+ });
+
+ it('returns zero from a malformed field rather than reading off the end', () => {
+ expect(sampleValue(field([], 0), 0.5, 0.5, PRECIPITATION)).toBe(0);
+ expect(sampleValue(field([node()], 8), 0.5, 0.5, PRECIPITATION)).toBe(0);
+ });
+
it('handles a one-node field without dividing by zero', () => {
const grid = field([node({ temperatureC: 7 })], 1);
expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBe(7);
diff --git a/src/TheLivingWorld.Web/src/map/weatherField.ts b/src/TheLivingWorld.Web/src/map/weatherField.ts
index 191c4ba..544a444 100644
--- a/src/TheLivingWorld.Web/src/map/weatherField.ts
+++ b/src/TheLivingWorld.Web/src/map/weatherField.ts
@@ -1,4 +1,4 @@
-import type { WeatherCondition, WeatherField } from '../api/types';
+import type { Weather, WeatherCondition, WeatherField } from '../api/types';
/** What the weather is doing at one point of the map, read out of the coarse server grid. */
export interface LocalWeather {
@@ -70,6 +70,40 @@ export function sampleWeatherField(field: WeatherField, u: number, v: number): L
};
}
+/**
+ * Reads one number out of the field, without building a whole {@link LocalWeather} for it. The renderer
+ * samples per particle and per patch of screen every frame, and at those rates the garbage from a full
+ * reading is what would cost, not the arithmetic.
+ */
+export function sampleValue(
+ field: WeatherField,
+ u: number,
+ v: number,
+ pick: (node: Weather) => number,
+): number {
+ const size = field.size;
+ if (size < 1 || field.nodes.length < size * size) return 0;
+
+ const x = clamp01(u) * (size - 1);
+ const y = clamp01(v) * (size - 1);
+
+ const x0 = Math.min(Math.floor(x), size - 1);
+ const y0 = Math.min(Math.floor(y), size - 1);
+ const x1 = Math.min(x0 + 1, size - 1);
+ const y1 = Math.min(y0 + 1, size - 1);
+ const fx = x - x0;
+ const fy = y - y0;
+
+ const at = (column: number, row: number) => pick(field.nodes[(row * size) + column]!);
+ const top = lerp(at(x0, y0), at(x1, y0), fx);
+ const bottom = lerp(at(x0, y1), at(x1, y1), fx);
+ return lerp(top, bottom, fy);
+}
+
+export const PRECIPITATION = (node: Weather): number => node.precipitationMmH;
+export const CLOUD_COVER = (node: Weather): number => node.cloudCover;
+export const SNOW_DEPTH = (node: Weather): number => node.snowDepthMm;
+
/**
* Bearings wrap, so averaging them as plain numbers puts the midpoint of 350° and 10° at 180° — pointing
* exactly backwards. Interpolating the unit vectors instead gives 0°, which is the answer.
diff --git a/src/TheLivingWorld.Web/src/map/weatherLayer.ts b/src/TheLivingWorld.Web/src/map/weatherLayer.ts
index 9841d24..b9d1f73 100644
--- a/src/TheLivingWorld.Web/src/map/weatherLayer.ts
+++ b/src/TheLivingWorld.Web/src/map/weatherLayer.ts
@@ -1,5 +1,5 @@
-import { Container, Graphics } from 'pixi.js';
-import type { PrecipitationSpec, SkyState } from './sky';
+import { BlurFilter, Container, Graphics } from 'pixi.js';
+import { FULL_SNOW_COVER_MM, type PrecipitationSpec, type SkyState } from './sky';
/** Densities in {@link PrecipitationSpec} are quoted for this viewport and scaled by area from here. */
const REFERENCE_AREA = 1280 * 720;
@@ -7,12 +7,37 @@ const REFERENCE_AREA = 1280 * 720;
/** A hard ceiling on particles, whatever the screen size — the whole layer redraws every frame. */
const MAX_PARTICLES = 600;
+/**
+ * Patches across the screen used to paint cloud, fog and lying snow. The server field is only 8×8 over the
+ * whole map, so this is plenty to carry its shape; a blur smooths the seams between patches into a gradient.
+ */
+const WASH_PATCHES = 10;
+
+/**
+ * Reads what the weather is doing over the ground under a screen position. The renderer never asks the field
+ * directly — this is how it stays about the territory rather than about the viewport.
+ */
+export interface WeatherProbe {
+ /** Precipitation in mm/h under this pixel. */
+ precipitation(screenX: number, screenY: number): number;
+ /** Cloud cover 0..1 under this pixel. */
+ cloud(screenX: number, screenY: number): number;
+ /** Lying snow in mm under this pixel. */
+ snow(screenX: number, screenY: number): number;
+}
+
+/** Used before any field has arrived: nothing anywhere. */
+export const NO_WEATHER: WeatherProbe = {
+ precipitation: () => 0,
+ cloud: () => 0,
+ snow: () => 0,
+};
+
const CLEAR_SKY: SkyState = {
sunElevationDeg: 90,
tint: 0xffffff,
tintAlpha: 0,
hazeAlpha: 0,
- snowCover: 0,
lightning: false,
};
@@ -23,6 +48,12 @@ const NOTHING_FALLING: PrecipitationSpec = {
speedPxPerSecond: 0,
};
+/** Below this the drop is over dry ground and simply is not drawn. */
+const PRECIPITATION_FLOOR_MMH = 0.05;
+
+/** Precipitation that counts as a downpour, for scaling a drop's opacity between edge and core. */
+const HEAVY_RAIN_MMH = 6;
+
interface Particle {
x: number;
y: number;
@@ -33,8 +64,10 @@ interface Particle {
}
/**
- * Everything the weather draws over the map: the wash for time of day, cloud, fog and lying snow, plus the
- * rain or snow falling through it. Both live in screen space, so panning does not drag the weather along.
+ * Everything the weather draws over the map. The wash for time of day is one flat layer — the sun sets on a
+ * whole town at once — but cloud, fog and lying snow are painted patch by patch from the field under the
+ * ground, and each drop is drawn only if it is over ground that is actually wet. That is what lets the edge
+ * of a front sit across the map instead of the whole screen raining together.
*/
export class WeatherLayer {
/** Sits above the map but below the place names, which stay readable through it. */
@@ -43,7 +76,9 @@ export class WeatherLayer {
/** Sits above everything — rain falls in front of the labels too. */
readonly precipitation = new Container();
- private readonly wash = new Graphics();
+ private readonly tint = new Graphics();
+ private readonly patches = new Graphics();
+ private readonly patchBlur = new BlurFilter({ strength: 24, quality: 3 });
private readonly flash = new Graphics();
private readonly drops = new Graphics();
private readonly particles: Particle[] = [];
@@ -52,7 +87,7 @@ export class WeatherLayer {
private height = 0;
private state: SkyState = CLEAR_SKY;
private spec: PrecipitationSpec = NOTHING_FALLING;
- private washDirty = true;
+ private probe: WeatherProbe = NO_WEATHER;
/** Seconds until the next strike, and how much of the current flash is left to burn off. */
private nextStrikeIn = 0;
@@ -60,7 +95,11 @@ export class WeatherLayer {
private flashPeak = 0;
constructor() {
- this.sky.addChild(this.wash);
+ // The patches are deliberately coarse; blurring them turns the grid into a smooth field.
+ this.patches.filters = [this.patchBlur];
+
+ this.sky.addChild(this.tint);
+ this.sky.addChild(this.patches);
this.sky.addChild(this.flash);
this.precipitation.addChild(this.drops);
this.sky.eventMode = 'none';
@@ -72,33 +111,21 @@ export class WeatherLayer {
this.width = width;
this.height = height;
- this.washDirty = true;
this.resizePool();
}
/** Nothing is drawn until this is called; a world with no weather yet stays untouched. */
- apply(state: SkyState, spec: PrecipitationSpec): void {
- if (
- state.tint !== this.state.tint
- || state.tintAlpha !== this.state.tintAlpha
- || state.hazeAlpha !== this.state.hazeAlpha
- || state.snowCover !== this.state.snowCover
- ) {
- this.washDirty = true;
- }
-
+ apply(state: SkyState, spec: PrecipitationSpec, probe: WeatherProbe): void {
this.state = state;
this.spec = spec;
+ this.probe = probe;
this.resizePool();
}
/** Steps the falling particles and repaints. Called once per frame. */
advance(deltaMs: number): void {
- if (this.washDirty) {
- this.paintWash();
- this.washDirty = false;
- }
-
+ this.paintTint();
+ this.paintPatches();
this.stepLightning(deltaMs);
this.stepParticles(deltaMs);
this.paintParticles();
@@ -106,11 +133,13 @@ export class WeatherLayer {
clear(): void {
this.particles.length = 0;
- this.wash.clear();
+ this.tint.clear();
+ this.patches.clear();
this.flash.clear();
this.drops.clear();
this.state = CLEAR_SKY;
this.spec = NOTHING_FALLING;
+ this.probe = NO_WEATHER;
this.nextStrikeIn = 0;
this.flashRemaining = 0;
}
@@ -120,23 +149,68 @@ export class WeatherLayer {
this.precipitation.destroy({ children: true });
}
- private paintWash(): void {
- this.wash.clear();
+ /** Time of day covers the whole map evenly, so it stays one rectangle. */
+ private paintTint(): void {
+ this.tint.clear();
+ if (this.width === 0 || this.state.tintAlpha <= 0.001) return;
+
+ this.tint.rect(0, 0, this.width, this.height).fill({
+ color: this.state.tint,
+ alpha: this.state.tintAlpha,
+ });
+ }
+
+ /**
+ * Cloud, fog and snow follow the ground, so they are painted as a grid read from the field under each
+ * patch. The patches are drawn oversized and blurred, which is what turns ten steps into a gradient.
+ */
+ private paintPatches(): void {
+ this.patches.clear();
if (this.width === 0 || this.height === 0) return;
- const { tint, tintAlpha, hazeAlpha, snowCover } = this.state;
+ const patchWidth = this.width / WASH_PATCHES;
+ const patchHeight = this.height / WASH_PATCHES;
+ this.patchBlur.strength = Math.max(patchWidth, patchHeight) * 0.9;
- if (tintAlpha > 0.001) {
- this.wash.rect(0, 0, this.width, this.height).fill({ color: tint, alpha: tintAlpha });
- }
+ // Bleed past the edges so the blur does not fade the wash out at the border of the screen.
+ const bleed = Math.max(patchWidth, patchHeight);
- // Lying snow goes on before the haze so fog still reads as fog over a white landscape.
- if (snowCover > 0.001) {
- this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xeef3f8, alpha: snowCover * 0.5 });
- }
+ for (let row = 0; row < WASH_PATCHES; row++) {
+ for (let column = 0; column < WASH_PATCHES; column++) {
+ const centreX = (column + 0.5) * patchWidth;
+ const centreY = (row + 0.5) * patchHeight;
- if (hazeAlpha > 0.001) {
- this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xd7dce2, alpha: hazeAlpha });
+ const snow = Math.min(this.probe.snow(centreX, centreY) / FULL_SNOW_COVER_MM, 1);
+ const overcast = this.probe.cloud(centreX, centreY);
+ const falling = Math.min(this.probe.precipitation(centreX, centreY) / HEAVY_RAIN_MMH, 1);
+
+ const x = column === 0 ? -bleed : column * patchWidth;
+ const y = row === 0 ? -bleed : row * patchHeight;
+ const w = patchWidth + (column === 0 || column === WASH_PATCHES - 1 ? bleed : 0);
+ const h = patchHeight + (row === 0 || row === WASH_PATCHES - 1 ? bleed : 0);
+
+ if (snow > 0.002) {
+ this.patches.rect(x, y, w, h).fill({ color: 0xeef3f8, alpha: snow * 0.5 });
+ }
+
+ // Cloud dims the ground under it, which is what makes a cloud shadow read as a shadow.
+ if (overcast > 0.002 && this.state.sunElevationDeg > -6) {
+ this.patches.rect(x, y, w, h).fill({ color: 0x8d95a0, alpha: overcast * 0.22 });
+ }
+
+ // The precipitation map proper: the shower darkens the ground it is standing over, so the shape of
+ // a front is legible from any zoom - individual drops are far too small to read from across a map.
+ if (falling > 0.01) {
+ this.patches.rect(x, y, w, h).fill({
+ color: this.spec.kind === 'snow' ? 0xdce6f2 : 0x5f7b98,
+ alpha: falling * 0.34,
+ });
+ }
+
+ if (this.state.hazeAlpha > 0.002) {
+ this.patches.rect(x, y, w, h).fill({ color: 0xd7dce2, alpha: this.state.hazeAlpha });
+ }
+ }
}
}
@@ -175,7 +249,7 @@ export class WeatherLayer {
this.flash.rect(0, 0, this.width, this.height).fill({ color: 0xf2f6ff, alpha });
}
- /** Grows or trims the pool to the density the current weather asks for. */
+ /** Grows or trims the pool to the density the wettest part of the screen asks for. */
private resizePool(): void {
const target = this.targetCount();
@@ -228,34 +302,73 @@ export class WeatherLayer {
}
}
+ /**
+ * Draws each particle at the strength of the ground beneath it, so the pool thins out to nothing across
+ * the edge of a front instead of raining evenly over the whole viewport.
+ */
private paintParticles(): void {
this.drops.clear();
if (this.particles.length === 0) return;
+ if (this.spec.kind === 'dust') {
+ this.paintDust();
+ return;
+ }
+
if (this.spec.kind === 'snow') {
- for (const particle of this.particles) {
- this.drops.circle(particle.x, particle.y, 1.1 * particle.scale);
+ // Opacity carries the intensity, so the flakes are grouped into bands and filled once per band.
+ for (const band of [0.3, 0.6, 1]) {
+ let drawn = false;
+ for (const particle of this.particles) {
+ if (this.bandOf(particle) !== band) continue;
+ this.drops.circle(particle.x, particle.y, 1.1 * particle.scale);
+ drawn = true;
+ }
+
+ if (drawn) this.drops.fill({ color: 0xffffff, alpha: 0.85 * band });
}
- this.drops.fill({ color: 0xffffff, alpha: 0.85 });
return;
}
- // Rain and dust are both streaks; only their length and colour differ. One path for the lot, stroked
- // once, so Pixi batches the whole thing into a single draw.
- const dust = this.spec.kind === 'dust';
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
- const length = dust ? 4 : 9 + (this.spec.speedPxPerSecond / 90);
+ const length = 9 + (this.spec.speedPxPerSecond / 90);
+
+ for (const band of [0.3, 0.6, 1]) {
+ let drawn = false;
+ for (const particle of this.particles) {
+ if (this.bandOf(particle) !== band) continue;
+ const streak = length * particle.scale;
+ this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (streak * slant), particle.y + streak);
+ drawn = true;
+ }
+
+ if (drawn) this.drops.stroke({ width: 1.1, color: 0xaec6dd, alpha: 0.55 * band });
+ }
+ }
+
+ /**
+ * Buckets a particle's local intensity into one of three opacities. Stroking once per bucket keeps the
+ * whole fall to three draws however many drops there are, which a per-drop alpha would not.
+ */
+ private bandOf(particle: Particle): number {
+ const local = this.probe.precipitation(particle.x, particle.y);
+ if (local < PRECIPITATION_FLOOR_MMH) return 0;
+
+ const strength = Math.min(local / HEAVY_RAIN_MMH, 1);
+ if (strength < 0.25) return 0.3;
+ return strength < 0.6 ? 0.6 : 1;
+ }
+
+ /** Dust blows over the whole storm rather than over wet ground, so it ignores the precipitation probe. */
+ private paintDust(): void {
+ const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
for (const particle of this.particles) {
- const streak = length * particle.scale;
+ const streak = 4 * particle.scale;
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (streak * slant), particle.y + streak);
}
- this.drops.stroke(
- dust
- ? { width: 1.4, color: 0xc9a86a, alpha: 0.4 }
- : { width: 1.1, color: 0xaec6dd, alpha: 0.55 },
- );
+ this.drops.stroke({ width: 1.4, color: 0xc9a86a, alpha: 0.4 });
}
}
diff --git a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs
index 9330493..3f63d81 100644
--- a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs
+++ b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs
@@ -258,6 +258,32 @@ public sealed class WorldSimulationTests
Assert.True(pressures > 1, "The field is uniform - the pressure systems are not being sampled.");
}
+ ///
+ /// The whole point of a field is that it has shape. A system wide enough to cover the map evenly reads
+ /// as one flat value with no edge, which is what makes a front invisible however carefully it is drawn.
+ ///
+ [Fact]
+ public void A_stormy_world_has_real_structure_across_the_map()
+ {
+ var summary = ReadySummary() with { Climate = ClimateKind.Oceanic, Latitude = 51.51 };
+
+ // Sample a few independent skies: any one roll can happen to be flat, a dozen cannot.
+ var structured = 0;
+ for (var attempt = 0; attempt < 12; attempt++)
+ {
+ using var simulation = WorldSimulation.Create(
+ summary with { Id = $"storm-{attempt:00000000}" }, catchUp: false);
+
+ var pressures = simulation.SnapshotWeatherField().Nodes
+ .Select(static node => node.PressureHpa)
+ .ToArray();
+
+ if (pressures.Max() - pressures.Min() > 2.0) structured++;
+ }
+
+ Assert.True(structured >= 8, $"Only {structured}/12 skies had any shape across the map.");
+ }
+
[Fact]
public void Neighbouring_field_nodes_stay_close_together()
{