Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 8ad9834cd9 docs(changelog): add the #1718 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:26:13 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 63e1e599eb perf(styles): NVG and FLIR bloom in two passes and redraw at 15 Hz when idle
Night vision read the scene 122 times for each pixel and FLIR 108, almost
all of it in an 11x11 and a 9x9 bloom loop, and either style held the scene
at 60 renders a second while nothing else moved.

Both bloom weights are a row weight times a column weight, and the stages
read their input with nearest sampling, so a new bloomRows pass sums each
row once per texel and the main shader sums the columns: 23 reads for night
vision and 45 for FLIR, with the same picture. A style with passes is built
as a PostProcessStageComposite that answers to the style's name, enabled
and uniforms as the single stage did.

Styles that declare timeHz (these two, at 15) step their clock at that rate
and ask for one frame a step, so an idle scene is drawn 15 times a second
with them on. Crossfades and the CRT style are still drawn every frame.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:24:55 +01:00
16 changed files with 471 additions and 925 deletions

No files matched your search

+2 -5
View File
@@ -564,11 +564,8 @@ Global Forecast System (GFS). The `/api/wind` server-side proxy selects the
latest available 0.25° cycle, reads its `.idx` inventory, byte-range fetches only
the `UGRD`/`VGRD` 10 m GRIB2 messages, optionally adding `TMP` at 2 m or
`PRMSL` at mean sea level from the same run and forecast time. It decodes the
selected messages with ecCodes (WASM) and resamples them to a compact grid (1° by
default) on a worker thread, so a refresh does not stall other API routes, and
serves a manifest plus Float32 U/V and optional scalar values. The sampled wind
of the current forecast file is kept, so choosing a color field fetches only
that field. It is keyless, cached
selected messages with ecCodes (WASM), resamples to a compact grid (1° by
default), and serves a manifest plus Float32 U/V and optional scalar values. It is keyless, cached
per cycle for an hour, and is a **forecast, not an observation**: the particles
show model flow, not measured wind. The renderer bakes bounded curves through
the sampled field and animates their phase on the GPU, with a canvas fallback.
-4
View File
@@ -1,4 +0,0 @@
- The Wind server no longer blocks every other request while it decodes a
forecast. Decoding runs on a worker thread (up to 400 ms of blocking down to
about 4 ms), and choosing an overlay after wind has loaded fetches only the
overlay's field (mazy1998, #1717).
+4
View File
@@ -0,0 +1,4 @@
- NVG and FLIR redraw 15 times a second instead of 60 when nothing else is
moving, and their bloom reads a fifth to a half as many texels per pixel for
the same picture. Their grain now changes 15 times a second (mazy1998,
#1718).
+2 -5
View File
@@ -1,6 +1,6 @@
import { fetchGfsWind } from './wind/gfs.js';
import { fetchIfsWind } from './wind/ifs.js';
import { decodeWindGribInWorker } from './wind/decoder.js';
import { decodeWindGribMessage } from './wind/decode.js';
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact
@@ -11,7 +11,7 @@ const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable';
export function windProxy({
fetchImpl = fetch,
now = () => Date.now(),
decodeImpl = decodeWindGribInWorker,
decodeImpl = decodeWindGribMessage,
targetDx = 1,
ttlMs = 3600_000,
timeoutMs = 40_000,
@@ -22,8 +22,6 @@ export function windProxy({
const loadings = new Map();
const grids = new Map();
const attempts = new Map();
// Sampled U/V of the last forecast object per model (see loadWeatherSnapshot).
const windCache = new Map();
const unavailable = (model, overlay) => ({
manifest: {
model,
@@ -56,7 +54,6 @@ export function windProxy({
targetDx,
overlay,
signal: controller.signal,
windCache,
});
controller.signal.throwIfAborted();
const { grid, cycle } = value;
-6
View File
@@ -1,6 +0,0 @@
// Worker-thread entry for the wind provider, started by ./decoder.js. Node
// loads this file from disk, so it may only import files that ship beside it.
import { parentPort } from 'node:worker_threads';
import { serveWindDecodes } from './decode.js';
serveWindDecodes(parentPort);
-17
View File
@@ -1,5 +1,3 @@
import { sampleWindField } from './grid.js';
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
let eccodesPromise = null;
function loadEccodes() {
@@ -51,18 +49,3 @@ export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
eccodes.writeFile(file, new Uint8Array(0));
}
}
/**
* Worker side of ./decoder.js: decode each posted message, validate and sample
* it to the display grid, and transfer back only the samples.
*/
export function serveWindDecodes(port, decodeImpl = decodeWindGribMessage) {
port.on('message', async ({ id, bytes, targetDx }) => {
try {
const field = sampleWindField(await decodeImpl(bytes), targetDx);
port.postMessage({ id, field }, [field.samples.buffer]);
} catch (error) {
port.postMessage({ id, error: String(error?.message ?? error) });
}
});
}
-108
View File
@@ -1,108 +0,0 @@
import { Worker } from 'node:worker_threads';
const WORKER_URL = new URL('./decode-worker.js', import.meta.url);
const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
/**
* Decode GRIB messages on one lazily started worker thread.
*
* ecCodes decodes a 0.25° message synchronously (~1.04 M doubles), and
* validating and resampling those values is synchronous too, so on the server
* thread each refresh stalled every other API route. The worker does all three
* and transfers back only the display-grid samples.
*
* The worker is referenced only while a job is pending, so it never keeps the
* process alive. A crash, an exit or a job that outlives `jobTimeoutMs`
* rejects every pending job and retires the worker; the next decode starts a
* new one.
*
* @param {{workerUrl?: URL, jobTimeoutMs?: number}} [options]
* @returns {{decode: Function, close: Function}}
*/
export function createWindDecoder({
workerUrl = WORKER_URL,
jobTimeoutMs = 30_000,
} = {}) {
let active = null;
let nextId = 1;
const retire = (state, error) => {
if (active === state) active = null;
const jobs = [...state.pending.values()];
state.pending.clear();
for (const job of jobs) job.reject(error);
void state.worker.terminate();
};
const start = () => {
const state = {
worker: new Worker(workerUrl),
pending: new Map(),
};
state.worker.unref();
state.worker.on('message', ({ id, field, error }) => {
const job = state.pending.get(id);
if (!job) return; // Already rejected by an abort.
if (error === undefined) job.resolve(field);
else job.reject(new Error(error));
});
state.worker.on('error', (cause) =>
retire(state, new Error('Wind decode worker failed', { cause })),
);
state.worker.on('exit', () =>
retire(state, new Error('Wind decode worker exited')),
);
return state;
};
/**
* Decode one message and sample it to `targetDx` (see `sampleWindField`).
* An aborted `signal` rejects at once; the worker finishes the bounded job
* and its result is dropped.
*/
const decode = (buffer, { targetDx = 1, signal } = {}) =>
new Promise((resolve, reject) => {
if (buffer.byteLength > MAX_MESSAGE_BYTES)
throw new Error('Wind GRIB exceeds byte budget');
signal?.throwIfAborted();
// A private copy: transferring the caller's (possibly pooled) Buffer
// would detach memory it still owns.
const bytes = new Uint8Array(buffer.byteLength);
bytes.set(buffer);
const state = (active ??= start());
const id = nextId++;
const settle = (finish) => (value) => {
if (state.pending.get(id) === job) state.pending.delete(id);
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
if (state.pending.size === 0) state.worker.unref();
finish(value);
};
const job = { resolve: settle(resolve), reject: settle(reject) };
const abort = () => job.reject(signal.reason);
const timer = setTimeout(
() => retire(state, new Error('Wind decode timed out')),
jobTimeoutMs,
);
timer.unref();
state.pending.set(id, job);
state.worker.ref();
signal?.addEventListener('abort', abort, { once: true });
state.worker.postMessage({ id, bytes, targetDx }, [bytes.buffer]);
});
return {
decode,
/** Stop the worker; pending jobs reject and a later decode restarts it. */
close() {
if (active) retire(active, new Error('Wind decode worker closed'));
},
};
}
let shared = null;
/**
* Default decoder for the wind provider: one worker for the whole process,
* started by the first wind request, however many `windProxy` instances exist.
*/
export function decodeWindGribInWorker(buffer, options) {
shared ??= createWindDecoder();
return shared.decode(buffer, options);
}
+20 -86
View File
@@ -125,7 +125,6 @@ export async function loadOptionalScalar({
fetchImpl,
decodeImpl,
signal,
targetDx = 1,
timeoutMs = 12_000,
}) {
if (!range) return undefined;
@@ -141,13 +140,7 @@ export async function loadOptionalScalar({
fetchImpl,
signal: controller.signal,
});
// Sampling here keeps an unusable companion on the same wind-only path as
// an undecodable one. The decoder gets the signal so a timeout or a
// cancelled refresh stops waiting for a decode that runs off this thread.
const value = sampleWindField(
await decodeImpl(bytes, { targetDx, signal: controller.signal }),
targetDx,
);
const value = await decodeImpl(bytes);
controller.signal.throwIfAborted();
return value;
} catch {
@@ -165,80 +158,17 @@ import {
selectLatestGfsCycle,
nearestGfsStep,
} from './catalog.js';
import { decodeWindGribInWorker } from './decoder.js';
import {
resampleWeatherSnapshot,
sampleWindField,
weatherScalarMetadata,
} from './grid.js';
/**
* Load U, V and the optional companion from one forecast object and resample
* them to the display grid.
*
* `windCache` (a Map owned by one `windProxy`) retains the sampled U/V of the
* last object loaded per model. A later request for the same object and byte
* ranges, such as an overlay change or a refresh within the same forecast
* step, then fetches and decodes only the companion. It holds two Float64
* display grids per model (2 x 65,160 x 8 B = 1.04 MB at 1 degree) and is
* written only after the pair has passed every wind check.
*/
export async function loadWeatherSnapshot({
model,
url,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
}) {
const key = [
url,
`${ranges.u.start}-${ranges.u.end}`,
`${ranges.v.start}-${ranges.v.end}`,
targetDx,
].join('|');
const kept = windCache?.get(model);
const wind = kept?.key === key ? kept : null;
// All selected messages come from this one issue/valid-time object.
const load = async (range) =>
sampleWindField(
await decodeImpl(await fetchRange({ url, ...range, fetchImpl, signal }), {
targetDx,
signal,
}),
targetDx,
);
const [u, v, scalar] = await Promise.all([
wind ? wind.u : load(ranges.u),
wind ? wind.v : load(ranges.v),
loadOptionalScalar({
range: scalarRange,
url,
fetchImpl,
decodeImpl,
signal,
targetDx,
}),
]);
signal?.throwIfAborted();
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
if (!wind) windCache?.set(model, { key, u, v });
return fields;
}
import { decodeWindGribMessage } from './decode.js';
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
export async function fetchGfsWind({
fetchImpl = fetch,
now = () => Date.now(),
targetDx = 1,
decodeImpl = decodeWindGribInWorker,
decodeImpl = decodeWindGribMessage,
signal,
overlay = 'none',
windCache,
} = {}) {
weatherScalarMetadata(overlay);
const nowMs = now();
@@ -264,18 +194,22 @@ export async function fetchGfsWind({
/* A missing optional field does not discard valid wind. */
}
}
const fields = await loadWeatherSnapshot({
model: 'gfs',
url: base,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
});
// All selected messages come from this one issue/valid-time object.
const load = async (range) =>
decodeImpl(await fetchRange({ url: base, ...range, fetchImpl, signal }));
const [u, v, scalar] = await Promise.all([
load(ranges.u),
load(ranges.v),
loadOptionalScalar({
range: scalarRange,
url: base,
fetchImpl,
decodeImpl,
signal,
}),
]);
signal?.throwIfAborted();
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
+37 -129
View File
@@ -48,132 +48,37 @@ export function resampleWindGrid({
if (!Array.prototype.every.call(values, Number.isFinite))
throw new Error('Invalid wind grid values');
}
const geometry = { ni, nj, lo1, la1, di, dj };
const sampled = (values) => ({
...geometry,
dx,
dy,
samples: sampleValues(values, geometry, dx, dy),
});
return composeWindGrid({
u: sampled(u),
v: sampled(v),
...(scalar === undefined ? {} : { scalar: sampled(scalar) }),
scalarScale,
scalarOffset,
});
}
/** Display-grid shape for a target spacing, under the point budget. */
function displayShape(dx, dy) {
const nx = Math.round(360 / dx);
const ny = Math.round(180 / dy) + 1;
if (nx * ny > 1_000_000)
throw new Error('Resampled wind grid exceeds point budget');
return { nx, ny };
}
/**
* Bilinearly sample one validated source field onto the display grid. The
* result stays in doubles so a caller can scale it before narrowing to
* Float32, exactly as if it had sampled, scaled and stored in one step.
*/
function sampleValues(source, { ni, nj, lo1, la1, di, dj }, dx, dy) {
const { nx, ny } = displayShape(dx, dy);
const samples = new Float64Array(nx * ny);
for (let y = 0; y < ny; y += 1) {
const lat = 90 - y * dy;
const sy = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
const y0 = Math.floor(sy);
const outU = new Float32Array(nx * ny);
const outV = new Float32Array(nx * ny);
const outScalar = scalar === undefined ? null : new Float32Array(nx * ny);
const sample = (source, lon, lat) => {
const x = ((lon - lo1) / di + ni) % ni;
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
const x0 = Math.floor(x);
const x1 = (x0 + 1) % ni;
const y0 = Math.floor(y);
const y1 = Math.min(nj - 1, y0 + 1);
const fy = sy - y0;
for (let x = 0; x < nx; x += 1) {
const lon = x * dx;
const sx = ((lon - lo1) / di + ni) % ni;
const x0 = Math.floor(sx);
const x1 = (x0 + 1) % ni;
const fx = sx - x0;
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
const bottom =
source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
samples[y * nx + x] = top * (1 - fy) + bottom * fy;
}
}
return samples;
}
/**
* Validate one decoded GRIB field and reduce it to display-grid samples.
*
* This is the expensive per-message step after the decode (a 0.25° message is
* ~1.04 M doubles to check), so the decode worker runs it and transfers only
* the result. A field that already carries samples for `targetDx` passes
* through, which lets the same call normalise worker output, injected decoders
* and the retained wind of the current cycle.
*
* @param {object} field - `decodeWindGribMessage` output, or a sampled field.
* @param {number} [targetDx] - Display-grid spacing in degrees.
* @returns {{ni: number, nj: number, lo1: number, la1: number, di: number,
* dj: number, units: (string|undefined), dx: number, dy: number,
* samples: Float64Array}}
*/
export function sampleWindField(field, targetDx = 1) {
if (!field) throw new Error('Mismatched weather grid geometry');
if (
field.samples instanceof Float64Array &&
field.dx === targetDx &&
field.dy === targetDx
)
return field;
const { ni, nj, lo1, la1, di, dj, values, units } = field;
if (
!Number.isInteger(ni) ||
!Number.isInteger(nj) ||
ni < 1 ||
nj < 1 ||
ni * nj > 2_000_000 ||
values?.length !== ni * nj ||
![lo1, la1, di, dj, targetDx].every(Number.isFinite) ||
di <= 0 ||
dj <= 0 ||
targetDx < 0.25 ||
targetDx > 180
)
throw new Error('Invalid wind grid geometry');
if (!Array.prototype.every.call(values, Number.isFinite))
throw new Error('Invalid wind grid values');
const geometry = { ni, nj, lo1, la1, di, dj };
return {
...geometry,
units,
dx: targetDx,
dy: targetDx,
samples: sampleValues(values, geometry, targetDx, targetDx),
const fx = x - x0;
const fy = y - y0;
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
return top * (1 - fy) + bottom * fy;
};
}
/** Narrow sampled components that share one display grid to Float32. */
function composeWindGrid({ u, v, scalar, scalarScale = 1, scalarOffset = 0 }) {
const { dx, dy } = u;
const { nx, ny } = displayShape(dx, dy);
const components = [u, v, ...(scalar ? [scalar] : [])];
if (
components.some(
(field) =>
field.dx !== dx ||
field.dy !== dy ||
!(field.samples instanceof Float64Array) ||
field.samples.length !== nx * ny,
)
)
throw new Error('Invalid wind grid geometry');
const outU = new Float32Array(u.samples);
const outV = new Float32Array(v.samples);
let outScalar = null;
if (scalar) {
outScalar = new Float32Array(nx * ny);
for (let index = 0; index < outScalar.length; index += 1)
outScalar[index] = scalar.samples[index] * scalarScale + scalarOffset;
for (let y = 0; y < ny; y += 1) {
for (let x = 0; x < nx; x += 1) {
const index = y * nx + x;
const lon = x * dx;
const lat = 90 - y * dy;
outU[index] = sample(u, lon, lat);
outV[index] = sample(v, lon, lat);
if (outScalar)
outScalar[index] =
sample(scalar, lon, lat) * scalarScale + scalarOffset;
}
}
for (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
if (!values.every(Number.isFinite))
@@ -225,17 +130,20 @@ export function resampleWeatherFields({
throw new Error('Mismatched weather grid geometry');
if (metadata && scalar.units !== (overlay === 'temperature' ? 'K' : 'Pa'))
throw new Error('Unexpected weather scalar units');
// Raw fields are validated and sampled here; worker output and the retained
// wind of this cycle already are, so only the cheap narrowing is left.
const [sampledU, sampledV, sampledScalar] = fields.map((field) =>
sampleWindField(field, targetDx),
);
const grid = composeWindGrid({
u: sampledU,
v: sampledV,
const grid = resampleWindGrid({
u: u.values,
v: v.values,
ni: u.ni,
nj: u.nj,
lo1: u.lo1,
la1: u.la1,
di: u.di,
dj: u.dj,
dx: targetDx,
dy: targetDx,
...(metadata
? {
scalar: sampledScalar,
scalar: scalar.values,
scalarScale: overlay === 'pressure' ? 0.01 : 1,
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
}
+22 -17
View File
@@ -1,6 +1,6 @@
import { fetchText, loadWeatherSnapshot } from './gfs.js';
import { decodeWindGribInWorker } from './decoder.js';
import { weatherScalarMetadata } from './grid.js';
import { fetchText, fetchRange, loadOptionalScalar } from './gfs.js';
import { decodeWindGribMessage } from './decode.js';
import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js';
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
const IFS_BASE = 'https://data.ecmwf.int/forecasts';
@@ -110,10 +110,9 @@ export async function fetchIfsWind({
fetchImpl = fetch,
now = () => Date.now(),
targetDx = 1,
decodeImpl = decodeWindGribInWorker,
decodeImpl = decodeWindGribMessage,
signal,
overlay = 'none',
windCache,
} = {}) {
weatherScalarMetadata(overlay);
const nowMs = now();
@@ -138,18 +137,24 @@ export async function fetchIfsWind({
/* A missing optional field does not discard valid wind. */
}
}
const fields = await loadWeatherSnapshot({
model: 'ifs',
url: urls.grib,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
});
// All selected messages come from this one issue/valid-time object.
const load = async (range) =>
decodeImpl(
await fetchRange({ url: urls.grib, ...range, fetchImpl, signal }),
);
const [u, v, scalar] = await Promise.all([
load(ranges.u),
load(ranges.v),
loadOptionalScalar({
range: scalarRange,
url: urls.grib,
fetchImpl,
decodeImpl,
signal,
}),
]);
signal?.throwIfAborted();
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
const runIso = new Date(runMs).toISOString();
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
return {
-517
View File
@@ -1,517 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { windProxy } from '../../server/providers/wind.js';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
// Imported per test so a missing worker decoder fails those tests, not the file.
const createWindDecoder = async (options) =>
(await import('../../server/providers/wind/decoder.js')).createWindDecoder(
options,
);
const require = createRequire(import.meta.url);
/** Whether this thread has loaded ecCodes (a CommonJS package). */
const ecCodesLoadedHere = () =>
Object.keys(require.cache).some((file) => file.includes('eccodes-wasm'));
const DECODE_MODULE = new URL(
'../../server/providers/wind/decode.js',
import.meta.url,
).href;
/**
* A worker that runs the provider's real serving loop over a stand-in for
* ecCodes. The first byte selects the behaviour, the second a synchronous busy
* time in tens of milliseconds, which is what a WASM decode looks like to the
* thread that runs it.
*/
const FAKE_WORKER = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from 'node:worker_threads';
import { serveWindDecodes } from ${JSON.stringify(DECODE_MODULE)};
serveWindDecodes(parentPort, (bytes) => {
if (bytes[0] === 1) throw new Error('bad message');
if (bytes[0] === 2) {
setTimeout(() => { throw new Error('crash'); });
return new Promise(() => {});
}
if (bytes[0] === 3) process.exit(1);
if (bytes[0] === 4) for (;;);
const until = Date.now() + (bytes[1] || 0) * 10;
while (Date.now() < until);
return {
ni: 4, nj: 3, lo1: 0, la1: 90, di: 90, dj: 90,
values: Float64Array.from({ length: 12 }, (_, i) => bytes[0] + i / 8),
units: 'm/s',
};
});
`)}`,
);
/** Run `work` while a 5 ms heartbeat records the longest main-thread stall. */
async function longestStall(work) {
let last = performance.now();
let gap = 0;
const beat = setInterval(() => {
const t = performance.now();
gap = Math.max(gap, t - last);
last = t;
}, 5);
try {
const started = performance.now();
const value = await work();
return { value, gap, elapsed: performance.now() - started };
} finally {
clearInterval(beat);
}
}
function install(plugin) {
let handler;
plugin.configureServer({
middlewares: { use: (_route, fn) => (handler = fn) },
});
return async (url = '/') => {
const res = {
writeHead(status) {
this.statusCode = status;
},
end(body) {
this.body = body;
},
};
await handler({ url, method: 'GET' }, res);
return res;
};
}
/** Manifest (minus fetchedAt) and grid bytes exactly as a client receives them. */
async function served(request, url) {
const { fetchedAt, ...manifest } = JSON.parse((await request(url)).body);
const grid = manifest.gridUrl
? (await request(manifest.gridUrl.replace('/api/wind', ''))).body
: null;
return { manifest, grid };
}
/** One GRIB2 message on a regular lat/lon grid with 16-bit simple packing. */
function syntheticGrib({ category, number, surface, level, values }) {
const [ni, nj] = [8, 5];
const u8 = (...bytes) => Buffer.from(bytes);
const u16 = (value) => {
const out = Buffer.alloc(2);
out.writeUInt16BE(value);
return out;
};
const u32 = (value) => {
const out = Buffer.alloc(4);
out.writeUInt32BE(value >>> 0);
return out;
};
const section = (id, ...parts) => {
const body = Buffer.concat(parts);
return Buffer.concat([u32(body.length + 5), u8(id), body]);
};
const reference = Buffer.alloc(4);
reference.writeFloatBE(Math.min(...values) * 10);
const packed = Buffer.alloc(values.length * 2);
values.forEach((value, index) =>
packed.writeUInt16BE(
Math.round(value * 10 - reference.readFloatBE()),
index * 2,
),
);
const rest = Buffer.concat([
section(1, u16(7), u16(0), u8(2, 1, 1), u16(2026), u8(9, 14, 6, 0, 0, 0, 1)),
section(
3,
u8(0),
u32(ni * nj),
u8(0, 0),
u16(0), // template 3.0, regular lat/lon
u8(6, 0),
u32(0),
u8(0),
u32(0),
u8(0),
u32(0),
u32(ni),
u32(nj),
u32(0),
u32(0xffffffff),
u32(90e6), // first point 90N 0E, last point 90S 315E, 45 degree steps
u32(0),
u8(48),
u32(90e6 | 0x80000000),
u32(315e6),
u32(45e6),
u32(45e6),
u8(0),
),
section(
4,
u16(0),
u16(0), // template 4.0
u8(category, number, 2, 0, 0),
u16(0),
u8(0, 1),
u32(0),
u8(surface, 0),
u32(level),
u8(255, 255),
u32(0xffffffff),
),
// Template 5.0: value = (reference + packed) / 10.
section(5, u32(ni * nj), u16(0), reference, u16(0), u16(1), u8(16, 0)),
section(6, u8(255)),
section(7, packed),
Buffer.from('7777'),
]);
return Buffer.concat([
Buffer.from('GRIB'),
u8(0, 0, 0, 2),
u32(0),
u32(16 + rest.length),
rest,
]);
}
const field = (seed, scale) =>
Array.from({ length: 40 }, (_, i) => seed + Math.sin(i * 1.7) * scale);
const MESSAGES = {
u: syntheticGrib({
category: 2,
number: 2,
surface: 103,
level: 10,
values: field(3, 14),
}),
v: syntheticGrib({
category: 2,
number: 3,
surface: 103,
level: 10,
values: field(-2, 9),
}),
tmp: syntheticGrib({
category: 0,
number: 0,
surface: 103,
level: 2,
values: field(281, 25),
}),
prmsl: syntheticGrib({
category: 3,
number: 1,
surface: 101,
level: 0,
values: field(101300, 2400),
}),
};
/** A GFS object holding the four synthetic messages, plus its `.idx`. */
function syntheticUpstream({ log = [], corrupt = () => false } = {}) {
const order = ['u', 'v', 'tmp', 'prmsl'];
const offsets = [0];
for (const name of order) offsets.push(offsets.at(-1) + MESSAGES[name].length);
const file = Buffer.concat(order.map((name) => MESSAGES[name]));
const idx = [
`1:${offsets[0]}:d=2026091406:UGRD:10 m above ground:6 hour fcst:`,
`2:${offsets[1]}:d=2026091406:VGRD:10 m above ground:6 hour fcst:`,
`3:${offsets[2]}:d=2026091406:TMP:2 m above ground:6 hour fcst:`,
`4:${offsets[3]}:d=2026091406:PRMSL:mean sea level:6 hour fcst:`,
`5:${offsets[4]}:d=2026091406:HGT:surface:6 hour fcst:`,
].join('\n');
const ranges = Object.fromEntries(
order.map((name, i) => [name, `bytes=${offsets[i]}-${offsets[i + 1] - 1}`]),
);
const fetchImpl = async (url, options = {}) => {
const range = options.headers?.Range;
log.push(range ?? 'idx');
if (!range) return new Response(idx, { status: 200 });
const [, start, end] = /bytes=(\d+)-(\d+)/.exec(range);
const name = order.find((key) => ranges[key] === range);
const body = Buffer.from(file.subarray(Number(start), Number(end) + 1));
if (corrupt(name)) body.fill(0);
return new Response(body, { status: 206 });
};
return { fetchImpl, ranges, log };
}
const NOON = Date.UTC(2026, 8, 14, 12);
const KEYS = ['/', '/?overlay=temperature', '/?overlay=pressure'];
test('a blocking decode runs off the server thread', async (t) => {
const decoder = await createWindDecoder({ workerUrl: FAKE_WORKER });
t.after(() => decoder.close());
// Warm the worker so its start-up is not counted, then block it for 300 ms.
await decoder.decode(Uint8Array.of(5, 0));
const { value, gap, elapsed } = await longestStall(() =>
decoder.decode(Uint8Array.of(7, 30), { targetDx: 90 }),
);
assert.ok(elapsed >= 280, `decode took ${elapsed} ms`);
assert.ok(gap < 150, `server thread stalled for ${gap} ms`);
// The worker validated and sampled the field; only the samples came back.
assert.equal(value.values, undefined);
assert.equal(value.dx, 90);
assert.ok(value.samples instanceof Float64Array);
assert.equal(value.samples.length, 4 * 3);
assert.equal(value.samples[0], 7);
});
test('the default wind provider decodes in the worker and matches the synchronous path byte for byte', async () => {
const outputs = [];
for (const options of [
{}, // default: ecCodes in the worker
{ decodeImpl: (bytes) => decodeWindGribMessage(bytes) }, // on this thread
]) {
const request = install(
windProxy({
now: () => NOON,
targetDx: 30,
fetchImpl: syntheticUpstream().fetchImpl,
...options,
}),
);
const out = [];
for (const key of KEYS) out.push(await served(request, key));
// The default path never brings ecCodes into this thread; the injected
// synchronous decoder does, which also proves the probe can see it.
assert.equal(ecCodesLoadedHere(), outputs.length === 1);
outputs.push(out);
}
const [worker, sync] = outputs;
for (let i = 0; i < KEYS.length; i += 1) {
assert.equal(worker[i].manifest.unavailable, false, KEYS[i]);
assert.deepEqual(worker[i].manifest, sync[i].manifest, KEYS[i]);
assert.ok(worker[i].grid.equals(sync[i].grid), `${KEYS[i]} grid bytes`);
}
assert.equal(worker[0].grid.length, 12 * 7 * 4 * 2);
assert.equal(worker[1].grid.length, 12 * 7 * 4 * 3);
assert.equal(worker[1].manifest.scalar.kind, 'temperature');
assert.equal(worker[2].manifest.scalar.kind, 'pressure');
});
test('partial and failed refreshes match the synchronous path through the worker', async () => {
for (const [bad, expected] of [
['tmp', { scalarError: 'Temperature field unavailable' }],
['u', { unavailable: true }],
]) {
const outputs = [];
for (const options of [
{},
{ decodeImpl: (bytes) => decodeWindGribMessage(bytes) },
]) {
const request = install(
windProxy({
now: () => NOON,
targetDx: 30,
fetchImpl: syntheticUpstream({ corrupt: (name) => name === bad })
.fetchImpl,
...options,
}),
);
outputs.push(await served(request, '/?overlay=temperature'));
}
const [worker, sync] = outputs;
assert.deepEqual(worker.manifest, sync.manifest);
for (const [name, value] of Object.entries(expected))
assert.equal(worker.manifest[name], value);
if (sync.grid) assert.ok(worker.grid.equals(sync.grid));
else assert.equal(worker.grid, null);
}
});
test('an overlay change within the same forecast object fetches and decodes only the scalar', async () => {
let clock = NOON;
let decodes = 0;
const upstream = syntheticUpstream();
const decodeImpl = (bytes) => {
decodes += 1;
return decodeWindGribMessage(bytes);
};
const request = install(
windProxy({
now: () => clock,
targetDx: 30,
fetchImpl: upstream.fetchImpl,
decodeImpl,
}),
);
const none = await served(request, '/');
assert.deepEqual(upstream.log, ['idx', upstream.ranges.u, upstream.ranges.v]);
assert.equal(decodes, 2);
upstream.log.length = 0;
const temperature = await served(request, '/?overlay=temperature');
assert.deepEqual(upstream.log, ['idx', upstream.ranges.tmp]);
assert.equal(decodes, 3);
upstream.log.length = 0;
const pressure = await served(request, '/?overlay=pressure');
assert.deepEqual(upstream.log, ['idx', upstream.ranges.prmsl]);
assert.equal(decodes, 4);
// Reused wind gives exactly what a server that fetched everything gives.
const fresh = install(
windProxy({
now: () => NOON,
targetDx: 30,
fetchImpl: syntheticUpstream().fetchImpl,
decodeImpl: (bytes) => decodeWindGribMessage(bytes),
}),
);
for (const [url, reused] of [
['/?overlay=temperature', temperature],
['/?overlay=pressure', pressure],
]) {
const full = await served(fresh, url);
assert.deepEqual(reused.manifest, full.manifest);
assert.ok(reused.grid.equals(full.grid));
}
assert.ok(
temperature.grid.subarray(0, none.grid.length).equals(none.grid),
'the overlay grid starts with the same U and V',
);
// The next forecast step is a different object: wind is fetched again.
clock += 3600_000;
upstream.log.length = 0;
await served(request, '/?overlay=temperature');
assert.deepEqual(upstream.log, [
'idx',
upstream.ranges.u,
upstream.ranges.v,
upstream.ranges.tmp,
]);
});
test('wind that failed its checks is never retained for a later overlay', async () => {
const upstream = syntheticUpstream({ corrupt: (name) => name === 'v' });
const request = install(
windProxy({
now: () => NOON,
targetDx: 30,
fetchImpl: upstream.fetchImpl,
decodeImpl: (bytes) => decodeWindGribMessage(bytes),
}),
);
assert.equal((await served(request, '/')).manifest.unavailable, true);
upstream.log.length = 0;
const overlay = await served(request, '/?overlay=temperature');
assert.equal(overlay.manifest.unavailable, true);
assert.ok(upstream.log.includes(upstream.ranges.u), 'U is fetched again');
});
test('a throwing, crashing, exiting or hung decode rejects its job and the next decode recovers', async (t) => {
const decoder = await createWindDecoder({
workerUrl: FAKE_WORKER,
jobTimeoutMs: 400,
});
t.after(() => decoder.close());
const ok = async () => {
const value = await decoder.decode(Uint8Array.of(9, 0), { targetDx: 90 });
assert.equal(value.samples[0], 9);
};
await ok();
// A decode error is reported for that job only; the worker stays up.
const [thrown, sibling] = await Promise.allSettled([
decoder.decode(Uint8Array.of(1, 0)),
decoder.decode(Uint8Array.of(9, 0), { targetDx: 90 }),
]);
assert.match(thrown.reason.message, /bad message/);
assert.equal(sibling.value.samples[0], 9);
// An uncaught error or an exit retires the worker; the next decode restarts it.
for (const [mode, pattern] of [
[2, /worker failed/],
[3, /worker exited/],
]) {
const results = await Promise.allSettled([
decoder.decode(Uint8Array.of(mode, 0)),
decoder.decode(Uint8Array.of(9, 0)),
]);
assert.match(results[0].reason.message, pattern);
// A job queued behind an exit dies with the worker; one queued behind an
// asynchronous crash may still finish first. Either way it settles.
if (mode === 3) assert.equal(results[1].status, 'rejected');
await ok();
}
// A decode that never returns is cut off instead of hanging its request.
await assert.rejects(decoder.decode(Uint8Array.of(4, 0)), /timed out/);
await ok();
// Cancelling stops the wait at once; the budget is checked before any copy.
const controller = new AbortController();
const slow = decoder.decode(Uint8Array.of(9, 20), {
signal: controller.signal,
});
controller.abort(new Error('cancelled'));
await assert.rejects(slow, /cancelled/);
await assert.rejects(
decoder.decode(new Uint8Array(8 * 1024 * 1024 + 1)),
/byte budget/,
);
await ok();
});
test('a worker crash during a refresh answers the request and the next refresh recovers', async (t) => {
const decoder = await createWindDecoder({ workerUrl: FAKE_WORKER });
t.after(() => decoder.close());
let clock = NOON;
let crash = true;
const request = install(
windProxy({
now: () => clock,
targetDx: 90,
decodeImpl: decoder.decode,
fetchImpl: async (url, options = {}) => {
if (!options.headers?.Range)
return new Response(
[
'1:0:d=2026091406:UGRD:10 m above ground:anl:',
'2:2:d=2026091406:VGRD:10 m above ground:anl:',
'3:4:d=2026091406:HGT:surface:anl:',
].join('\n'),
);
return new Response(Uint8Array.of(crash ? 3 : 6, 0), { status: 206 });
},
}),
);
const failed = await served(request, '/');
assert.equal(failed.manifest.unavailable, true);
crash = false;
clock += 60_000; // past the failure backoff
const recovered = await served(request, '/');
assert.equal(recovered.manifest.unavailable, false);
assert.equal(recovered.grid.length, 4 * 3 * 4 * 2);
});
test('closing one decoder rejects its jobs and leaves another working', async (t) => {
const a = await createWindDecoder({ workerUrl: FAKE_WORKER });
const b = await createWindDecoder({ workerUrl: FAKE_WORKER });
t.after(() => b.close());
const pending = a.decode(Uint8Array.of(9, 10));
a.close();
await assert.rejects(pending, /closed/);
assert.equal((await b.decode(Uint8Array.of(9, 0))).samples[0], 9);
});
test('an idle decode worker does not keep the process alive', () => {
const decoderUrl = new URL(
'../../server/providers/wind/decoder.js',
import.meta.url,
).href;
const script = `
import(${JSON.stringify(decoderUrl)}).then(async ({ createWindDecoder }) => {
const decoder = await createWindDecoder({ workerUrl: new URL(${JSON.stringify(FAKE_WORKER.href)}) });
const field = await decoder.decode(Uint8Array.of(9, 0));
console.log('decoded', field.samples.length);
});
`;
const child = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8',
timeout: 20_000,
});
assert.equal(child.status, 0, child.stderr);
assert.equal(child.stdout.trim(), 'decoded 65160');
});
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nightVisionShader } from './surveillance.js';
import { thermalShader } from './thermal.js';
/**
* Texture reads one pixel costs: every `texture(` call, multiplied by the
* trip counts of the `for (int v = a; v <= b; v++)` loops around it.
*/
function textureReads(source) {
const token =
/for \(int \w+ = (-?\d+); \w+ <= (-?\d+); \w+\+\+\) \{|texture\(|[{}]/g;
const scopes = [1];
let reads = 0;
for (const match of source.matchAll(token)) {
const inside = scopes.at(-1);
if (match[0] === 'texture(') reads += inside;
else if (match[0] === '}') scopes.pop();
else if (match[0] === '{') scopes.push(inside);
else scopes.push(inside * (Number(match[2]) - Number(match[1]) + 1));
}
assert.deepEqual(scopes, [1], 'braces balance');
return reads;
}
function readsPerPixel(shader) {
return [shader, ...(shader.passes || [])].reduce(
(total, pass) => total + textureReads(pass.fragmentShader),
0,
);
}
test('the read counter multiplies nested loop trips', () => {
const nested = `void main() {
for (int y = -5; y <= 5; y++) {
for (int x = -5; x <= 5; x++) { a += texture(t, uv).r; }
}
b = texture(t, uv);
}`;
assert.equal(textureReads(nested), 122);
});
test('night vision blooms with two 11-sample sums, not 121 samples', () => {
// 11 in the row pass, 11 column sums and the pixel itself.
assert.equal(readsPerPixel(nightVisionShader), 23);
});
test('FLIR blooms with two 9-sample sums, not 81 samples', () => {
// 9 in the row pass, 9 column sums, the 5x5 soft blur, the pixel itself
// and the centre sample behind the temperature readout.
assert.equal(readsPerPixel(thermalShader), 45);
});
for (const shader of [nightVisionShader, thermalShader])
test(`${shader.name}: each pass is fed the uniforms it declares`, () => {
assert.ok(shader.passes.length > 0);
for (const pass of shader.passes) {
const declared = [
...pass.fragmentShader.matchAll(/uniform float (\w+);/g),
].map((match) => match[1]);
assert.deepEqual(declared, pass.uniforms);
for (const uniform of pass.uniforms)
assert.ok(uniform in shader.uniforms, `${uniform} is a style uniform`);
assert.match(
shader.fragmentShader,
new RegExp(`uniform sampler2D ${pass.texture};`),
);
assert.match(
shader.fragmentShader,
new RegExp(`texture\\(${pass.texture},`),
);
}
});
test('the row and column weights multiply back to the single-pass weight', () => {
// Night vision: exp(-(x² + y²) / 18). FLIR: exp(-0.5 (x² + y²) / 8).
const cases = [
[
5,
(x, y) => Math.exp(-(x * x + y * y) / 18),
(v) => Math.exp(-(v * v) / 18),
],
[
4,
(x, y) => Math.exp((-0.5 * (x * x + y * y)) / 8),
(v) => Math.exp((-0.5 * v * v) / 8),
],
];
for (const [reach, whole, part] of cases)
for (let y = -reach; y <= reach; y++)
for (let x = -reach; x <= reach; x++)
assert.ok(Math.abs(whole(x, y) - part(x) * part(y)) < 1e-12);
});
+54 -8
View File
@@ -11,8 +11,55 @@ import { msg } from '../i18n/marker.js';
* scanlineStr (0-1) — scanline intensity (kept for compatibility)
* pixelation (1-6) — intensifier tube resolution pixelation
*/
/**
* The bloom is an 11x11 Gaussian over samples four texels apart. Its weight
* exp(-(x² + y²) / 18) is a product of a row weight and a column weight, and
* the stage reads its input with nearest sampling, so every sample is a whole
* texel away from the first. This pass therefore sums the eleven samples of
* each row once per texel and the main pass sums eleven of those: 22 reads
* for each pixel where one pass took 121, with the same result.
*
* The row sum is written across two 8-bit channels (whole 255ths in red, the
* remainder in green) so the stage keeps the default render target.
*/
const NVG_BLOOM_ROWS_SHADER = /* glsl */ `
uniform sampler2D colorTexture;
uniform vec2 colorTextureDimensions;
uniform float gain;
in vec2 v_textureCoordinates;
void main() {
vec2 texel = 1.0 / colorTextureDimensions;
float gainLevel = mix(0.8, 2.5, gain);
float rowSum = 0.0;
float rowW = 0.0;
for (int x = -5; x <= 5; x++) {
vec2 offset = vec2(float(x), 0.0) * texel * 4.0;
float sLuma = dot(texture(colorTexture, v_textureCoordinates + offset).rgb, vec3(0.299, 0.587, 0.114));
float bright = smoothstep(0.4, 0.9, sLuma * gainLevel);
float w = exp(-float(x * x) / 18.0);
rowSum += bright * w;
rowW += w;
}
float scaled = rowSum / rowW * 255.0;
out_FragColor = vec4(floor(scaled) / 255.0, fract(scaled), 0.0, 1.0);
}
`;
export const nightVisionShader = {
name: 'surveillance',
// The film grain, scanline drift and clock digits step at this rate, and
// the scene is redrawn at it while the style is the only thing moving.
timeHz: 15,
passes: [
{
name: 'bloomRows',
texture: 'bloomRowsTexture',
uniforms: ['gain'],
fragmentShader: NVG_BLOOM_ROWS_SHADER,
},
],
uniforms: {
gain: { default: 0.55, min: 0, max: 1, label: msg('Gain') },
bloom: { default: 0.3, min: 0, max: 1, label: msg('Bloom') },
@@ -21,6 +68,7 @@ export const nightVisionShader = {
},
fragmentShader: /* glsl */ `
uniform sampler2D colorTexture;
uniform sampler2D bloomRowsTexture;
uniform vec2 colorTextureDimensions;
uniform float intensity;
uniform float time;
@@ -189,17 +237,15 @@ export const nightVisionShader = {
// ── Intensifier tube bloom (THE key NVG visual) ─────
// Bloom around bright sources — wider kernel for realistic halos
// Rows were summed by the bloomRows pass; the columns are summed here.
float bloomAccum = 0.0;
float bloomW = 0.0;
for (int y = -5; y <= 5; y++) {
for (int x = -5; x <= 5; x++) {
vec2 offset = vec2(float(x), float(y)) * texel * 4.0;
float sLuma = dot(texture(colorTexture, distUV + offset).rgb, vec3(0.299, 0.587, 0.114));
float bright = smoothstep(0.4, 0.9, sLuma * gainLevel);
float w = exp(-float(x * x + y * y) / 18.0);
bloomAccum += bright * w;
bloomW += w;
}
vec2 offset = vec2(0.0, float(y)) * texel * 4.0;
vec2 rows = texture(bloomRowsTexture, distUV + offset).rg;
float w = exp(-float(y * y) / 18.0);
bloomAccum += (rows.r + rows.g / 255.0) * w;
bloomW += w;
}
bloomAccum /= bloomW;
+54 -12
View File
@@ -13,8 +13,55 @@ import { msg } from '../i18n/marker.js';
* pixelation (1-6) — sensor resolution pixelation grid size
* palette (0-1) — 0 = monochrome FLIR, 1 = Ironbow "Predator" color ramp
*/
/**
* The hot-spot bloom is a 9x9 Gaussian over samples three texels apart. Its
* weight is a product of a row weight and a column weight, and the stage
* reads its input with nearest sampling, so this pass sums the nine samples
* of each row once per texel and the main pass sums nine of those: 18 reads
* for each pixel where one pass took 81, with the same result. The row sum is
* written across two 8-bit channels, as in the night-vision bloom.
*/
const FLIR_BLOOM_ROWS_SHADER = /* glsl */ `
uniform sampler2D colorTexture;
uniform vec2 colorTextureDimensions;
uniform float sensitivity;
uniform float mode;
in vec2 v_textureCoordinates;
void main() {
vec2 texel = 1.0 / colorTextureDimensions;
float sens = mix(0.25, 1.0, sensitivity);
float isBlackHot = step(0.5, mode);
float rowSum = 0.0;
float rowW = 0.0;
for (int x = -4; x <= 4; x++) {
vec2 offset = vec2(float(x), 0.0) * texel * 3.0;
float sLuma = dot(texture(colorTexture, v_textureCoordinates + offset).rgb, vec3(0.299, 0.587, 0.114));
float sMapped = clamp((sLuma - (0.5 - sens * 0.5)) / sens, 0.0, 1.0);
float sFinal = mix(sMapped, 1.0 - sMapped, isBlackHot);
float w = exp(-0.5 * float(x * x) / 8.0);
rowSum += smoothstep(0.6, 1.0, sFinal) * w;
rowW += w;
}
float scaled = rowSum / rowW * 255.0;
out_FragColor = vec4(floor(scaled) / 255.0, fract(scaled), 0.0, 1.0);
}
`;
export const thermalShader = {
name: 'thermal',
// The drifting noise and the frame counter step at this rate, and the scene
// is redrawn at it while the style is the only thing moving.
timeHz: 15,
passes: [
{
name: 'bloomRows',
texture: 'bloomRowsTexture',
uniforms: ['sensitivity', 'mode'],
fragmentShader: FLIR_BLOOM_ROWS_SHADER,
},
],
uniforms: {
sensitivity: { default: 0.75, min: 0, max: 1, label: msg('Sensitivity') },
bloom: { default: 0.65, min: 0, max: 1, label: msg('Bloom') },
@@ -24,6 +71,7 @@ export const thermalShader = {
},
fragmentShader: /* glsl */ `
uniform sampler2D colorTexture;
uniform sampler2D bloomRowsTexture;
uniform vec2 colorTextureDimensions;
uniform float intensity;
uniform float time;
@@ -209,21 +257,15 @@ export const thermalShader = {
// ── Hot-spot bloom/bleed ────────────────────────────
// Sample a wider area for bloom on bright spots
// Rows were summed by the bloomRows pass; the columns are summed here.
float bloomSample = 0.0;
float bloomWeight = 0.0;
for (int y = -4; y <= 4; y++) {
for (int x = -4; x <= 4; x++) {
vec2 offset = vec2(float(x), float(y)) * texel * 3.0;
float sLuma = dot(texture(colorTexture, uv + offset).rgb, vec3(0.299, 0.587, 0.114));
float sMapped = clamp((sLuma - (0.5 - sens * 0.5)) / sens, 0.0, 1.0);
float sFinal = mix(sMapped, 1.0 - sMapped, isBlackHot);
float w = exp(-0.5 * float(x * x + y * y) / 8.0);
// Only bloom the "hot" pixels (bright in WHOT, dark values in BHOT...
// but since we already inverted, just bloom high values)
float hotness = smoothstep(0.6, 1.0, sFinal);
bloomSample += hotness * w;
bloomWeight += w;
}
vec2 offset = vec2(0.0, float(y)) * texel * 3.0;
vec2 rows = texture(bloomRowsTexture, uv + offset).rg;
float w = exp(-0.5 * float(y * y) / 8.0);
bloomSample += (rows.r + rows.g / 255.0) * w;
bloomWeight += w;
}
bloomSample /= bloomWeight;
thermalColor += bloomSample * bloom * 0.8;
+76 -10
View File
@@ -1,4 +1,4 @@
import { PostProcessStage } from 'cesium';
import { PostProcessStage, PostProcessStageComposite } from 'cesium';
import {
bloomStrengthFromIntensity,
clampBloomIntensity,
@@ -26,6 +26,7 @@ export class VisualEffects {
now = () => performance.now(),
wallNow = () => Date.now(),
createStage = (options) => new PostProcessStage(options),
createComposite = (options) => new PostProcessStageComposite(options),
}) {
this.viewer = viewer;
this.requestRender = requestRender;
@@ -36,6 +37,7 @@ export class VisualEffects {
this.now = now;
this.wallNow = wallNow;
this.createStage = createStage;
this.createComposite = createComposite;
this.stages = {};
this.transitions = new Map();
this.startTime = wallNow();
@@ -60,11 +62,11 @@ export class VisualEffects {
uniforms.time = 0.0;
for (const [name, meta] of Object.entries(shader.uniforms || {}))
uniforms[name] = meta.default;
const stage = this.createStage({
name: `godsEyeView_${name}`,
fragmentShader: shader.fragmentShader,
const stage = this.createStyleStage(
`godsEyeView_${name}`,
shader,
uniforms,
});
);
stage.enabled = false;
this.viewer.scene.postProcessStages.add(stage);
this.stages[name] = stage;
@@ -72,6 +74,57 @@ export class VisualEffects {
this.stageEntries = Object.entries(this.stages);
}
/**
* One stage for a single-pass style. A style with `passes` becomes a
* composite: each pass reads the scene, the main shader reads the scene and
* each pass's output, and the composite answers to the style's name,
* `enabled` and `uniforms` as a single stage would.
*
* Cesium replaces a stage's `uniforms` object on its first frame, so the
* composite's uniforms and the passes' read the main stage's current object
* each time rather than the one it was created with.
*/
createStyleStage(name, shader, uniforms) {
if (!shader.passes?.length)
return this.createStage({
name,
fragmentShader: shader.fragmentShader,
uniforms,
});
let main = null;
const shared = {};
for (const uniform of Object.keys(uniforms))
Object.defineProperty(shared, uniform, {
enumerable: true,
get: () => main.uniforms[uniform],
set: (value) => {
main.uniforms[uniform] = value;
},
});
const passes = shader.passes.map((pass) => {
const passName = `${name}_${pass.name}`;
uniforms[pass.texture] = passName;
return this.createStage({
name: passName,
fragmentShader: pass.fragmentShader,
uniforms: Object.fromEntries(
pass.uniforms.map((uniform) => [uniform, () => shared[uniform]]),
),
});
});
main = this.createStage({
name: `${name}_main`,
fragmentShader: shader.fragmentShader,
uniforms,
});
return this.createComposite({
name,
stages: [...passes, main],
inputPreviousStageTexture: false,
uniforms: shared,
});
}
initPostProcess(sharpenIntensity = this.sharpenIntensity) {
if (this.stopped || this.sharpenStage) return;
this.bloomStage = this.viewer.scene.postProcessStages.bloom;
@@ -213,16 +266,29 @@ export class VisualEffects {
this.transitions.delete(name);
}
}
// A style with `timeHz` moves its clock in steps and asks for one frame
// a step; any other animated style is drawn every frame.
let animatedStageVisible = false;
for (const [, stage] of this.stageEntries) {
let everyFrame = this.transitions.size > 0;
let stepped = false;
for (const [name, stage] of this.stageEntries) {
if (stage.enabled && stage.uniforms.time !== undefined) {
stage.uniforms.time = elapsedSec;
if (stage.uniforms.intensity > 0.001) animatedStageVisible = true;
const hz = STYLES[name].timeHz;
const time = hz ? Math.floor(elapsedSec * hz) / hz : elapsedSec;
if (stage.uniforms.intensity > 0.001) {
animatedStageVisible = true;
if (!hz) everyFrame = true;
else if (time !== stage.uniforms.time) stepped = true;
}
stage.uniforms.time = time;
}
}
const needed = this.transitions.size > 0 || animatedStageVisible;
if (needed) this.holdRender('style-anim');
else this.releaseRender('style-anim');
if (everyFrame) this.holdRender('style-anim');
else {
this.releaseRender('style-anim');
if (stepped) this.requestRender('style-anim');
}
this.frameId = needed ? this.requestFrame(update) : null;
};
this.frameId = this.requestFrame(update);
+107 -1
View File
@@ -8,7 +8,7 @@ import {
MILITARY_DETECTION_PRESET,
} from './visualPresets.js';
function fixture() {
function fixture(overrides = {}) {
let time = 0;
let next = 0;
const frames = new Map();
@@ -52,6 +52,7 @@ function fixture() {
now: () => time,
wallNow: () => 10_000 + time,
createStage: (options) => ({ ...options, enabled: true }),
...overrides,
});
function tick(value) {
time = value;
@@ -149,6 +150,111 @@ test('only visible animated stages keep the animation clock and render hold aliv
f.effects.destroy();
});
test('night vision and FLIR step their clock at 15 Hz and ask for one frame a step', () => {
for (const name of ['surveillance', 'thermal']) {
const f = fixture();
f.effects.initStyles();
const stage = f.effects.stages[name];
f.effects.setStageIntensity(stage, 1);
const steps = () =>
f.requests.filter((reason) => reason === 'style-anim').length;
f.tick(1000);
assert.equal(stage.uniforms.time, 1);
assert.equal(f.holds.size, 0, 'no continuous render while idle');
assert.equal(steps(), 1);
assert.equal(f.frames.size, 1, 'the clock keeps running');
for (const time of [1016, 1033, 1050]) f.tick(time);
assert.equal(stage.uniforms.time, 1, 'the clock holds between steps');
assert.equal(steps(), 1, 'no frame is asked for between steps');
f.tick(1067);
assert.equal(stage.uniforms.time, 16 / 15);
assert.equal(steps(), 2);
f.tick(2000);
assert.equal(stage.uniforms.time, 2);
assert.equal(steps(), 3);
f.effects.setStageIntensity(stage, 0);
f.tick(2100);
assert.equal(f.frames.size, 0);
f.effects.destroy();
}
});
test('a crossfade into a stepped style is still drawn every frame', () => {
const f = fixture();
f.effects.initStyles();
f.effects.startTransition('thermal', 0, 1);
f.tick(250);
assert.equal(f.holds.has('style-anim'), true);
f.tick(500);
assert.equal(f.effects.stages.thermal.uniforms.intensity, 1);
f.tick(520);
assert.equal(f.holds.size, 0, 'the hold ends with the crossfade');
assert.equal(f.frames.size, 1);
f.effects.destroy();
});
test('a style with passes is one composite whose uniforms reach every pass', () => {
const created = [];
const composites = [];
const f = fixture({
createStage: (options) => {
const stage = { ...options, enabled: true };
created.push(stage);
return stage;
},
createComposite: (options) => {
const composite = {
...options,
get enabled() {
return options.stages[0].enabled;
},
set enabled(value) {
for (const stage of options.stages) stage.enabled = value;
},
};
composites.push(composite);
return composite;
},
});
f.effects.initStyles();
assert.deepEqual(
composites.map((composite) => composite.name),
['godsEyeView_surveillance', 'godsEyeView_thermal'],
);
assert.equal(f.stages.size, 5, 'one pipeline entry per style');
const composite = f.effects.stages.surveillance;
const [rows, main] = composite.stages;
assert.equal(composite.inputPreviousStageTexture, false);
assert.equal(rows.name, 'godsEyeView_surveillance_bloomRows');
assert.equal(main.uniforms.bloomRowsTexture, rows.name);
assert.deepEqual(Object.keys(rows.uniforms), ['gain']);
assert.deepEqual(Object.keys(composite.uniforms), [
'intensity',
'time',
'gain',
'bloom',
'scanlineStr',
'pixelation',
]);
assert.equal(rows.enabled, false);
assert.equal(main.enabled, false);
// Cesium swaps a stage's uniforms object on its first frame.
main.uniforms = { ...main.uniforms };
composite.uniforms.gain = 0.9;
assert.equal(main.uniforms.gain, 0.9);
assert.equal(rows.uniforms.gain(), 0.9);
f.effects.setStageIntensity(composite, 1);
assert.equal(main.uniforms.intensity, 1);
assert.equal(rows.enabled, true);
assert.equal(main.enabled, true);
assert.equal(
created.filter((stage) => stage.name.startsWith('godsEyeView_thermal'))
.length,
2,
);
f.effects.destroy();
});
test('direct Cockpit intensity writes are reconciled before rendering', () => {
const f = fixture();
f.effects.initStyles();