mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Show map colours on Navara as they are, in every view and style
Navara lit the map imagery a second time: from space its atmosphere irradiance multiplied the map colours by the sun, so the far side of the globe went black in wide views; up close the aerial-perspective haze washed the imagery out, and an inspection exposure of 10 and a threefold atmosphere luminance fed the sensor styles (NVG, FLIR, CRT) colours far brighter than Cesium's. Map textures already carry their lighting, so the atmosphere now stays in the sky and clouds, the exposure stays at 1 and an active style bypasses filmic tone mapping.
This commit is contained in:
1 parent
a9b703f3ea
commit
667190f815
7 files changed
+193
-267
No files matched your search
@@ -154,6 +154,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
styles: createNavaraStyles(view, {
|
||||
shaders: STYLE_SHADERS,
|
||||
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
||||
onActiveChange: (active) => look.setStyled(active),
|
||||
}),
|
||||
movers: createNavaraMovers(view, {
|
||||
overlay,
|
||||
@@ -201,7 +202,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
const placed = readCameraPlace(camera);
|
||||
const landscape =
|
||||
placed &&
|
||||
plan.globeLighting &&
|
||||
plan.inspectionDaylight &&
|
||||
automaticLandscape &&
|
||||
worldClock.isLive() &&
|
||||
(engine.maps.baseKind === 'sentinel2' ||
|
||||
|
||||
+32
-76
@@ -1,22 +1,19 @@
|
||||
// Builds a Navara look (viewOptions.js navaraScenePlan) on an initialised
|
||||
// view and keeps it current while the view renders: the sun follows `now`
|
||||
// (the world time in the app, shared with the HUD's SUN elevation); the cinematic look
|
||||
// lights the globe
|
||||
// from the atmosphere when seen from space, places its clouds above the terrain while the sun is up, brightens the atmosphere
|
||||
// (createLuminanceScaler, uniforms from internals.js), compiles both
|
||||
// night-side shader variants at startup and shows its stars only at night
|
||||
// or from space. All of it runs only on frames the view draws anyway, so an
|
||||
// idle view stays idle.
|
||||
// (the world time in the app, shared with the HUD's SUN elevation). The sky
|
||||
// and clouds keep the cinematic lighting; map colours stay readable at any
|
||||
// time and height. All of it runs only on frames the view draws anyway, so
|
||||
// an idle view stays idle.
|
||||
|
||||
import {
|
||||
cloudsSunlitAt,
|
||||
ACES_FILMIC,
|
||||
AGX,
|
||||
LINEAR,
|
||||
MAP_AERIAL_PERSPECTIVE,
|
||||
cloudTerrainOffset,
|
||||
landscapeSunTime,
|
||||
CUMULUS_BASES,
|
||||
LOCAL_CLOUD_COVERAGE,
|
||||
globeLightingAt,
|
||||
starsVisibleAt,
|
||||
sunDateStale,
|
||||
} from './viewOptions.js';
|
||||
@@ -46,15 +43,21 @@ export function buildNavaraLook(
|
||||
{ forEachLuminanceUniform = null } = {},
|
||||
) {
|
||||
const handles = [];
|
||||
let scene = null;
|
||||
if (plan.pluginDefault) {
|
||||
const scene = plugin.addDefaultPhotorealScene();
|
||||
scene = plugin.addDefaultPhotorealScene();
|
||||
handles.push(...Object.values(scene).filter(Boolean));
|
||||
} else {
|
||||
for (const desc of plan.meshes) handles.push(view.addMesh(desc));
|
||||
for (const desc of plan.lights) handles.push(view.addLight(desc));
|
||||
for (const desc of plan.effects) handles.push(view.addEffect(desc));
|
||||
}
|
||||
// Set before tiles compile: neither forward nor deferred sunlight should
|
||||
// relight imagery that already contains its own shadows.
|
||||
view.lit = false;
|
||||
view.toneMappingExposure = 1;
|
||||
const effect = (name) => {
|
||||
if (scene) return scene[name] ?? null;
|
||||
const at = plan.effects.findIndex((desc) => Object.hasOwn(desc, name));
|
||||
return at < 0
|
||||
? null
|
||||
@@ -64,7 +67,10 @@ export function buildNavaraLook(
|
||||
const at = plan.meshes.findIndex((desc) => Object.hasOwn(desc, name));
|
||||
return at < 0 ? null : handles[at];
|
||||
};
|
||||
const aerial = plan.globeLighting ? effect('aerialPerspective') : null;
|
||||
if (scene)
|
||||
effect('aerialPerspective')?.update({
|
||||
aerialPerspective: { ...MAP_AERIAL_PERSPECTIVE },
|
||||
});
|
||||
const clouds = effect('clouds');
|
||||
const stars = plan.daylightHidesStars ? mesh('stars') : null;
|
||||
const brighten = createLuminanceScaler(
|
||||
@@ -77,19 +83,14 @@ export function buildNavaraLook(
|
||||
clouds,
|
||||
},
|
||||
);
|
||||
let globeLit = false;
|
||||
// Both aerial-perspective variants (with and without the night side) are
|
||||
// compiled on the first two frames, during startup, so a later switch
|
||||
// finds its shader compiled (a cold switch stalled a frame for up to
|
||||
// 0.8 s, mid-flight on the way down from space).
|
||||
let warmUp = aerial ? 2 : 0;
|
||||
let cloudsShown = true;
|
||||
let cloudsSunlit = true;
|
||||
let landscape = false;
|
||||
let daylightTime = null;
|
||||
let fillLight;
|
||||
let fill = 0;
|
||||
const tone = effect('toneMapping');
|
||||
const toneMode =
|
||||
plan.effects.find((desc) => desc.toneMapping)?.toneMapping.mode ?? AGX;
|
||||
let styled = false;
|
||||
let starsShown = true;
|
||||
|
||||
const frame = (
|
||||
@@ -100,17 +101,14 @@ export function buildNavaraLook(
|
||||
landscapeRequested = false,
|
||||
) => {
|
||||
// Every drawn frame: a material Navara rebuilds comes back unscaled.
|
||||
let changed = brighten(!landscapeRequested);
|
||||
if (landscapeRequested !== landscape && tone) {
|
||||
let changed = brighten();
|
||||
if (landscapeRequested !== landscape) {
|
||||
landscape = landscapeRequested;
|
||||
daylightTime = null;
|
||||
view.toneMappingExposure = landscape ? 10 : 1;
|
||||
tone.update({ toneMapping: { mode: landscape ? AGX : ACES_FILMIC } });
|
||||
changed = true;
|
||||
}
|
||||
const atmosphere = view.atmosphere;
|
||||
// Choose inspection daylight once. Rotating the camera must not rotate
|
||||
// the terminator across the geography.
|
||||
// Choose inspection daylight once so rotating the camera leaves the
|
||||
// sky's sun direction fixed.
|
||||
if (landscape && place && daylightTime === null)
|
||||
daylightTime = landscapeSunTime(now(), place.lng);
|
||||
const time = daylightTime ?? now();
|
||||
@@ -122,50 +120,6 @@ export function buildNavaraLook(
|
||||
place && typeof atmosphere?.getSunElevation === 'function'
|
||||
? atmosphere.getSunElevation(place)
|
||||
: Number.NaN;
|
||||
// Keep the map legible from orbit and when descending into darkness.
|
||||
// At low altitude the fill increases from sunset to civil dusk. Every
|
||||
// base map takes the night fill: the tiles are lit by the sun and the
|
||||
// sky, so a street map turned black after dusk, where Cesium (the
|
||||
// reference) keeps it legible; the orbital fill stays the landscape's.
|
||||
const orbitalFill = Number.isFinite(height)
|
||||
? Math.min(1, Math.max(0, (height - 50_000) / 250_000))
|
||||
: 0;
|
||||
const nightFill = Number.isFinite(elevation)
|
||||
? Math.min(1, Math.max(0, -elevation / 6))
|
||||
: 0;
|
||||
const nextFill = landscape ? Math.max(orbitalFill, nightFill) : nightFill;
|
||||
if (nextFill !== fill) {
|
||||
fill = nextFill;
|
||||
if (fillLight) fillLight.update({ ambient: { intensity: fill } });
|
||||
else fillLight = view.addLight({ ambient: { intensity: fill } });
|
||||
changed = true;
|
||||
}
|
||||
const opacity = aerial?.ref?.raw?.rawEffect?.blendMode.opacity;
|
||||
const strength = 1 - 0.2 * fill;
|
||||
if (opacity && opacity.value !== strength) {
|
||||
opacity.value = strength;
|
||||
changed = true;
|
||||
}
|
||||
if (warmUp) {
|
||||
warmUp -= 1;
|
||||
aerial.update(warmUp ? IRRADIANCE_ON : IRRADIANCE_OFF);
|
||||
return true;
|
||||
}
|
||||
if (aerial) {
|
||||
const next = globeLightingAt(height, globeLit);
|
||||
// Switching rebuilds the aerial perspective's shader (warmed up
|
||||
// above): it switches on only at rest (a flight from city to city
|
||||
// through space never switches) and off as soon as the camera comes
|
||||
// down, before the tiles would show relit.
|
||||
if (next !== globeLit && !(next && moving)) {
|
||||
globeLit = next;
|
||||
// Only the aerial perspective changes: the tiles already draw plain
|
||||
// albedo, so view.lit stays (toggling it recompiles every tile
|
||||
// material, a frame of about 0.8 s mid-flight).
|
||||
aerial.update(next ? IRRADIANCE_ON : IRRADIANCE_OFF);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Terrain-relative layers keep distant cumulus above mountain ridges.
|
||||
// Keep the deck visible from aircraft altitude, then clear it for regional views.
|
||||
if (clouds) {
|
||||
@@ -212,6 +166,14 @@ export function buildNavaraLook(
|
||||
return {
|
||||
handles,
|
||||
frame,
|
||||
// Sensor shaders expect display colours, as on Cesium. A filmic curve
|
||||
// before their gain/thresholds changes both contrast and clipping.
|
||||
setStyled(active) {
|
||||
if (!tone || active === styled) return false;
|
||||
styled = active;
|
||||
tone.update({ toneMapping: { mode: styled ? LINEAR : toneMode } });
|
||||
return true;
|
||||
},
|
||||
resetDaylight() {
|
||||
daylightTime = null;
|
||||
},
|
||||
@@ -246,12 +208,6 @@ export function buildNavaraLook(
|
||||
/** Navara's own stars intensity, for a look that does not set one. */
|
||||
const NAVARA_STARS_INTENSITY = 10;
|
||||
|
||||
const IRRADIANCE_ON = Object.freeze({
|
||||
aerialPerspective: Object.freeze({ irradiance: true }),
|
||||
});
|
||||
const IRRADIANCE_OFF = Object.freeze({
|
||||
aerialPerspective: Object.freeze({ irradiance: false }),
|
||||
});
|
||||
const VISIBLE = Object.freeze({ visible: true });
|
||||
const HIDDEN = Object.freeze({ visible: false });
|
||||
|
||||
|
||||
+101
-133
@@ -1,7 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildNavaraLook } from './look.js';
|
||||
import { navaraScenePlan } from './viewOptions.js';
|
||||
import { navaraScenePlan, MAP_AERIAL_PERSPECTIVE } from './viewOptions.js';
|
||||
import { createStylesPort } from './stylePass.js';
|
||||
import { STYLES } from '../../ui/visualPresets.js';
|
||||
import { createWorldClock } from '../../time/worldClock.js';
|
||||
|
||||
function fakeView(dateMs) {
|
||||
@@ -59,7 +61,7 @@ test('the sky follows world time through seek and live without moving live feeds
|
||||
assert.equal(view.atmosphere.date.getTime(), wall);
|
||||
});
|
||||
|
||||
/** A cinematic look past its two warm-up frames (updates cleared). */
|
||||
/** A cinematic look after its initial visibility updates (cleared). */
|
||||
function warmCinematic(view, internals) {
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
@@ -74,35 +76,24 @@ function warmCinematic(view, internals) {
|
||||
return look;
|
||||
}
|
||||
|
||||
test('the night-side shader variants are both compiled on the first two frames', () => {
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
);
|
||||
const aerial = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'aerialPerspective'),
|
||||
);
|
||||
assert.equal(look.frame(900), true);
|
||||
assert.equal(look.frame(900), true);
|
||||
assert.deepEqual(aerial.updates, [
|
||||
{ aerialPerspective: { irradiance: true } },
|
||||
{ aerialPerspective: { irradiance: false } },
|
||||
]);
|
||||
assert.equal(look.frame(900), false, 'then the usual rules');
|
||||
const minimal = fakeView(0);
|
||||
const plain = buildNavaraLook(
|
||||
minimal,
|
||||
plugin([]),
|
||||
navaraScenePlan('minimal'),
|
||||
() => 0,
|
||||
);
|
||||
assert.equal(plain.frame(900), false, 'no aerial perspective: no warm-up');
|
||||
test('map colours are unlit before the first frame, with no night-side warm-up', () => {
|
||||
for (const name of ['minimal', 'cinematic']) {
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan(name),
|
||||
() => 0,
|
||||
);
|
||||
assert.equal(view.lit, false);
|
||||
assert.equal(view.toneMappingExposure, 1);
|
||||
assert.equal(look.frame(900), false);
|
||||
assert.equal(look.frame(900), false);
|
||||
assert.ok(view.added.every(({ updates }) => updates.length === 0));
|
||||
}
|
||||
});
|
||||
|
||||
test('the photoreal look is the default plugin scene, untouched', () => {
|
||||
test('the photoreal look keeps its plugin scene with the same map colour rules', () => {
|
||||
const calls = [];
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
@@ -114,7 +105,7 @@ test('the photoreal look is the default plugin scene, untouched', () => {
|
||||
assert.deepEqual(calls, [[]]);
|
||||
assert.deepEqual(look.handles, [{ id: 'sky' }]);
|
||||
assert.equal(look.frame(8_000_000), false, 'no globe lighting');
|
||||
assert.equal(view.lit, true);
|
||||
assert.equal(view.lit, false);
|
||||
});
|
||||
|
||||
test('the cinematic look adds its descriptors in order', () => {
|
||||
@@ -131,23 +122,59 @@ test('the cinematic look adds its descriptors in order', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('from space the globe is lit by the atmosphere; near the ground the tiles keep their own light', () => {
|
||||
test('all styles retain map colours across height, sunlight, motion and daylight inspection', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const effect = (name) =>
|
||||
view.added.find(({ desc }) => Object.hasOwn(desc, name));
|
||||
assert.equal(look.frame(900), false, 'nothing changes at street level');
|
||||
assert.equal(look.frame(8_000_000), true);
|
||||
assert.equal(view.lit, true, 'no tile material recompiles');
|
||||
assert.deepEqual(effect('aerialPerspective').updates, [
|
||||
{ aerialPerspective: { irradiance: true } },
|
||||
]);
|
||||
assert.equal(look.frame(7_000_000), false, 'no change while it stays up');
|
||||
assert.equal(look.frame(1_200_000), false, 'hysteresis');
|
||||
assert.equal(look.frame(500_000), true);
|
||||
assert.deepEqual(effect('aerialPerspective').updates.at(-1), {
|
||||
aerialPerspective: { irradiance: false },
|
||||
let now = 0;
|
||||
let elevation = 40;
|
||||
view.atmosphere.getSunElevation = () => elevation;
|
||||
const look = buildNavaraLook(view, plugin([]), navaraScenePlan(), () => now);
|
||||
const port = createStylesPort(view, {
|
||||
shaders: STYLES,
|
||||
onActiveChange: (active) => look.setStyled(active),
|
||||
});
|
||||
const aerial = view.added.find(({ desc }) => desc.aerialPerspective);
|
||||
const tone = view.added.find(({ desc }) => desc.toneMapping);
|
||||
assert.deepEqual(aerial.desc.aerialPerspective, MAP_AERIAL_PERSPECTIVE);
|
||||
for (const style of ['normal', ...Object.keys(STYLES), 'normal']) {
|
||||
for (const name of Object.keys(STYLES))
|
||||
port.set(name, { intensity: name === style ? 1 : 0, uniforms: {} });
|
||||
for (const landscape of [true, false]) {
|
||||
for (elevation of [40, -3, -25, -90]) {
|
||||
now += 6 * 3600_000;
|
||||
for (const height of [
|
||||
600,
|
||||
12_000,
|
||||
100_000,
|
||||
1_000_000,
|
||||
1_500_000,
|
||||
1_700_000,
|
||||
8_000_000,
|
||||
19_000_000,
|
||||
Number.NaN,
|
||||
]) {
|
||||
for (const moving of [true, false]) {
|
||||
look.frame(height, moving, 0, { lat: 40, lng: 10 }, landscape);
|
||||
assert.equal(view.lit, false);
|
||||
assert.equal(view.toneMappingExposure, 1);
|
||||
assert.equal(
|
||||
aerial.updates.length,
|
||||
0,
|
||||
'no height or motion can relight the map',
|
||||
);
|
||||
assert.equal(
|
||||
(tone.updates.at(-1) ?? tone.desc).toneMapping.mode,
|
||||
style === 'normal' ? 6 : 0,
|
||||
style,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
view.added.every(({ desc }) => !desc.ambient),
|
||||
'no additive fill can wash out the map',
|
||||
);
|
||||
});
|
||||
|
||||
test('cumulus rises over mountain terrain and leaves the orbital view clear', () => {
|
||||
@@ -169,89 +196,53 @@ test('cumulus rises over mountain terrain and leaves the orbital view clear', ()
|
||||
assert.deepEqual(clouds.updates.at(-1), { visible: true }, 'above the deck');
|
||||
});
|
||||
|
||||
test('globe lighting switches on only at rest, and off even in motion', () => {
|
||||
test('plugin tone mapping restores after styles and its aerial pass preserves map colours', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const aerial = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'aerialPerspective'),
|
||||
const aerial = view.addEffect({ aerialPerspective: {} });
|
||||
const tone = view.addEffect({ toneMapping: { mode: 7 } });
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
{
|
||||
addDefaultPhotorealScene: () => ({
|
||||
aerialPerspective: aerial,
|
||||
toneMapping: tone,
|
||||
}),
|
||||
},
|
||||
navaraScenePlan('photoreal'),
|
||||
() => 0,
|
||||
);
|
||||
look.frame(20_000, true);
|
||||
look.frame(2_300_000, true);
|
||||
assert.deepEqual(aerial.updates, []);
|
||||
assert.equal(look.frame(8_000_000, false), true, 'at rest');
|
||||
assert.equal(look.frame(500_000, true), true, 'coming down');
|
||||
assert.deepEqual(aerial.updates.at(-1), {
|
||||
aerialPerspective: { irradiance: false },
|
||||
});
|
||||
assert.deepEqual(aerial.updates, [
|
||||
{ aerialPerspective: MAP_AERIAL_PERSPECTIVE },
|
||||
]);
|
||||
assert.equal(look.setStyled(true), true);
|
||||
assert.equal(look.setStyled(true), false, 'no recompile on uniform updates');
|
||||
look.frame(8_000_000);
|
||||
assert.equal(view.toneMappingExposure, 1);
|
||||
assert.equal(look.setStyled(false), true);
|
||||
assert.deepEqual(tone.updates, [
|
||||
{ toneMapping: { mode: 0 } },
|
||||
{ toneMapping: { mode: 7 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('daylight inspection restores live exposure, tone mapping and time when left', () => {
|
||||
test('daylight inspection changes sky time without changing map exposure or tone mapping', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const tone = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'toneMapping'),
|
||||
);
|
||||
const aerial = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'aerialPerspective'),
|
||||
);
|
||||
const opacity = { value: 1 };
|
||||
aerial.ref = { raw: { rawEffect: { blendMode: { opacity } } } };
|
||||
const tone = view.added.find(({ desc }) => desc.toneMapping);
|
||||
const fuji = { lat: 35.2599, lng: 138.6574 };
|
||||
look.frame(6508, false, 2600, fuji, true);
|
||||
assert.equal(view.toneMappingExposure, 10);
|
||||
assert.equal(opacity.value, 1);
|
||||
assert.equal(tone.updates.at(-1).toneMapping.mode, 7);
|
||||
assert.equal(view.toneMappingExposure, 1);
|
||||
assert.ok(
|
||||
Math.abs(view.atmosphere.date.getTime() / 3_600_000 + fuji.lng / 15 - 13) <
|
||||
1e-6,
|
||||
);
|
||||
look.frame(750_149, false, 0, fuji, true);
|
||||
assert.ok(opacity.value < 1);
|
||||
const ambient = view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient'));
|
||||
assert.equal(ambient.desc.ambient.intensity, 1);
|
||||
look.frame(6508, false, 2600, fuji, false);
|
||||
assert.equal(ambient.updates.at(-1).ambient.intensity, 0);
|
||||
assert.equal(view.toneMappingExposure, 1);
|
||||
assert.equal(opacity.value, 1);
|
||||
assert.equal(tone.updates.at(-1).toneMapping.mode, 6);
|
||||
assert.deepEqual(tone.updates, []);
|
||||
assert.equal(view.atmosphere.date.getTime(), 0);
|
||||
});
|
||||
|
||||
test('the dark side retains satellite imagery down to the surface', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const aerial = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'aerialPerspective'),
|
||||
);
|
||||
const opacity = { value: 1 };
|
||||
aerial.ref = { raw: { rawEffect: { blendMode: { opacity } } } };
|
||||
const origin = { lat: 21.975, lng: 20.9038 };
|
||||
const fuji = { lat: 35.2599, lng: 138.6574 };
|
||||
let elevation = 40;
|
||||
view.atmosphere.getSunElevation = () => elevation;
|
||||
look.frame(33_529_370, false, 0, origin, true);
|
||||
const sun = view.atmosphere.date;
|
||||
const ambient = view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient'));
|
||||
elevation = -30;
|
||||
for (const height of [2_000_000, 100_000, 50_000, 6508, 600]) {
|
||||
look.frame(height, true, 0, fuji, true);
|
||||
assert.equal(opacity.value, 0.8);
|
||||
assert.equal(
|
||||
ambient.updates.length,
|
||||
0,
|
||||
'the orbital fill remains at full strength',
|
||||
);
|
||||
assert.equal(view.atmosphere.date, sun);
|
||||
}
|
||||
elevation = -3;
|
||||
look.frame(600, false, 0, fuji, true);
|
||||
assert.equal(ambient.updates.at(-1).ambient.intensity, 0.5);
|
||||
elevation = 10;
|
||||
look.frame(600, false, 0, origin, true);
|
||||
assert.equal(ambient.updates.at(-1).ambient.intensity, 0);
|
||||
assert.equal(opacity.value, 1);
|
||||
});
|
||||
|
||||
test('inspection sunlight stays fixed through orbit, zoom and move end', () => {
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
@@ -270,29 +261,6 @@ test('inspection sunlight stays fixed through orbit, zoom and move end', () => {
|
||||
assert.equal(view.atmosphere.date, restored);
|
||||
});
|
||||
|
||||
test('a street map keeps the night fill after dusk, as Cesium keeps it lit', () => {
|
||||
// OSM over Austin at 12 km on the live sun: not a landscape, so the sun
|
||||
// is the wall clock's and the map has no orbital fill.
|
||||
const view = fakeView(0);
|
||||
const look = warmCinematic(view);
|
||||
const austin = { lat: 30.27, lng: -97.74 };
|
||||
let elevation = 40;
|
||||
view.atmosphere.getSunElevation = () => elevation;
|
||||
look.frame(12_000, false, 0, austin, false);
|
||||
const ambient = () =>
|
||||
view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient'));
|
||||
assert.equal(ambient(), undefined, 'no fill in daylight');
|
||||
elevation = -3;
|
||||
look.frame(12_000, false, 0, austin, false);
|
||||
assert.equal(ambient().desc.ambient.intensity, 0.5, 'half at dusk');
|
||||
elevation = -40;
|
||||
look.frame(12_000, false, 0, austin, false);
|
||||
assert.equal(ambient().updates.at(-1).ambient.intensity, 1, 'full at night');
|
||||
elevation = 10;
|
||||
look.frame(12_000, false, 0, austin, false);
|
||||
assert.equal(ambient().updates.at(-1).ambient.intensity, 0, 'none by day');
|
||||
});
|
||||
|
||||
test('the sun follows the wall clock, a minute at most behind, only on drawn frames', () => {
|
||||
let now = 1_000_000;
|
||||
const view = fakeView(now);
|
||||
|
||||
@@ -241,9 +241,14 @@ export class GevStylesPass extends Pass {
|
||||
* its Milky Way the same way).
|
||||
* The caller registers the effect (stylePasses.js createNavaraStyles).
|
||||
* @param {object} view an initialised ThreeView
|
||||
* @param {{ shaders: Record<string, { name: string, uniforms?: object, fragmentShader: string }>, onStarsDimmed?: (amount: number) => void }} options
|
||||
* `onActiveChange` selects display tone mapping while any style draws,
|
||||
* including both sides of a crossfade.
|
||||
* @param {{ shaders: Record<string, { name: string, uniforms?: object, fragmentShader: string }>, onStarsDimmed?: (amount: number) => void, onActiveChange?: (active: boolean) => void }} options
|
||||
*/
|
||||
export function createStylesPort(view, { shaders, onStarsDimmed = null }) {
|
||||
export function createStylesPort(
|
||||
view,
|
||||
{ shaders, onStarsDimmed = null, onActiveChange = null },
|
||||
) {
|
||||
const order = Object.keys(shaders);
|
||||
for (const name of order)
|
||||
navaraStyleFragmentShader(shaders[name].fragmentShader);
|
||||
@@ -265,6 +270,7 @@ export function createStylesPort(view, { shaders, onStarsDimmed = null }) {
|
||||
});
|
||||
if (name === 'anime') onStarsDimmed?.(state.intensity);
|
||||
const next = controller.visible().length > 0;
|
||||
if (next !== shown) onActiveChange?.(next);
|
||||
if (!handle) {
|
||||
if (!next) return;
|
||||
handle = view.addEffect({
|
||||
@@ -285,6 +291,7 @@ export function createStylesPort(view, { shaders, onStarsDimmed = null }) {
|
||||
destroy() {
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
if (shown) onActiveChange?.(false);
|
||||
shown = false;
|
||||
if (states.get('anime')?.intensity) onStarsDimmed?.(0);
|
||||
states.clear();
|
||||
|
||||
@@ -260,3 +260,28 @@ test('the styles port adds the pass once, hides it at 0 and fades the stars with
|
||||
assert.equal(view.deleted, 1);
|
||||
assert.deepEqual(dimmed, [0.5, 0, 0.9, 0], 'destroy brings the stars back');
|
||||
});
|
||||
|
||||
test('display tone mapping spans a crossfade and restores only after the last style leaves', () => {
|
||||
const active = [];
|
||||
const port = createStylesPort(fakeView(), {
|
||||
shaders: STYLES,
|
||||
onActiveChange: (value) => active.push(value),
|
||||
});
|
||||
const set = (name, intensity) => port.set(name, { intensity, uniforms: {} });
|
||||
set('retro', 0.001);
|
||||
assert.deepEqual(active, [], 'below the drawing threshold');
|
||||
set('retro', 0.1);
|
||||
set('surveillance', 0.5);
|
||||
set('retro', 0);
|
||||
set('thermal', 0.5);
|
||||
set('surveillance', 0);
|
||||
set('thermal', 1);
|
||||
assert.deepEqual(active, [true], 'uniform changes never recompile the look');
|
||||
set('thermal', 0);
|
||||
assert.deepEqual(active, [true, false]);
|
||||
set('noir', 1);
|
||||
port.destroy();
|
||||
assert.deepEqual(active, [true, false, true, false]);
|
||||
port.destroy();
|
||||
assert.equal(active.length, 4, 'destroy restores once');
|
||||
});
|
||||
@@ -61,6 +61,15 @@ export function resolveNavaraLook(search = '') {
|
||||
// postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7).
|
||||
export const ACES_FILMIC = 6;
|
||||
export const AGX = 7;
|
||||
export const LINEAR = 0;
|
||||
|
||||
// Map textures already encode their lighting. Atmosphere belongs in the sky
|
||||
// and clouds, not in a second lighting or haze pass over those colours.
|
||||
export const MAP_AERIAL_PERSPECTIVE = Object.freeze({
|
||||
irradiance: false,
|
||||
transmittance: false,
|
||||
inscatter: false,
|
||||
});
|
||||
|
||||
// Pick early afternoon at the starting landscape longitude. The look keeps
|
||||
// this timestamp while the user explores; telemetry uses its own clock.
|
||||
@@ -71,25 +80,6 @@ export function landscapeSunTime(now, longitude) {
|
||||
return Math.floor((now + offset) / day) * day + 13 * hour - offset;
|
||||
}
|
||||
|
||||
// Camera heights (m) where the cinematic look switches the globe to
|
||||
// atmosphere lighting (night side and terminator) and back. The gap keeps a
|
||||
// camera hovering near one height from toggling.
|
||||
export const GLOBE_LIGHTING_ENTER_M = 1_500_000;
|
||||
export const GLOBE_LIGHTING_EXIT_M = 1_000_000;
|
||||
|
||||
/**
|
||||
* Whether the cinematic look lights the globe from the atmosphere (deferred
|
||||
* irradiance: a real night side) at this camera height. Up close the Google
|
||||
* tiles keep their baked lighting: relit, they turn grey and dark (their
|
||||
* shadows and shading would count twice). Hysteresis between the two heights.
|
||||
*/
|
||||
export function globeLightingAt(height, current = false) {
|
||||
if (!Number.isFinite(height)) return current;
|
||||
return current
|
||||
? height > GLOBE_LIGHTING_EXIT_M
|
||||
: height >= GLOBE_LIGHTING_ENTER_M;
|
||||
}
|
||||
|
||||
// Navara cloud layers use ellipsoid heights. Over uplands raise the two
|
||||
// cumulus layers together; at world scale a single ground sample no longer
|
||||
// represents the whole view. Quantize the offset to avoid tiny layer updates.
|
||||
@@ -100,15 +90,14 @@ export function cloudTerrainOffset(height, ground) {
|
||||
return Math.ceil(Math.max(0, ground - 500) / 500) * 750;
|
||||
}
|
||||
|
||||
// Navara's sky, clouds and haze come from the atmosphere model in physical
|
||||
// Navara's sky and clouds come from the atmosphere model in physical
|
||||
// radiance, scaled for a tone-mapping exposure near 10, while the Google
|
||||
// tiles carry baked colours that need exposure 1. At exposure 1 the noon sky
|
||||
// came out dark navy (sRGB about 39, 58, 90 at Manhattan facing the
|
||||
// horizon), and the stars showed through it. The cinematic look multiplies
|
||||
// the atmosphere's radiance-to-luminance factors (sky, clouds, aerial
|
||||
// perspective; not the stars) by this scale instead of raising
|
||||
// the exposure, so the sky turns daylight blue and the tiles keep their
|
||||
// colours.
|
||||
// the atmosphere's radiance-to-luminance factors (not the stars) by this
|
||||
// scale instead of raising the exposure, so the sky turns daylight blue
|
||||
// and the tiles keep their colours.
|
||||
export const ATMOSPHERE_LUMINANCE_SCALE = 3;
|
||||
|
||||
// Stars show from space or once the sun is well below the horizon at the
|
||||
@@ -172,11 +161,7 @@ const cinematic = (mobile) => ({
|
||||
// would cost every frame and darken nothing the tiles show).
|
||||
lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }],
|
||||
effects: [
|
||||
// Depth-reconstructed normals: Navara 0.2.1's G-buffer normals are zero
|
||||
// for Google tiles and the globe, which turns atmosphere lighting black.
|
||||
// albedoScale brightens that lighting's day side to the photoreal
|
||||
// look's (it scales the albedo only where the atmosphere lights it).
|
||||
{ aerialPerspective: { useNormalBuffer: false, albedoScale: 2 } },
|
||||
{ aerialPerspective: { ...MAP_AERIAL_PERSPECTIVE } },
|
||||
...(mobile
|
||||
? []
|
||||
: [
|
||||
@@ -203,7 +188,7 @@ const cinematic = (mobile) => ({
|
||||
{ toneMapping: { mode: ACES_FILMIC } },
|
||||
mobile ? { fxaa: {} } : { smaa: {} },
|
||||
],
|
||||
globeLighting: true,
|
||||
inspectionDaylight: true,
|
||||
atmosphereLuminance: ATMOSPHERE_LUMINANCE_SCALE,
|
||||
daylightHidesStars: true,
|
||||
});
|
||||
@@ -215,7 +200,7 @@ const PLANS = {
|
||||
// Match cinematic: Google tiles already carry baked sunlight.
|
||||
lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }],
|
||||
effects: [],
|
||||
globeLighting: false,
|
||||
inspectionDaylight: false,
|
||||
atmosphereLuminance: 1,
|
||||
daylightHidesStars: false,
|
||||
}),
|
||||
@@ -224,7 +209,7 @@ const PLANS = {
|
||||
meshes: [],
|
||||
lights: [],
|
||||
effects: [],
|
||||
globeLighting: false,
|
||||
inspectionDaylight: false,
|
||||
atmosphereLuminance: 1,
|
||||
daylightHidesStars: false,
|
||||
}),
|
||||
@@ -238,8 +223,7 @@ const PLANS = {
|
||||
* aerial perspective, lens flare, AgX tone mapping, SMAA);
|
||||
* - `cinematic` (the app's default): photoreal plus volumetric clouds for
|
||||
* mountain and coastal views, ACES tone mapping, brighter stars, no sun shadow
|
||||
* maps, and atmosphere lighting of the globe from space (`globeLighting`,
|
||||
* see globeLightingAt), a brighter atmosphere (ATMOSPHERE_LUMINANCE_SCALE)
|
||||
* maps, a brighter atmosphere (ATMOSPHERE_LUMINANCE_SCALE)
|
||||
* and stars only at night or from space (starsVisibleAt). On a mobile-optimized
|
||||
* view it drops the clouds and the lens flare and uses FXAA.
|
||||
*/
|
||||
|
||||
@@ -2,12 +2,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_NAVARA_LOOK,
|
||||
GLOBE_LIGHTING_ENTER_M,
|
||||
GLOBE_LIGHTING_EXIT_M,
|
||||
NAVARA_CONTEXT_ATTRIBUTES,
|
||||
NAVARA_LOOKS,
|
||||
cloudTerrainOffset,
|
||||
globeLightingAt,
|
||||
navaraScenePlan,
|
||||
navaraViewOptions,
|
||||
resolveNavaraLook,
|
||||
@@ -61,7 +58,7 @@ test('cinematic is the default look; photoreal is the default plugin scene; mini
|
||||
meshes: [],
|
||||
lights: [],
|
||||
effects: [],
|
||||
globeLighting: false,
|
||||
inspectionDaylight: false,
|
||||
atmosphereLuminance: 1,
|
||||
daylightHidesStars: false,
|
||||
});
|
||||
@@ -70,7 +67,7 @@ test('cinematic is the default look; photoreal is the default plugin scene; mini
|
||||
meshes: [{ sky: {} }, { stars: {} }],
|
||||
lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }],
|
||||
effects: [],
|
||||
globeLighting: false,
|
||||
inspectionDaylight: false,
|
||||
atmosphereLuminance: 1,
|
||||
daylightHidesStars: false,
|
||||
});
|
||||
@@ -99,10 +96,10 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no
|
||||
'smaa',
|
||||
]);
|
||||
const [aerial, clouds, , toneMapping] = plan.effects;
|
||||
// Forward lighting up close: no irradiance, shadow or specular terms.
|
||||
assert.deepEqual(aerial.aerialPerspective, {
|
||||
useNormalBuffer: false,
|
||||
albedoScale: 2,
|
||||
irradiance: false,
|
||||
transmittance: false,
|
||||
inscatter: false,
|
||||
});
|
||||
assert.equal(clouds.clouds.coverage, 0.36);
|
||||
assert.equal(clouds.clouds.shadows, false, 'no cloud shadow maps');
|
||||
@@ -119,7 +116,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no
|
||||
'cloud layers sit above the city views',
|
||||
);
|
||||
assert.equal(toneMapping.toneMapping.mode, 6, 'ACES filmic');
|
||||
assert.equal(plan.globeLighting, true);
|
||||
assert.equal(plan.inspectionDaylight, true);
|
||||
// Legibility: none of the effects that blur, rain on or grey the map.
|
||||
for (const rejected of ['depthOfField', 'rainDrop', 'ssao', 'ssr'])
|
||||
assert.ok(!names(plan.effects).includes(rejected), rejected);
|
||||
@@ -148,18 +145,6 @@ test('?look= picks a look, anything else the cinematic default', () => {
|
||||
assert.equal(resolveNavaraLook('?look=constructor'), 'cinematic');
|
||||
});
|
||||
|
||||
test('globe lighting turns on from space and off near the ground, with hysteresis', () => {
|
||||
assert.equal(globeLightingAt(GLOBE_LIGHTING_ENTER_M - 1, false), false);
|
||||
assert.equal(globeLightingAt(GLOBE_LIGHTING_ENTER_M, false), true);
|
||||
assert.equal(globeLightingAt(8_000_000, false), true);
|
||||
assert.equal(globeLightingAt(1_200_000, true), true, 'inside the gap');
|
||||
assert.equal(globeLightingAt(1_200_000, false), false, 'inside the gap');
|
||||
assert.equal(globeLightingAt(GLOBE_LIGHTING_EXIT_M, true), false);
|
||||
assert.equal(globeLightingAt(900, true), false);
|
||||
assert.equal(globeLightingAt(Number.NaN, true), true, 'unknown keeps it');
|
||||
assert.ok(GLOBE_LIGHTING_EXIT_M < GLOBE_LIGHTING_ENTER_M);
|
||||
});
|
||||
|
||||
test('cumulus layers rise over mountains, with sea-level bases from space', () => {
|
||||
assert.equal(cloudTerrainOffset(900, 10), 0);
|
||||
assert.equal(cloudTerrainOffset(4350, 3100), 4500);
|
||||
|
||||
Reference in new issue
Block a user