Update weather system to improve accuracy and detail; increase weather grid size to 12x12 for finer resolution, enhance weather layer rendering to accurately reflect localized conditions, and implement new sampling methods for precipitation and cloud cover. Refactor related components for better performance and clarity in weather data handling.

This commit is contained in:
Leonid Pershin
2026-08-17 10:26:01 +03:00
parent cb5117edba
commit c80c9f14e2
11 changed files with 387 additions and 104 deletions
+16 -8
View File
@@ -108,7 +108,7 @@ them without reworking the data model.
| `GET /api/worlds/{id}` | Status of one world | | `GET /api/worlds/{id}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index | | `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}/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` 14). Body: `{ paused?, timeScale? }` | | `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 14). Body: `{ paused?, timeScale? }` |
| `DELETE /api/worlds/{id}` | Remove a world and its chunks | | `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 | | `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° - 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 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 `WeatherLayer` sits over the map. **What it draws is about the ground, not about the viewport.** Only the
place names goes a wash: a colour for the time of day, interpolated from the sun's elevation through golden light of the time of day covers the screen evenly — the sun sets on a whole town at once — and that is the
hour, dusk and night, greyed down by cloud while the sun is up; then white for lying snow; then a pale haze one flat rectangle: a colour interpolated from the sun's elevation through golden hour, dusk and night.
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 Everything else is read from the field under each patch of screen and painted where it is actually
looks like weather rather than a barcode. The whole thing is read from the server grid under the middle of happening: lying snow, the dimming under cloud, and the precipitation map itself, which darkens the ground a
the screen, so panning towards a front walks into the rain. 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` 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, only knows how to paint the result. A dark theme pulls the night wash back rather than switching it off,
@@ -307,8 +307,11 @@ public sealed class WorldSimulation : IDisposable
} }
} }
/// <summary>Nodes per side of the weather grid served to the renderer.</summary> /// <summary>
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.
/// </summary>
public const int WeatherGridSize = 12;
/// <summary>Weather at the middle of the map - what the HUD and the world list show.</summary> /// <summary>Weather at the middle of the map - what the HUD and the world list show.</summary>
public WeatherDto SnapshotWeather() public WeatherDto SnapshotWeather()
@@ -45,6 +45,9 @@ public static class WeatherModel
private const float DaysPerYear = 365.2425f; private const float DaysPerYear = 365.2425f;
/// <summary>How far the wind may push the temperature either way, in °C.</summary>
private const float MaxAdvectionC = 2.5f;
/// <summary>-1 at midwinter, +1 at midsummer, flipped below the equator.</summary> /// <summary>-1 at midwinter, +1 at midsummer, flipped below the equator.</summary>
public static float SeasonPhase(DateTime gameTime, double latitude) 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); 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. // 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 poleward = latitude < 0 ? -windVectorY : windVectorY;
var advection = 0.35f * poleward; var advection = Math.Clamp(0.12f * poleward, -MaxAdvectionC, MaxAdvectionC);
var temperature = var temperature =
climate.MeanTemperatureC climate.MeanTemperatureC
@@ -200,7 +200,11 @@ public static class WeatherSystem
VelocityX: velocityX, VelocityX: velocityX,
VelocityY: velocityY, VelocityY: velocityY,
IntensityHpa: cyclone ? -magnitude : magnitude * 0.7f, 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, AgeHours: 0f,
LifetimeHours: random.Range(18f, 72f)); LifetimeHours: random.Range(18f, 72f));
} }
+65 -6
View File
@@ -7,8 +7,16 @@ import { LabelLayer } from './labelLayer';
import { LAYER_ORDER, type MapLayers } from './layers'; import { LAYER_ORDER, type MapLayers } from './layers';
import { precipitationSpec, skyState } from './sky'; import { precipitationSpec, skyState } from './sky';
import { THEMES, type Theme, type ThemeName } from './theme'; import { THEMES, type Theme, type ThemeName } from './theme';
import { CALM, sampleWeatherField, type LocalWeather } from './weatherField'; import {
import { WeatherLayer } from './weatherLayer'; 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 * 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. */ /** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
const CHUNK_UPDATE_INTERVAL_MS = 90; 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 { export interface MapStatus {
zoom: number; zoom: number;
metersPerPixel: number; metersPerPixel: number;
@@ -235,17 +246,20 @@ export class MapView {
this.weather.resize(viewport.width, viewport.height); this.weather.resize(viewport.width, viewport.height);
if (this.gameTime) { 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( this.weather.apply(
skyState(this.gameTime, this.latitude, local, this.theme.dark), skyState(this.gameTime, this.latitude, centre, this.theme.dark),
precipitationSpec(local), precipitationSpec(this.wettestInView(viewport, centre)),
this.buildProbe(viewport),
); );
} }
this.weather.advance(deltaMs); 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 { private localWeather(): LocalWeather {
if (!this.weatherField || this.worldSizeMeters === 0) return CALM; 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 { private drawBorder(): void {
const half = this.worldSizeMeters / 2; const half = this.worldSizeMeters / 2;
this.border this.border
+14 -19
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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'; import { CALM, type LocalWeather } from './weatherField';
const WARSAW = 52.23; const WARSAW = 52.23;
@@ -74,24 +74,19 @@ describe('skyState', () => {
expect(onDark).toBeLessThan(onLight); expect(onDark).toBeLessThan(onLight);
}); });
it('greys the light down under cloud, but only while the sun is up', () => { it('leaves cloud, rain and snow out of the global wash', () => {
const clearNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false); // These belong to particular ground, so the layer paints them patch by patch from the field. Folding
const cloudyNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather({ cloudCover: 1 }), false); // them in here would smear a shower standing over one corner of the town across the whole of it.
expect(cloudyNoon.tintAlpha).toBeGreaterThan(clearNoon.tintAlpha); 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); expect(filthy.tintAlpha).toBeCloseTo(clear.tintAlpha, 6);
const cloudyNight = skyState(at(2012, 12, 21, 0), WARSAW, weather({ cloudCover: 1 }), false); expect(filthy.tint).toBe(clear.tint);
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);
}); });
it('hazes over for fog and a blizzard, but not for plain rain', () => { 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).toBeGreaterThanOrEqual(0);
expect(state.tintAlpha).toBeLessThanOrEqual(1); expect(state.tintAlpha).toBeLessThanOrEqual(1);
expect(state.snowCover).toBeLessThanOrEqual(1); expect(state.hazeAlpha).toBeLessThanOrEqual(1);
} }
} }
}); });
+6 -14
View File
@@ -13,8 +13,6 @@ export interface SkyState {
tintAlpha: number; tintAlpha: number;
/** A separate pale layer for fog and driving snow, which lighten rather than darken. */ /** A separate pale layer for fog and driving snow, which lighten rather than darken. */
hazeAlpha: number; 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. */ /** True during a thunderstorm, which is the only thing that separates one from plain heavy rain. */
lightning: boolean; 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 * `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 * 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 elevation = sunElevationDeg(gameTime, latitude);
const base = interpolateStops(elevation); 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 { return {
sunElevationDeg: elevation, sunElevationDeg: elevation,
tint, tint: base.tint,
tintAlpha: clamp01(alpha), tintAlpha: clamp01(base.alpha * (alreadyDark ? 0.45 : 1)),
hazeAlpha: hazeFor(weather), hazeAlpha: hazeFor(weather),
snowCover: clamp01(weather.snowDepthMm / FULL_SNOW_COVER_MM),
lightning: weather.condition === 'thunderstorm', lightning: weather.condition === 'thunderstorm',
}; };
} }
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Weather, WeatherCondition, WeatherField } from '../api/types'; 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> = {}): Weather { function node(overrides: Partial<Weather> = {}): Weather {
return { return {
@@ -109,6 +115,43 @@ describe('sampleWeatherField', () => {
expect(sampleWeatherField(field([node()], 8), 0.5, 0.5).precipitationMmH).toBe(0); 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', () => { it('handles a one-node field without dividing by zero', () => {
const grid = field([node({ temperatureC: 7 })], 1); const grid = field([node({ temperatureC: 7 })], 1);
expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBe(7); expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBe(7);
+35 -1
View File
@@ -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. */ /** What the weather is doing at one point of the map, read out of the coarse server grid. */
export interface LocalWeather { 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 * 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. * exactly backwards. Interpolating the unit vectors instead gives 0°, which is the answer.
+164 -51
View File
@@ -1,5 +1,5 @@
import { Container, Graphics } from 'pixi.js'; import { BlurFilter, Container, Graphics } from 'pixi.js';
import type { PrecipitationSpec, SkyState } from './sky'; 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. */ /** Densities in {@link PrecipitationSpec} are quoted for this viewport and scaled by area from here. */
const REFERENCE_AREA = 1280 * 720; 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. */ /** A hard ceiling on particles, whatever the screen size — the whole layer redraws every frame. */
const MAX_PARTICLES = 600; 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 = { const CLEAR_SKY: SkyState = {
sunElevationDeg: 90, sunElevationDeg: 90,
tint: 0xffffff, tint: 0xffffff,
tintAlpha: 0, tintAlpha: 0,
hazeAlpha: 0, hazeAlpha: 0,
snowCover: 0,
lightning: false, lightning: false,
}; };
@@ -23,6 +48,12 @@ const NOTHING_FALLING: PrecipitationSpec = {
speedPxPerSecond: 0, 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 { interface Particle {
x: number; x: number;
y: 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 * Everything the weather draws over the map. The wash for time of day is one flat layer — the sun sets on a
* rain or snow falling through it. Both live in screen space, so panning does not drag the weather along. * 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 { export class WeatherLayer {
/** Sits above the map but below the place names, which stay readable through it. */ /** 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. */ /** Sits above everything — rain falls in front of the labels too. */
readonly precipitation = new Container(); 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 flash = new Graphics();
private readonly drops = new Graphics(); private readonly drops = new Graphics();
private readonly particles: Particle[] = []; private readonly particles: Particle[] = [];
@@ -52,7 +87,7 @@ export class WeatherLayer {
private height = 0; private height = 0;
private state: SkyState = CLEAR_SKY; private state: SkyState = CLEAR_SKY;
private spec: PrecipitationSpec = NOTHING_FALLING; 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. */ /** Seconds until the next strike, and how much of the current flash is left to burn off. */
private nextStrikeIn = 0; private nextStrikeIn = 0;
@@ -60,7 +95,11 @@ export class WeatherLayer {
private flashPeak = 0; private flashPeak = 0;
constructor() { 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.sky.addChild(this.flash);
this.precipitation.addChild(this.drops); this.precipitation.addChild(this.drops);
this.sky.eventMode = 'none'; this.sky.eventMode = 'none';
@@ -72,33 +111,21 @@ export class WeatherLayer {
this.width = width; this.width = width;
this.height = height; this.height = height;
this.washDirty = true;
this.resizePool(); this.resizePool();
} }
/** Nothing is drawn until this is called; a world with no weather yet stays untouched. */ /** Nothing is drawn until this is called; a world with no weather yet stays untouched. */
apply(state: SkyState, spec: PrecipitationSpec): void { apply(state: SkyState, spec: PrecipitationSpec, probe: WeatherProbe): 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;
}
this.state = state; this.state = state;
this.spec = spec; this.spec = spec;
this.probe = probe;
this.resizePool(); this.resizePool();
} }
/** Steps the falling particles and repaints. Called once per frame. */ /** Steps the falling particles and repaints. Called once per frame. */
advance(deltaMs: number): void { advance(deltaMs: number): void {
if (this.washDirty) { this.paintTint();
this.paintWash(); this.paintPatches();
this.washDirty = false;
}
this.stepLightning(deltaMs); this.stepLightning(deltaMs);
this.stepParticles(deltaMs); this.stepParticles(deltaMs);
this.paintParticles(); this.paintParticles();
@@ -106,11 +133,13 @@ export class WeatherLayer {
clear(): void { clear(): void {
this.particles.length = 0; this.particles.length = 0;
this.wash.clear(); this.tint.clear();
this.patches.clear();
this.flash.clear(); this.flash.clear();
this.drops.clear(); this.drops.clear();
this.state = CLEAR_SKY; this.state = CLEAR_SKY;
this.spec = NOTHING_FALLING; this.spec = NOTHING_FALLING;
this.probe = NO_WEATHER;
this.nextStrikeIn = 0; this.nextStrikeIn = 0;
this.flashRemaining = 0; this.flashRemaining = 0;
} }
@@ -120,23 +149,68 @@ export class WeatherLayer {
this.precipitation.destroy({ children: true }); this.precipitation.destroy({ children: true });
} }
private paintWash(): void { /** Time of day covers the whole map evenly, so it stays one rectangle. */
this.wash.clear(); 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; 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) { // Bleed past the edges so the blur does not fade the wash out at the border of the screen.
this.wash.rect(0, 0, this.width, this.height).fill({ color: tint, alpha: tintAlpha }); const bleed = Math.max(patchWidth, patchHeight);
}
// Lying snow goes on before the haze so fog still reads as fog over a white landscape. for (let row = 0; row < WASH_PATCHES; row++) {
if (snowCover > 0.001) { for (let column = 0; column < WASH_PATCHES; column++) {
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xeef3f8, alpha: snowCover * 0.5 }); const centreX = (column + 0.5) * patchWidth;
} const centreY = (row + 0.5) * patchHeight;
if (hazeAlpha > 0.001) { const snow = Math.min(this.probe.snow(centreX, centreY) / FULL_SNOW_COVER_MM, 1);
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xd7dce2, alpha: hazeAlpha }); 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 }); 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 { private resizePool(): void {
const target = this.targetCount(); 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 { private paintParticles(): void {
this.drops.clear(); this.drops.clear();
if (this.particles.length === 0) return; if (this.particles.length === 0) return;
if (this.spec.kind === 'dust') {
this.paintDust();
return;
}
if (this.spec.kind === 'snow') { if (this.spec.kind === 'snow') {
for (const particle of this.particles) { // Opacity carries the intensity, so the flakes are grouped into bands and filled once per band.
this.drops.circle(particle.x, particle.y, 1.1 * particle.scale); 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; 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 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) { 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.moveTo(particle.x, particle.y).lineTo(particle.x + (streak * slant), particle.y + streak);
} }
this.drops.stroke( this.drops.stroke({ width: 1.4, color: 0xc9a86a, alpha: 0.4 });
dust
? { width: 1.4, color: 0xc9a86a, alpha: 0.4 }
: { width: 1.1, color: 0xaec6dd, alpha: 0.55 },
);
} }
} }
@@ -258,6 +258,32 @@ public sealed class WorldSimulationTests
Assert.True(pressures > 1, "The field is uniform - the pressure systems are not being sampled."); Assert.True(pressures > 1, "The field is uniform - the pressure systems are not being sampled.");
} }
/// <summary>
/// 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.
/// </summary>
[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] [Fact]
public void Neighbouring_field_nodes_stay_close_together() public void Neighbouring_field_nodes_stay_close_together()
{ {