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() {