Drive the Navara volumetric clouds from the cockpit weather behind an optional render.setCloudCover capability

The cockpit's weather feed moves out of the Cesium cloud pass and serves both: Cesium keeps its own cloud canvas, an engine with its own clouds takes their cover from the same observations, with no second WebGL canvas.
This commit is contained in:
German Khokhlov committed 2026-11-03 21:22:34 +04:00
1 parent d8c63c69d8
commit f0d38897f4
15 files changed
+1004 -79

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
// The cockpit weather clouds on Navara (Stage 6 C3): in cockpit mode with
// the cockpit weather on, Navara's own volumetric clouds take their cover
// from the weather under the camera (clear: none; overcast: a heavy deck),
// with no second canvas or WebGL context; leaving the cockpit gives the look
// its own clouds back. On Cesium the cockpit keeps its own cloud canvas and
// the engine draws none. The weather route is a fixture, the page clock is
// shifted to an Austin midday (the look shows clouds only in daylight), and
// cockpit mode is entered through its event (the cloud controllers listen
// to it; a real cockpit needs a tracked aircraft). Keyless preview under the
// strict default-deny network policy.
//
// node scripts/qa-navara-cockpit-clouds.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
capture,
checker,
diff,
freeze,
saveCapture,
stats,
visualPageUrl,
} from './qa/navaraVisual.mjs';
const base = localPreview(process.argv[2]);
const outDir = process.argv
.find((arg) => arg.startsWith('--out='))
?.slice('--out='.length);
const LOW_VIEW =
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=90&pitch=-8&map=osm&l=';
const MIDDAY_UTC = '2026-07-15T18:00:00Z';
const WEATHER = {
clear: { weatherCode: 0, cloudCoverPct: 0, windKph: 6, visibilityM: 20000 },
overcast: {
weatherCode: 3,
cloudCoverPct: 100,
windKph: 25,
visibilityM: 15000,
},
};
let weather = WEATHER.clear;
const asked = [];
const strict = createStrict({
origin: base.origin,
layers: [],
routes: [
{
pattern: `${base.origin}/api/weather-effects*`,
respond: (request) => {
asked.push(request.url);
return {
contentType: 'application/json',
body: JSON.stringify({ status: 'live', weather }),
};
},
},
],
});
const report = { checks: [] };
const check = checker(report, assert);
const save = (name, pixels) => saveCapture(outDir, name, pixels);
// Clouds grey the frame: the blue sky and the map's coloured lines go.
const saturation = (pixels) => stats(pixels).saturation;
// A few seconds of continuous frames: the clouds converge over frames.
async function converged(page, name) {
await page.evaluate(async () => {
const { engine } = window.__godsEyeView;
engine.render.setContinuous(true);
await new Promise((resolve) => setTimeout(resolve, 3000));
engine.render.setContinuous(false);
});
const pixels = await capture(page);
save(name, pixels);
return pixels;
}
const cockpit = (page, active) =>
page.evaluate((active) => {
document.body.classList.toggle('cockpit-mode', active);
window.dispatchEvent(
new CustomEvent('gev:cockpit-mode-changed', { detail: { active } }),
);
}, active);
const weatherToggle = (page, enabled) =>
page.evaluate((enabled) => {
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled } }),
);
}, enabled);
const coverage = (page) =>
page.evaluate(
() => window.__godsEyeView.engine.render.cloudCover?.().coverage,
);
await strict.run(
{ args: ['--use-angle=metal', '--enable-gpu'] },
async (browser) => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.evaluateOnNewDocument((utc) => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
const offset = Date.parse(utc) - Date.now();
const RealDate = Date;
class ShiftedDate extends RealDate {
constructor(...args) {
if (args.length === 0) super(RealDate.now() + offset);
else super(...args);
}
static now() {
return RealDate.now() + offset;
}
}
globalThis.Date = ShiftedDate;
}, MIDDAY_UTC);
await page.setViewport({ width: 1280, height: 800 });
await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), {
waitUntil: 'domcontentloaded',
});
await waitForEngine(page, 'navara');
await freeze(page);
const setup = await page.evaluate(() => {
const a = window.__godsEyeView;
return {
cover: a.engine.render.cloudCover?.() ?? null,
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
canvases: document.querySelectorAll('canvas').length,
};
});
check(
'Navara takes the cockpit cloud cover itself, no cloud canvas',
setup.cover?.coverage === null && !setup.canvas,
setup,
);
const lookOwn = await converged(page, 'look-own');
await weatherToggle(page, true);
await cockpit(page, true);
await page.waitForFunction(
() => window.__godsEyeView.engine.render.cloudCover().coverage === 0,
{ timeout: 10000, polling: 100 },
);
check('clear weather in the cockpit: no cloud cover', asked.length >= 1, {
requests: asked.length,
});
const clear = await converged(page, 'cockpit-clear');
// Overcast 30 km east: the movement makes the weather stale.
weather = WEATHER.overcast;
await page.evaluate(() => {
const { engine } = window.__godsEyeView;
const place = engine.camera.pose().cartographic;
const east =
place.longitude + 30000 / (6378137 * Math.cos(place.latitude));
const { sin, cos } = Math;
// WGS84 Earth-fixed position of (east, latitude, height).
const a = 6378137;
const e2 = 0.00669437999014;
const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2);
const h = place.height;
engine.camera.setView({
destination: {
x: (n + h) * cos(place.latitude) * cos(east),
y: (n + h) * cos(place.latitude) * sin(east),
z: (n * (1 - e2) + h) * sin(place.latitude),
},
orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 },
});
});
await page.waitForFunction(
() => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4,
{ timeout: 15000, polling: 100 },
);
const overcastCover = await coverage(page);
const overcast = await converged(page, 'cockpit-overcast');
const greyed = {
lookOwn: saturation(lookOwn),
clear: saturation(clear),
overcast: saturation(overcast),
clearToOvercast: diff(clear, overcast).mean,
};
check(
'overcast weather covers the view with Navara clouds, clear leaves it open',
overcastCover > 0.4 &&
greyed.overcast < greyed.clear * 0.6 &&
greyed.lookOwn < greyed.clear &&
greyed.clearToOvercast > 15,
{ overcastCover, greyed, requests: asked.length },
);
const contexts = await page.evaluate(() => ({
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
canvases: document.querySelectorAll('canvas').length,
}));
check(
'no second canvas or WebGL context in the cockpit',
!contexts.canvas && contexts.canvases === setup.canvases,
{ before: setup.canvases, after: contexts },
);
await cockpit(page, false);
check(
'leaving the cockpit gives the look its own clouds back',
(await coverage(page)) === null,
);
// Cesium keeps its own cockpit cloud canvas; its engine draws none.
await strict.switchTo(page, 'cesium');
await freeze(page);
weather = WEATHER.overcast;
await cockpit(page, true);
await page.waitForFunction(
() =>
Number(
document.getElementById('cockpit-cloud-effects')?.dataset
.cloudStrength,
) > 0.3,
{ timeout: 15000, polling: 100 },
);
const cesium = await page.evaluate(() => {
const canvas = document.getElementById('cockpit-cloud-effects');
return {
setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover,
strength: Number(canvas.dataset.cloudStrength),
cockpit: canvas.dataset.cockpit,
};
});
check(
'Cesium: the cockpit draws its own cloud canvas, the engine none',
cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true',
cesium,
);
await cockpit(page, false);
await weatherToggle(page, false);
check('no page errors', errors.length === 0, errors);
if (outDir)
fs.writeFileSync(
path.join(outDir, 'cockpit-clouds.json'),
JSON.stringify(report, null, 2),
);
},
);
+15 -6
View File
@@ -1,7 +1,11 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import {
initCockpitCloudEffects,
initEngineCockpitClouds,
} from '../cockpitCloudEffects.js';
import { engineCapabilities } from '../globe/contract.js';
import { t } from '../i18n/index.js';
import { attachScenePart, currentSceneOf } from './sceneParts.js';
import { shellScenePart } from './shellPart.js';
@@ -67,7 +71,9 @@ export async function createApplicationControls({
);
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
// Cesium fog or post-process stages and is fully stopped in map mode. An
// engine that draws its own clouds (Navara, render.setCloudCover) gets
// their cover from the same weather instead, with no canvas of its own.
const weatherEffects = null;
let cockpitCloudEffects = null;
await attachScenePart(
@@ -75,10 +81,13 @@ export async function createApplicationControls({
{
name: 'cockpit-clouds',
attachScene(next, { defer: release }) {
if (!next.viewer) return;
const effects = initCockpitCloudEffects(next.viewer, {
weatherService: operations.requests.weather,
});
const weatherService = operations.requests.weather;
let effects = null;
if (next.viewer)
effects = initCockpitCloudEffects(next.viewer, { weatherService });
else if (engineCapabilities(next.engine).cloudCover)
effects = initEngineCockpitClouds(next.engine, { weatherService });
if (!effects) return;
cockpitCloudEffects = effects;
release(() => {
if (cockpitCloudEffects === effects) cockpitCloudEffects = null;
+302 -70
View File
@@ -184,13 +184,111 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
return value === '1';
}
/**
* The cloud strength (0..1) a weather observation gives at an altitude: the
* weather profile's cloud, faded out above the cloud deck.
*/
export function cockpitCloudStrength(weather, altitudeM) {
const profile = deriveWeatherEffectProfile(weather);
return profile.available
? profile.cloud * weatherAltitudeFactors(altitudeM).cloud
: 0;
}
/** Highest cloud cover a globe engine is asked for (overcast, not a wall). */
export const COCKPIT_CLOUD_COVERAGE_MAX = 0.8;
/** The globe engine's cloud cover (0..1) for a cockpit cloud strength. */
export function cockpitCloudCoverage(strength) {
const value = Math.min(1, Math.max(0, Number(strength) || 0));
return value * COCKPIT_CLOUD_COVERAGE_MAX;
}
/**
* The cockpit's weather observations, shared by both cloud presentations:
* one request at a time, the last observation and where and when it was
* taken (cockpitWeatherRefreshDue decides when it is stale).
*/
export class CockpitWeatherFeed {
constructor(weatherService = applicationServices.weather) {
this.weatherService = weatherService;
this.weather = null;
this.fetchedAt = 0;
this.anchor = null;
this.pending = null;
this.abort = null;
this.sourceStatus = 'idle';
}
/** Whether an observation for `point` must be fetched. */
due(point, nowMs = Date.now()) {
return cockpitWeatherRefreshDue({
nowMs,
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
});
}
/**
* Fetch the observation at `point` (replacing a request in flight):
* `onWeather(weather, status)` once it arrives, `onError()` when the
* observation is unavailable (an abort calls neither). Resolves to the
* weather, or null.
*/
fetch(point, { onWeather, onError } = {}) {
this.abort?.abort();
const request = (this.abort = new AbortController());
const pending = this.weatherService.getConditions(
point.latitude,
point.longitude,
{ signal: request.signal },
);
this.pending = pending
.then(async (payload) => {
if (this.abort !== request || request.signal.aborted) return null;
if (!payload?.weather)
throw new Error('Cloud weather observation unavailable');
this.weather = payload.weather;
this.fetchedAt = Date.now();
this.anchor = point;
this.sourceStatus = payload.status || 'ready';
onWeather?.(payload.weather, this.sourceStatus);
return payload.weather;
})
.catch((error) => {
if (request.signal.aborted || this.abort !== request) return null;
if (error?.name !== 'AbortError') {
this.sourceStatus = 'unavailable';
onError?.(error);
}
return null;
})
.finally(() => {
if (this.abort === request) {
this.pending = null;
this.abort = null;
}
});
return this.pending;
}
/** Abandon a request in flight; the last observation stays. */
cancel() {
this.abort?.abort();
this.abort = null;
this.pending = null;
}
}
/**
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
*/
class CockpitCloudEffectsController {
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
this.weatherService = weatherService;
this.feed = new CockpitWeatherFeed(weatherService);
this.viewer = viewer;
this.canvas = document.createElement('canvas');
this.canvas.id = 'cockpit-cloud-effects';
@@ -206,15 +304,10 @@ class CockpitCloudEffectsController {
this.startupTimer = null;
this.lastFrameMs = 0;
this.lastRefreshCheckMs = 0;
this.fetchedAt = 0;
this.anchor = null;
this.weather = null;
this.strength = 0;
this.targetStrength = 0;
this.windDirectionDeg = 0;
this.windStrength = 0;
this.pending = null;
this.abort = null;
this.destroyed = false;
this.reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
@@ -387,9 +480,7 @@ class CockpitCloudEffectsController {
this.refreshTimer = null;
if (this.startupTimer !== null) window.clearTimeout(this.startupTimer);
this.startupTimer = null;
this.abort?.abort();
this.abort = null;
this.pending = null;
this.feed.cancel();
this.strength = 0;
this.targetStrength = 0;
this.canvas.classList.remove('active');
@@ -407,66 +498,33 @@ class CockpitCloudEffectsController {
if (
this.destroyed ||
!this.enabled ||
this.pending ||
this.feed.pending ||
this.suspended ||
!document.body.classList.contains('cockpit-mode')
) {
return this.pending;
return this.feed.pending;
}
const point = this.cameraPoint();
if (!point) return null;
if (
this.weather &&
Date.now() - this.fetchedAt < WEATHER_REFRESH_MS &&
greatCircleM(this.anchor, point) < WEATHER_MOVE_REFRESH_M
) {
this.applyWeather(this.weather, point.altitudeM);
return this.weather;
if (!this.feed.due(point)) {
this.applyWeather(this.feed.weather, point.altitudeM);
return this.feed.weather;
}
this.abort?.abort();
this.abort = new AbortController();
const pending = this.weatherService.getConditions(
point.latitude,
point.longitude,
{ signal: this.abort.signal },
);
const request = this.abort;
this.pending = pending
.then(async (payload) => {
if (this.abort !== request || request.signal.aborted) return null;
if (!payload?.weather)
throw new Error('Cloud weather observation unavailable');
this.weather = payload.weather;
this.fetchedAt = Date.now();
this.anchor = point;
this.applyWeather(payload.weather, point.altitudeM);
this.canvas.dataset.sourceStatus = payload.status || 'ready';
return payload.weather;
})
.catch((error) => {
if (request.signal.aborted || this.abort !== request) return;
if (error?.name !== 'AbortError') {
this.targetStrength = 0;
this.canvas.dataset.sourceStatus = 'unavailable';
}
return null;
})
.finally(() => {
if (this.abort === request) {
this.pending = null;
this.abort = null;
}
});
return this.pending;
return this.feed.fetch(point, {
onWeather: (weather, status) => {
this.applyWeather(weather, point.altitudeM);
this.canvas.dataset.sourceStatus = status;
},
onError: () => {
this.targetStrength = 0;
this.canvas.dataset.sourceStatus = 'unavailable';
},
});
}
applyWeather(weather, altitudeM) {
const profile = deriveWeatherEffectProfile(weather);
const altitude = weatherAltitudeFactors(altitudeM);
this.targetStrength = profile.available
? profile.cloud * altitude.cloud
: 0;
this.targetStrength = cockpitCloudStrength(weather, altitudeM);
this.windDirectionDeg = profile.windDirectionDeg || 0;
this.windStrength = profile.wind || 0;
this.canvas.dataset.cloudStrength = this.targetStrength.toFixed(3);
@@ -505,17 +563,7 @@ class CockpitCloudEffectsController {
if (timeMs - this.lastRefreshCheckMs >= 1000) {
this.lastRefreshCheckMs = timeMs;
const point = this.cameraPoint();
if (
point &&
cockpitWeatherRefreshDue({
nowMs: Date.now(),
fetchedAt: this.fetchedAt,
anchor: this.anchor,
point,
hasWeather: Boolean(this.weather),
})
)
void this.refresh();
if (point && this.feed.due(point)) void this.refresh();
}
const blend = this.reducedMotion ? 1 : 0.08;
@@ -572,7 +620,7 @@ class CockpitCloudEffectsController {
enabled: this.enabled,
active: this.canvas.classList.contains('active'),
strength: this.targetStrength,
weather: this.weather ? { ...this.weather } : null,
weather: this.feed.weather ? { ...this.feed.weather } : null,
renderSize: { width: this.canvas.width, height: this.canvas.height },
sourceStatus: this.canvas.dataset.sourceStatus || 'idle',
};
@@ -634,3 +682,187 @@ class CockpitCloudEffectsController {
export function initCockpitCloudEffects(viewer, options) {
return new CockpitCloudEffectsController(viewer, options);
}
/**
* The cockpit's weather clouds on a globe engine that draws its own clouds
* (render.setCloudCover, the `cloudCover` capability: Navara's volumetric
* clouds), with no canvas or WebGL context of its own. While cockpit mode
* and the cockpit weather toggle are on, the engine's cloud cover follows
* the weather under the camera (the same observations, refresh rule and
* strength as the Cesium pass); otherwise the engine keeps its own clouds.
*/
class EngineCockpitCloudsController {
constructor(engine, { weatherService = applicationServices.weather } = {}) {
this.engine = engine;
this.feed = new CockpitWeatherFeed(weatherService);
this.timer = null;
this.targetStrength = 0;
this.coverage = null;
this.suspended = false;
this.destroyed = false;
this.enabled = this.readEnabledPreference();
this.onCockpitMode = (event) => {
const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
if (active && this.enabled) this.start();
else this.stop();
};
this.onEnabledChange = (event) => {
const requested = event?.detail?.enabled;
this.setEnabled(
typeof requested === 'boolean' ? requested : !this.enabled,
);
};
window.addEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
window.addEventListener('gev:cockpit-weather-toggle', this.onEnabledChange);
this.emitEnabledState();
if (this.enabled && document.body.classList.contains('cockpit-mode'))
this.start();
}
readEnabledPreference() {
try {
return cockpitWeatherEnabledFromStoredValue(
localStorage.getItem(WEATHER_ENABLED_STORAGE_KEY),
);
} catch {
return false;
}
}
emitEnabledState() {
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: this.enabled },
}),
);
}
setEnabled(enabled) {
this.enabled = !!enabled;
try {
localStorage.setItem(
WEATHER_ENABLED_STORAGE_KEY,
this.enabled ? '1' : '0',
);
} catch {
/* best effort */
}
if (!this.enabled) this.stop();
else if (document.body.classList.contains('cockpit-mode')) this.start();
this.emitEnabledState();
}
cameraPoint() {
if (this.engine.isDestroyed?.()) return null;
let place;
try {
place = this.engine.camera.pose().cartographic;
} catch {
return null;
}
const latitude = geo.math.toDegrees(place?.latitude);
const longitude = geo.math.toDegrees(place?.longitude);
if (![latitude, longitude].every(Number.isFinite)) return null;
return {
latitude,
longitude,
altitudeM: Math.max(0, Number(place.height) || 0),
};
}
/** Shows the cloud cover for `strength`; null gives the engine its own. */
showCoverage(coverage) {
if (coverage === this.coverage || this.engine.isDestroyed?.()) return;
this.coverage = coverage;
this.engine.render.setCloudCover({ coverage });
this.engine.render.request('cockpit-clouds');
}
applyWeather(weather, altitudeM) {
this.targetStrength = cockpitCloudStrength(weather, altitudeM);
this.showCoverage(cockpitCloudCoverage(this.targetStrength));
}
start() {
if (this.destroyed || !this.enabled || this.suspended || this.timer) return;
// The first observation at once, then the refresh rule every second
// (time or movement), as the Cesium pass checks it.
this.timer = window.setInterval(() => this.check(), 1000);
this.check();
}
check() {
if (
this.destroyed ||
!this.enabled ||
this.suspended ||
!document.body.classList.contains('cockpit-mode')
)
return;
const point = this.cameraPoint();
if (!point || this.feed.pending) return;
if (!this.feed.due(point)) {
this.applyWeather(this.feed.weather, point.altitudeM);
return;
}
void this.feed.fetch(point, {
onWeather: (weather) => {
if (this.timer) this.applyWeather(weather, point.altitudeM);
},
onError: () => {
this.targetStrength = 0;
if (this.timer) this.showCoverage(0);
},
});
}
stop() {
if (this.timer) window.clearInterval(this.timer);
this.timer = null;
this.feed.cancel();
this.targetStrength = 0;
this.showCoverage(null);
}
getSnapshot() {
return {
enabled: this.enabled,
active: this.timer !== null && this.coverage !== null,
strength: this.targetStrength,
coverage: this.coverage,
weather: this.feed.weather ? { ...this.feed.weather } : null,
renderSize: null,
sourceStatus: this.feed.sourceStatus,
};
}
/** Pauses the weather checks while the document is hidden. */
setSuspended(suspended) {
this.suspended = Boolean(suspended);
if (this.suspended) {
if (this.timer) window.clearInterval(this.timer);
this.timer = null;
this.feed.cancel();
} else if (document.body.classList.contains('cockpit-mode')) {
this.start();
}
}
destroy() {
if (this.destroyed) return;
this.stop();
this.destroyed = true;
window.removeEventListener('gev:cockpit-mode-changed', this.onCockpitMode);
window.removeEventListener(
'gev:cockpit-weather-toggle',
this.onEnabledChange,
);
}
}
/** The cockpit weather clouds on a globe engine with the cloudCover capability. */
export function initEngineCockpitClouds(engine, options) {
return new EngineCockpitCloudsController(engine, options);
}
+171
View File
@@ -1,10 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
import * as geo from './geo/index.js';
import {
initCockpitCloudEffects,
cockpitCloudRenderSize,
cockpitWeatherRefreshDue,
cockpitWeatherEnabledFromStoredValue,
COCKPIT_CLOUD_COVERAGE_MAX,
CockpitWeatherFeed,
cockpitCloudCoverage,
cockpitCloudStrength,
initEngineCockpitClouds,
} from './cockpitCloudEffects.js';
test('cockpit cloud framebuffer stays low resolution on large displays', () => {
@@ -198,3 +205,167 @@ test('a destroyed cockpit cloud controller frees its GPU objects and WebGL conte
}
}
});
const OVERCAST = { weatherCode: 3, cloudCoverPct: 100, windKph: 20 };
const CLEAR = { weatherCode: 0, cloudCoverPct: 0, windKph: 5 };
test('the cloud strength is the weather profile cloud under the deck, the cover its share', () => {
assert.ok(cockpitCloudStrength(OVERCAST, 1000) > 0.5);
assert.equal(cockpitCloudStrength(CLEAR, 1000), 0);
assert.equal(cockpitCloudStrength({}, 1000), 0, 'no observation, no cloud');
assert.equal(cockpitCloudStrength(OVERCAST, 30000), 0, 'above the deck');
assert.equal(cockpitCloudCoverage(1), COCKPIT_CLOUD_COVERAGE_MAX);
assert.equal(cockpitCloudCoverage(0), 0);
assert.equal(cockpitCloudCoverage(Number.NaN), 0);
assert.equal(cockpitCloudCoverage(0.5), 0.5 * COCKPIT_CLOUD_COVERAGE_MAX);
});
test('the weather feed keeps the last observation and reports failures', async () => {
const asked = [];
let answer = { status: 'live', weather: OVERCAST };
const feed = new CockpitWeatherFeed({
getConditions: async (lat, lon, { signal }) => {
asked.push([lat, lon, signal.aborted]);
if (answer instanceof Error) throw answer;
return answer;
},
});
const point = { latitude: 30, longitude: -97, altitudeM: 900 };
assert.equal(feed.due(point), true);
const seen = [];
assert.deepEqual(
await feed.fetch(point, { onWeather: (w, s) => seen.push([w, s]) }),
OVERCAST,
);
assert.deepEqual(seen, [[OVERCAST, 'live']]);
assert.equal(feed.due(point), false, 'fresh and here');
assert.equal(feed.pending, null);
answer = new Error('down');
const errors = [];
assert.equal(
await feed.fetch(point, { onError: (e) => errors.push(e) }),
null,
);
assert.equal(errors.length, 1);
assert.equal(feed.sourceStatus, 'unavailable');
assert.deepEqual(feed.weather, OVERCAST, 'the last observation stays');
// A cancelled request reports nothing.
answer = { weather: CLEAR };
const late = feed.fetch(point, { onWeather: () => seen.push('late') });
feed.cancel();
assert.equal(await late, null);
assert.equal(seen.length, 1);
});
// The page globals the engine controller reads, restored afterwards.
function withPage(run) {
const names = ['document', 'window', 'localStorage', 'CustomEvent'];
const saved = Object.fromEntries(names.map((n) => [n, globalThis[n]]));
const listeners = new Map();
const timers = new Map();
let nextTimer = 1;
const body = new Set(['cockpit-mode']);
let created = 0;
globalThis.document = {
createElement() {
created++;
return {};
},
body: { classList: { contains: (name) => body.has(name) } },
};
globalThis.window = {
addEventListener: (type, fn) => listeners.set(type, fn),
removeEventListener: (type) => listeners.delete(type),
dispatchEvent() {},
setInterval: (fn) => {
timers.set(nextTimer, fn);
return nextTimer++;
},
clearInterval: (id) => timers.delete(id),
};
globalThis.localStorage = { getItem: () => '1', setItem() {} };
globalThis.CustomEvent = class {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
}
};
const page = {
body,
listeners,
timers,
get canvases() {
return created;
},
};
return Promise.resolve(run(page)).finally(() => {
for (const [name, value] of Object.entries(saved)) {
if (value === undefined) delete globalThis[name];
else globalThis[name] = value;
}
});
}
const settle = () => new Promise((resolve) => setImmediate(resolve));
test('on an engine with its own clouds the cockpit weather sets their cover, no canvas', () =>
withPage(async (page) => {
const engine = createFakeEngine({ kind: 'navara', cloudCover: true });
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.74, 30.27, 1500),
});
let weather = OVERCAST;
const clouds = initEngineCockpitClouds(engine, {
weatherService: { getConditions: async () => ({ weather }) },
});
await settle();
const overcast = cockpitCloudCoverage(cockpitCloudStrength(OVERCAST, 1500));
assert.ok(Math.abs(engine.render.cloudCover().coverage - overcast) < 1e-9);
assert.equal(page.canvases, 0, 'no canvas, no second WebGL context');
assert.equal(page.timers.size, 1, 'one refresh check');
assert.equal(clouds.getSnapshot().active, true);
// Leaving the cockpit gives the engine its own clouds back.
page.body.delete('cockpit-mode');
page.listeners.get('gev:cockpit-mode-changed')({
detail: { active: false },
});
assert.deepEqual(engine.render.cloudCover(), { coverage: null });
assert.equal(page.timers.size, 0);
// Back in, with clear weather after the cache is stale.
page.body.add('cockpit-mode');
weather = CLEAR;
clouds.feed.fetchedAt = 0;
page.listeners.get('gev:cockpit-mode-changed')({
detail: { active: true },
});
await settle();
assert.equal(engine.render.cloudCover().coverage, 0);
// The weather toggle off stops it; destroy leaves the engine its own.
page.listeners.get('gev:cockpit-weather-toggle')({
detail: { enabled: false },
});
assert.deepEqual(engine.render.cloudCover(), { coverage: null });
clouds.destroy();
assert.equal(page.listeners.size, 0);
}));
test('a hidden page pauses the engine cockpit clouds checks', () =>
withPage(async (page) => {
const engine = createFakeEngine({ kind: 'navara', cloudCover: true });
let asked = 0;
const clouds = initEngineCockpitClouds(engine, {
weatherService: {
getConditions: async () => {
asked++;
return { weather: OVERCAST };
},
},
});
await settle();
clouds.setSuspended(true);
assert.equal(page.timers.size, 0);
clouds.setSuspended(false);
assert.equal(page.timers.size, 1);
clouds.destroy();
assert.ok(asked >= 1);
}));
+33
View File
@@ -259,6 +259,16 @@
* Cesium mode the app drives the scene's own bloom stage.
* @property {() => { enabled: boolean, strength: number }} [bloom] The bloom
* last set (a copy).
* @property {(options: { coverage?: number | null }) => void} [setCloudCover]
* Optional, with cloudCover (capability `cloudCover`): how much of the sky
* the engine's own volumetric clouds cover, 0..1, or null for the
* engine's own cover (its look). Where the clouds show (height, daylight)
* stays the engine's. The cockpit drives it from the weather under the
* camera (src/cockpitCloudEffects.js). A coverage outside 0..1 that is not
* null throws a TypeError. Null until set. Cesium has none: in Cesium
* mode the cockpit draws its own cloud pass.
* @property {() => { coverage: number | null }} [cloudCover] The cover last
* set (a copy).
*
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
*
@@ -681,6 +691,10 @@ const OPTIONAL_SHAPE = {
'render.setBloom': isFunction,
'render.bloom': isFunction,
},
cloudCover: {
'render.setCloudCover': isFunction,
'render.cloudCover': isFunction,
},
};
/** The optional capability flags, e.g. 'visualStyles'. */
@@ -843,3 +857,22 @@ export function mergeBloom(current, { enabled, strength } = {}) {
strength: strength ?? current.strength,
};
}
/** The cloud cover an engine starts with (GlobeRender.cloudCover): its own. */
export const CLOUD_COVER_INITIAL = Object.freeze({ coverage: null });
/**
* GlobeRender.setCloudCover's argument merged onto `current`: a new state,
* or a TypeError for a coverage that is neither null nor in 0..1.
*/
export function mergeCloudCover(current, { coverage } = {}) {
if (
coverage !== undefined &&
coverage !== null &&
!(isNumber(coverage) && coverage >= 0 && coverage <= 1)
)
throw new TypeError(
`Cloud coverage must be null or a number in 0..1: ${coverage}`,
);
return { coverage: coverage === undefined ? current.coverage : coverage };
}
+26
View File
@@ -6,6 +6,7 @@ import {
INPUT_TYPES,
MODIFIERS,
BLOOM_INITIAL,
CLOUD_COVER_INITIAL,
ENGINE_CAPABILITIES,
OPTIONAL_ENGINE_MEMBERS,
SHARPEN_INITIAL,
@@ -14,6 +15,7 @@ import {
engineCapabilities,
isEngineEvent,
mergeBloom,
mergeCloudCover,
mergeSharpen,
mergeStyle,
} from './contract.js';
@@ -476,3 +478,27 @@ test('the bloom capability: whole or absent, merged and checked', async (t) => {
assert.equal(checks.length, 1);
for (const check of checks) await t.test(check.name, () => check.run());
});
test('the cloudCover capability: whole or absent, null for the engine own', async (t) => {
assert.ok(ENGINE_CAPABILITIES.includes('cloudCover'));
const engine = createFakeEngine({ cloudCover: true });
assert.equal(engineCapabilities(engine).cloudCover, true);
assert.equal(engineCapabilities(createFakeEngine()).cloudCover, false);
assert.deepEqual(engine.render.cloudCover(), CLOUD_COVER_INITIAL);
const half = {
...engine,
render: { ...engine.render, cloudCover: undefined },
};
assert.throws(() => assertGlobeEngine(half), /render\.cloudCover\b/);
assert.deepEqual(mergeCloudCover({ coverage: 0.3 }, {}), { coverage: 0.3 });
assert.deepEqual(mergeCloudCover({ coverage: 0.3 }, { coverage: null }), {
coverage: null,
});
for (const bad of [{ coverage: 2 }, { coverage: -1 }, { coverage: 'x' }])
assert.throws(() => mergeCloudCover(CLOUD_COVER_INITIAL, bad), TypeError);
const checks = createContractSuite(engine, {
waitFrames: async (count) => engine.fake.frames(count),
}).filter(({ name }) => /\(cloudCover\)/.test(name));
assert.equal(checks.length, 1);
for (const check of checks) await t.test(check.name, () => check.run());
});
+21
View File
@@ -0,0 +1,21 @@
// The cloud cover port createNavaraEngine takes (render.setCloudCover): the
// cockpit's weather clouds drawn by Navara's own volumetric clouds, the
// look's cloud effect (look.js), instead of a second WebGL canvas. Only a
// look that draws clouds has it (the cinematic look; not on a
// mobile-optimized view). Destroying it gives the look its own cover back.
/**
* @param {{ hasClouds: boolean, setCloudCoverage: (coverage: number | null) => boolean }} look
* @returns {{ set: (state: { coverage: number | null }) => void, destroy: () => void } | null}
*/
export function createNavaraCloudCover(look) {
if (!look?.hasClouds) return null;
return {
set({ coverage }) {
look.setCloudCoverage(coverage);
},
destroy() {
look.setCloudCoverage(null);
},
};
}
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createNavaraCloudCover } from './cockpitClouds.js';
test('only a look with clouds gives the engine a cloud cover port', () => {
assert.equal(createNavaraCloudCover(null), null);
assert.equal(
createNavaraCloudCover({ hasClouds: false, setCloudCoverage() {} }),
null,
);
const set = [];
const port = createNavaraCloudCover({
hasClouds: true,
setCloudCoverage: (coverage) => set.push(coverage),
});
port.set({ coverage: 0.4 });
port.set({ coverage: null });
port.set({ coverage: 0 });
port.destroy();
assert.deepEqual(set, [0.4, null, 0, null], 'destroy gives the look its own');
});
+23 -1
View File
@@ -9,10 +9,12 @@
import * as geo from '../../geo/index.js';
import {
BLOOM_INITIAL,
CLOUD_COVER_INITIAL,
SHARPEN_INITIAL,
STYLE_INITIAL,
createEngineEvent,
mergeBloom,
mergeCloudCover,
mergeSharpen,
mergeStyle,
} from '../contract.js';
@@ -93,6 +95,7 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
* appClock?: import('../../time/clock.js').AppClock,
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
* bloom?: { set: (state: { enabled: boolean, strength: number }) => void, destroy: () => void },
* clouds?: { set: (state: { coverage: number | null }) => void, destroy: () => void },
* styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => void, destroy: () => void },
* }} options `hooks` are internals.js; `container` defaults to the
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
@@ -100,7 +103,9 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
* `styles` draws render.setStyle (stylePasses.js; without it the engine
* has no visualStyles capability) and the engine destroys it; `bloom`
* draws render.setBloom (bloom.js; without it no bloom capability), also
* destroyed with the engine;
* destroyed with the engine; `clouds` draws render.setCloudCover
* (cockpitClouds.js; without it, as on a look without clouds, no
* cloudCover capability), destroyed with the engine;
* `appClock` (the engine clock's time) defaults to the page's app clock;
* `movers` is engine.movers on the view's three runtime (movers/index.js;
* without it createSet throws), `lines` engine.lines (lines/index.js;
@@ -121,6 +126,7 @@ export function createNavaraEngine(
sharpen: sharpenPort = null,
styles: stylesPort = null,
bloom: bloomPort = null,
clouds: cloudsPort = null,
} = {},
) {
if (!view?.camera?.raw)
@@ -331,6 +337,20 @@ export function createNavaraEngine(
bloom: () => ({ ...bloom }),
}
: {};
let cloudCover = { ...CLOUD_COVER_INITIAL };
const cloudRender = cloudsPort
? {
setCloudCover(options) {
const next = mergeCloudCover(cloudCover, options);
const changed = next.coverage !== cloudCover.coverage;
cloudCover = next;
if (!changed || destroyed) return;
cloudsPort.set({ ...next });
view.forceUpdate();
},
cloudCover: () => ({ ...cloudCover }),
}
: {};
return {
kind: 'navara',
host: { container: container ?? canvas.parentElement, canvas, size },
@@ -361,6 +381,7 @@ export function createNavaraEngine(
sharpen: () => ({ ...sharpen }),
...styleRender,
...bloomRender,
...cloudRender,
},
surface,
input: input.input,
@@ -383,6 +404,7 @@ export function createNavaraEngine(
sharpenPort?.destroy();
stylesPort?.destroy();
bloomPort?.destroy();
cloudsPort?.destroy();
moversPort?.destroy();
linesPort?.destroy();
},
@@ -291,10 +291,34 @@ test('setBloom hands changed states to the bloom port and draws a frame', () =>
assert.equal(destroyed, 1);
});
test('setCloudCover hands changed covers to the clouds port and draws a frame', () => {
const view = fakeView();
const shown = [];
let destroyed = 0;
const engine = createNavaraEngine(view, {
hooks,
clouds: { set: (state) => shown.push(state), destroy: () => destroyed++ },
});
assertGlobeEngine(engine);
assert.equal(engineCapabilities(engine).cloudCover, true);
assert.deepEqual(engine.render.cloudCover(), { coverage: null });
engine.render.setCloudCover({ coverage: 0.6 });
assert.deepEqual(view.calls.at(-1), ['forceUpdate']);
const calls = view.calls.length;
engine.render.setCloudCover({ coverage: 0.6 });
assert.equal(view.calls.length, calls, 'an unchanged cover draws nothing');
engine.render.setCloudCover({ coverage: null });
assert.throws(() => engine.render.setCloudCover({ coverage: 7 }), TypeError);
assert.deepEqual(shown, [{ coverage: 0.6 }, { coverage: null }]);
engine.destroy();
assert.equal(destroyed, 1);
});
test('without a styles port the engine has no visualStyles capability', () => {
const { engine } = setup();
assert.equal(engineCapabilities(engine).visualStyles, false);
assert.equal(engineCapabilities(engine).bloom, false);
assert.equal(engineCapabilities(engine).cloudCover, false);
assert.equal(engine.render.setStyle, undefined);
assert.equal(engine.render.setBloom, undefined);
});
+2
View File
@@ -24,6 +24,7 @@ import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js';
import { createNavaraStyles } from './stylePasses.js';
import { createNavaraBloom } from './bloom.js';
import { createNavaraCloudCover } from './cockpitClouds.js';
import { retroShader } from '../../styles/retro.js';
import { nightVisionShader } from '../../styles/surveillance.js';
import { thermalShader } from '../../styles/thermal.js';
@@ -159,6 +160,7 @@ export async function createNavaraGlobe({
...(appClock ? { appClock } : {}),
sharpen: createNavaraSharpen(view),
bloom: createNavaraBloom(view),
clouds: createNavaraCloudCover(look),
styles: createNavaraStyles(view, {
shaders: STYLE_SHADERS,
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
+17
View File
@@ -16,6 +16,7 @@ import {
cloudTerrainOffset,
landscapeSunTime,
CUMULUS_BASES,
LOCAL_CLOUD_COVERAGE,
globeLightingAt,
starsVisibleAt,
sunDateStale,
@@ -205,12 +206,28 @@ export function buildNavaraLook(
plan.meshes.find((desc) => Object.hasOwn(desc, 'stars'))?.stars
?.intensity ?? NAVARA_STARS_INTENSITY;
let starsDimmed = 0;
let cloudCoverage = LOCAL_CLOUD_COVERAGE;
return {
handles,
frame,
resetDaylight() {
daylightTime = null;
},
/** Whether the look draws volumetric clouds (cinematic, not on mobile). */
hasClouds: Boolean(clouds),
/**
* The clouds' cover (0..1), or null for the look's own
* (LOCAL_CLOUD_COVERAGE); true when it changed. Where and when they
* show stays the look's rule (height, sunlight).
*/
setCloudCoverage(coverage) {
if (!clouds) return false;
const next = coverage ?? LOCAL_CLOUD_COVERAGE;
if (next === cloudCoverage) return false;
cloudCoverage = next;
clouds.update({ clouds: { coverage: next } });
return true;
},
/** Dims the stars by `amount` (0 none, 1 hidden); true when it changed. */
setStarsDimmed(amount) {
const next = Math.min(1, Math.max(0, Number(amount) || 0));
+30
View File
@@ -509,3 +509,33 @@ test('Anime fades the stars from the look own brightness and back', () => {
);
assert.equal(photoreal.setStarsDimmed(1), false);
});
test('the cockpit sets the cinematic clouds cover and null gives the look its own', async () => {
const { LOCAL_CLOUD_COVERAGE } = await import('./viewOptions.js');
const view = fakeView(0);
const look = warmCinematic(view);
const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds'));
assert.equal(look.hasClouds, true);
assert.equal(look.setCloudCoverage(null), false, 'already the look own');
assert.equal(look.setCloudCoverage(0.7), true);
assert.equal(look.setCloudCoverage(0.7), false);
assert.equal(look.setCloudCoverage(0), true);
assert.equal(look.setCloudCoverage(null), true);
assert.deepEqual(
clouds.updates.filter((update) => update.clouds),
[
{ clouds: { coverage: 0.7 } },
{ clouds: { coverage: 0 } },
{ clouds: { coverage: LOCAL_CLOUD_COVERAGE } },
],
);
// The mobile cinematic and the minimal looks draw no clouds.
const mobile = buildNavaraLook(
fakeView(0),
plugin([]),
navaraScenePlan('cinematic', { mobile: true }),
() => 0,
);
assert.equal(mobile.hasClouds, false);
assert.equal(mobile.setCloudCoverage(0.5), false);
});
+47
View File
@@ -2772,9 +2772,56 @@ export function createContractSuite(
if (capabilities.visualStyles)
checks.push(...styleChecks(engine.render, settle));
if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle));
if (capabilities.cloudCover)
checks.push(...cloudCoverChecks(engine.render, settle));
return checks.map(([name, run]) => ({ name, run }));
}
// The optional cloudCover capability (render.setCloudCover).
function cloudCoverChecks(render, settle) {
return [
[
'render.setCloudCover (cloudCover): sets, copies, gives back, rejects bad values',
async () => {
const previous = render.cloudCover();
try {
render.setCloudCover({ coverage: 0.7 });
ensure(render.cloudCover().coverage === 0.7, 'coverage not set');
render.cloudCover().coverage = 0;
ensure(
render.cloudCover().coverage === 0.7,
'cloudCover() is a copy',
);
await settle(2);
render.setCloudCover({});
ensure(render.cloudCover().coverage === 0.7, 'omitted keeps it');
render.setCloudCover({ coverage: null });
ensure(render.cloudCover().coverage === null, 'null: the engine own');
for (const bad of [-0.1, 1.2, Number.NaN, '0.5']) {
let error = null;
try {
render.setCloudCover({ coverage: bad });
} catch (caught) {
error = caught;
}
ensure(
error instanceof TypeError,
`setCloudCover(${bad}) did not throw a TypeError`,
);
}
ensure(
render.cloudCover().coverage === null,
'after rejected values',
);
} finally {
render.setCloudCover(previous);
await settle(1);
}
},
],
];
}
// The optional bloom capability (render.setBloom).
function bloomChecks(render, settle) {
return [
+20 -2
View File
@@ -23,10 +23,12 @@ import {
MODIFIERS,
normalizeInputOptions,
BLOOM_INITIAL,
CLOUD_COVER_INITIAL,
SHARPEN_INITIAL,
STYLE_INITIAL,
createEngineEvent,
mergeBloom,
mergeCloudCover,
mergeSharpen,
mergeStyle,
} from '../contract.js';
@@ -231,6 +233,19 @@ function rectangleFromCartographics(points) {
return { west, south, east, north };
}
// render.setCloudCover/cloudCover when asked; nothing otherwise.
function fakeCloudCover(enabled, record) {
if (!enabled) return {};
let cover = { ...CLOUD_COVER_INITIAL };
return {
setCloudCover(options) {
record('render.setCloudCover', [options]);
cover = mergeCloudCover(cover, options);
},
cloudCover: () => ({ ...cover }),
};
}
// render.setBloom/bloom when asked; nothing otherwise.
function fakeBloom(enabled, record) {
if (!enabled) return {};
@@ -279,7 +294,8 @@ function fakeStyles(visualStyles, record) {
* longitude?: number, latitude?: number, altitude?: number,
* container?: object, canvas?: object, nowMs?: number,
* appClock?: import('../../time/clock.js').AppClock,
* visualStyles?: Record<string, string[]>, bloom?: boolean }} [options]
* visualStyles?: Record<string, string[]>, bloom?: boolean,
* cloudCover?: boolean }} [options]
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
* for the host elements (DOM mocks for shell tests). The initial pose looks straight
* down from `altitude` metres (10 000 km). The engine clock reads
@@ -288,7 +304,8 @@ function fakeStyles(visualStyles, record) {
* `fake.advanceTime(ms)` (a given appClock keeps its own wall clock).
* `visualStyles` ({ style: its uniform names }) adds the optional
* visualStyles capability (render.setStyle), each uniform starting at 0;
* `bloom: true` the optional bloom capability (render.setBloom).
* `bloom: true` the optional bloom capability (render.setBloom);
* `cloudCover: true` the optional cloudCover one (render.setCloudCover).
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
*/
export function createFakeEngine(options = {}) {
@@ -976,6 +993,7 @@ export function createFakeEngine(options = {}) {
sharpen: () => ({ ...sharpen }),
...fakeStyles(options.visualStyles, record),
...fakeBloom(options.bloom, record),
...fakeCloudCover(options.cloudCover, record),
},
surface: {
sampleHeight(position, exclude) {