mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge migration/phase2-engine (4aaf9746) into migration/phase3-layers
The phase-2 review fixes: look internals in internals.js, the groundFloor resample throttle, tile height selection, the sharpen mirror, QA tile cache limits, engine.groundFloor diagnostics. In navara/index.js both sides are kept: the look gets internals.js's luminance uniforms and reads the sun's time from the engine clock's frame time, so engine.clock stays the one time source for the sun.
This commit is contained in:
commit
7c7422e281
21 files changed
+692
-291
No files matched your search
@@ -1033,7 +1033,6 @@
|
||||
"src/globe/cesium/render.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/navara/atmosphereLuminance.js",
|
||||
"src/globe/navara/cameraMath.js",
|
||||
"src/globe/navara/cameraMath.test.mjs",
|
||||
"src/globe/navara/cameraModel.js",
|
||||
|
||||
@@ -1760,7 +1760,6 @@
|
||||
"src/globe/cesium/render.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/globe/navara/atmosphereLuminance.js",
|
||||
"src/globe/navara/cameraMath.js",
|
||||
"src/globe/navara/cameraModel.js",
|
||||
"src/globe/navara/createNavaraEngine.js",
|
||||
|
||||
@@ -16,6 +16,11 @@ export const CACHED_TILE_HOSTS = Object.freeze([
|
||||
'terrain.reearth.land',
|
||||
]);
|
||||
const RETRIES = 4;
|
||||
/** Who is asking, with a contact URL (the tile hosts' usage policies). */
|
||||
export const QA_USER_AGENT =
|
||||
'gods-eye-view-qa/1 (headless tests with a disk cache; https://github.com/bilawalsidhu/gods-eye-view)';
|
||||
/** Fetches to the tile hosts in flight at once. */
|
||||
export const MAX_MISSES_IN_FLIGHT = 2;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
@@ -59,7 +64,7 @@ export async function fetchTile(
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
'User-Agent': 'gods-eye-view-qa (headless test, cached)',
|
||||
'User-Agent': QA_USER_AGENT,
|
||||
...(accept ? { Accept: accept } : {}),
|
||||
},
|
||||
});
|
||||
@@ -73,6 +78,48 @@ export async function fetchTile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads missing tiles politely: at most MAX_MISSES_IN_FLIGHT fetches at
|
||||
* once (the rest queue), and one fetch for identical misses in flight (same
|
||||
* URL and Accept). `load(url, accept)` resolves to fetchTile's result.
|
||||
*/
|
||||
export function createMissLoader({
|
||||
fetchImpl = fetch,
|
||||
wait = sleep,
|
||||
maxInFlight = MAX_MISSES_IN_FLIGHT,
|
||||
} = {}) {
|
||||
const pending = new Map();
|
||||
const queue = [];
|
||||
let running = 0;
|
||||
const next = () => {
|
||||
while (running < maxInFlight && queue.length) {
|
||||
const job = queue.shift();
|
||||
running++;
|
||||
fetchTile(job.url, { accept: job.accept, fetchImpl, wait })
|
||||
.then(job.resolve, job.reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
pending.delete(job.key);
|
||||
next();
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
load(url, accept) {
|
||||
const key = `${url}\n${accept ?? ''}`;
|
||||
let promise = pending.get(key);
|
||||
if (!promise) {
|
||||
promise = new Promise((resolve, reject) =>
|
||||
queue.push({ key, url, accept, resolve, reject }),
|
||||
);
|
||||
pending.set(key, promise);
|
||||
next();
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the page's requests to CACHED_TILE_HOSTS from the cache (filling
|
||||
* it on a miss). Returns the counts.
|
||||
@@ -84,6 +131,7 @@ export async function installTileCache(
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const stats = { hits: 0, fetched: 0, cancelled: 0, failed: 0, errors: {} };
|
||||
const client = await page.createCDPSession();
|
||||
const misses = createMissLoader();
|
||||
client.on('Fetch.requestPaused', async ({ requestId, request }) => {
|
||||
const accept = Object.entries(request.headers ?? {}).find(
|
||||
([name]) => name.toLowerCase() === 'accept',
|
||||
@@ -101,7 +149,7 @@ export async function installTileCache(
|
||||
};
|
||||
stats.hits++;
|
||||
} else {
|
||||
const tile = await fetchTile(request.url, { accept });
|
||||
const tile = await misses.load(request.url, accept);
|
||||
// Only successes are cached; a 404 (no tile there) is passed on.
|
||||
if (tile.status === 200) {
|
||||
fs.writeFileSync(file, tile.body);
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
* Sharpen slider sets in 0.1..2.1). Omitted fields keep their value; a
|
||||
* non-boolean `enabled` or a negative or non-finite `amount` throws a
|
||||
* TypeError. Off with amount 1 until set. The Cesium engine only keeps the
|
||||
* state: in Cesium mode the app's own post stage sharpens.
|
||||
* state: in Cesium mode the app's own post stage sharpens and mirrors its
|
||||
* state here (src/ui/visualEffects.js).
|
||||
* @property {() => { enabled: boolean, amount: number }} sharpen
|
||||
* The sharpening last set (a copy).
|
||||
*
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// The cinematic look's brighter atmosphere (viewOptions.js
|
||||
// ATMOSPHERE_LUMINANCE_SCALE): the radiance-to-luminance factors of the
|
||||
// atmosphere's materials (sky, aerial perspective, clouds), multiplied by a
|
||||
// scale. Navara 0.2.1 has no public setting for them; they are uniforms of
|
||||
// the takram materials its descriptors build, reached through each handle's
|
||||
// public `ref.raw`, then fields of those objects that are not documented:
|
||||
//
|
||||
// - sky: ref.raw (the three Mesh) .material.uniforms;
|
||||
// - aerial perspective: ref.raw (Navara's Effect) .rawEffect.uniforms (a Map);
|
||||
// - clouds: ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms (the
|
||||
// clouds swap that material when their quality changes).
|
||||
//
|
||||
// Re-check each path before moving to another Navara version (as for
|
||||
// internals.js). Upstream ask: a sky luminance or atmosphere exposure that
|
||||
// leaves unlit content (the baked tiles) alone.
|
||||
|
||||
export const LUMINANCE_UNIFORMS = Object.freeze([
|
||||
'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE',
|
||||
'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE',
|
||||
]);
|
||||
|
||||
const uniformsOf = {
|
||||
sky: (raw) => raw?.material?.uniforms,
|
||||
aerialPerspective: (raw) => raw?.rawEffect?.uniforms,
|
||||
clouds: (raw) => raw?.rawEffect?.cloudsPass?.currentMaterial?.uniforms,
|
||||
};
|
||||
|
||||
const get = (uniforms, name) =>
|
||||
uniforms instanceof Map ? uniforms.get(name) : uniforms?.[name];
|
||||
|
||||
/**
|
||||
* Scales the atmosphere's luminance factors to `scale` times the values
|
||||
* their materials were built with. Idempotent: each factor's vector is
|
||||
* scaled once (Navara 0.2.1 shares one vector between the sky, the aerial
|
||||
* perspective and the clouds), and a material Navara rebuilds with a new
|
||||
* vector is scaled when it first shows.
|
||||
* @returns {(handles: { sky?: object, aerialPerspective?: object, clouds?: object }) => boolean}
|
||||
* applies the scale; true when it changed a uniform.
|
||||
*/
|
||||
export function createAtmosphereLuminance(scale) {
|
||||
const scaled = new WeakSet();
|
||||
return (handles) => {
|
||||
if (scale === 1) return false;
|
||||
let changed = false;
|
||||
for (const [name, read] of Object.entries(uniformsOf)) {
|
||||
const uniforms = read(handles[name]?.ref?.raw);
|
||||
if (!uniforms) continue;
|
||||
for (const key of LUMINANCE_UNIFORMS) {
|
||||
const uniform = get(uniforms, key);
|
||||
const value = uniform?.value;
|
||||
if (!value || typeof value.multiplyScalar !== 'function') continue;
|
||||
// Navara's materials share one vector per factor: scale it once.
|
||||
if (scaled.has(value)) continue;
|
||||
value.multiplyScalar(scale);
|
||||
scaled.add(value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { createNavaraCredits } from './credits.js';
|
||||
import { createNavaraInput } from './input.js';
|
||||
import { createNavaraMaps } from './maps.js';
|
||||
import { createNavaraSurface } from './surface.js';
|
||||
import { createGroundFloor } from './groundFloor.js';
|
||||
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
||||
|
||||
const { cartesian3, math } = geo;
|
||||
|
||||
@@ -246,5 +246,11 @@ export function createNavaraEngine(
|
||||
sharpenPort?.destroy();
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
// the camera and the margin it keeps above it (QA measures against it).
|
||||
groundFloor: {
|
||||
ground: () => groundFloor.ground(),
|
||||
margin: GROUND_FLOOR.MIN_ABOVE,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -400,3 +400,10 @@ test('the floor does not fight flights or a lookAt target', () => {
|
||||
near(heightOf(engine), 130, 1e-6, 'lifted once released');
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => {
|
||||
const { engine } = setup();
|
||||
assert.equal(typeof engine.groundFloor.ground, 'function');
|
||||
assert.equal(engine.groundFloor.margin, 30);
|
||||
assert.equal(engine.groundFloor.ground(), undefined, 'nothing sampled yet');
|
||||
});
|
||||
@@ -37,6 +37,11 @@ export const GROUND_FLOOR = Object.freeze({
|
||||
RESAMPLE_METRES: 20,
|
||||
/** Age (ms) after which the ground is sampled again. */
|
||||
RESAMPLE_MS: 250,
|
||||
/**
|
||||
* Age (ms) before a move resamples: a sample of the ground outside the
|
||||
* view costs about 1.6 ms (surface.js), too much for every frame.
|
||||
*/
|
||||
MIN_RESAMPLE_MS: 100,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -81,12 +86,19 @@ export function createGroundFloor({
|
||||
0,
|
||||
WGS84,
|
||||
);
|
||||
if (
|
||||
sample &&
|
||||
at - sample.at < GROUND_FLOOR.RESAMPLE_MS &&
|
||||
cartesian3.distance(point, sample.point) < GROUND_FLOOR.RESAMPLE_METRES
|
||||
)
|
||||
return sample.height;
|
||||
// Again after RESAMPLE_MS, or after a RESAMPLE_METRES move once
|
||||
// MIN_RESAMPLE_MS has passed (a pan does not sample every frame).
|
||||
if (sample) {
|
||||
const age = at - sample.at;
|
||||
const moved =
|
||||
cartesian3.distance(point, sample.point) >=
|
||||
GROUND_FLOOR.RESAMPLE_METRES;
|
||||
if (
|
||||
age < GROUND_FLOOR.RESAMPLE_MS &&
|
||||
!(moved && age >= GROUND_FLOOR.MIN_RESAMPLE_MS)
|
||||
)
|
||||
return sample.height;
|
||||
}
|
||||
const height =
|
||||
surface.sampleHeight(place) ?? surface.terrainHeight?.(place) ?? 0;
|
||||
sample = { point, at, height };
|
||||
|
||||
@@ -130,6 +130,26 @@ test('the ground is resampled after a move or a while, and the clamp only on rea
|
||||
state.position = at(2.36, 48.85, 500);
|
||||
state.ground = 140;
|
||||
floor.update();
|
||||
assert.equal(state.samples, 2, 'not within MIN_RESAMPLE_MS of the last');
|
||||
state.time += GROUND_FLOOR.MIN_RESAMPLE_MS;
|
||||
floor.update();
|
||||
assert.equal(state.samples, 3, 'resampled after a move');
|
||||
assert.equal(state.clamps.length, 2, 'a 40 m change is sent');
|
||||
});
|
||||
|
||||
test('a pan resamples at most every MIN_RESAMPLE_MS, however far it moves', () => {
|
||||
// A sample of the ground outside the view costs about 1.6 ms (surface.js):
|
||||
// not on every frame of a pan.
|
||||
assert.ok(GROUND_FLOOR.MIN_RESAMPLE_MS >= 100);
|
||||
const { floor, state } = setup({ ground: 100 });
|
||||
state.position = at(2.35, 48.85, 500);
|
||||
floor.update();
|
||||
for (let frame = 1; frame <= 30; frame++) {
|
||||
state.time = frame * 16;
|
||||
state.position = at(2.35 + frame * 0.001, 48.85, 500); // ~73 m a frame
|
||||
floor.update();
|
||||
}
|
||||
// 480 ms of panning: the first sample and one every 100 ms at most.
|
||||
assert.ok(state.samples <= 1 + Math.ceil(480 / GROUND_FLOOR.MIN_RESAMPLE_MS));
|
||||
assert.ok(state.samples >= 3, 'still follows the pan');
|
||||
});
|
||||
+29
-25
@@ -6,7 +6,7 @@ import ThreeView from '@navaramap/three';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { DefaultPlugin } from '@navaramap/three-default-plugin';
|
||||
import { createNavaraEngine } from './createNavaraEngine.js';
|
||||
import { navaraHooks } from './internals.js';
|
||||
import { forEachLuminanceUniform, navaraHooks } from './internals.js';
|
||||
import { buildNavaraLook } from './look.js';
|
||||
import { createNavaraSharpen } from './sharpen.js';
|
||||
import {
|
||||
@@ -33,28 +33,22 @@ function preservedCanvas(container) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** The camera's place in degrees (for the sun's elevation), or null. */
|
||||
function cameraPlace(camera) {
|
||||
try {
|
||||
const place = camera.pose().cartographic;
|
||||
return place
|
||||
? {
|
||||
lat: geo.math.toDegrees(place.latitude),
|
||||
lng: geo.math.toDegrees(place.longitude),
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// The camera's place for the look, refreshed in place each frame (no
|
||||
// allocation): degrees for the sun's elevation, and the height.
|
||||
const lookCarto = { longitude: 0, latitude: 0, height: 0 };
|
||||
const lookPlace = { lat: 0, lng: 0 };
|
||||
|
||||
/** The camera height, or NaN while the camera has no position yet. */
|
||||
function cameraHeight(camera) {
|
||||
try {
|
||||
return camera.height();
|
||||
} catch {
|
||||
return Number.NaN;
|
||||
}
|
||||
/** Fills lookPlace from the camera; false while it has no position yet. */
|
||||
function readCameraPlace(camera) {
|
||||
const place = geo.cartographic.fromCartesian(
|
||||
camera.livePosition,
|
||||
geo.ellipsoid.WGS84,
|
||||
lookCarto,
|
||||
);
|
||||
if (!place) return false;
|
||||
lookPlace.lat = geo.math.toDegrees(place.latitude);
|
||||
lookPlace.lng = geo.math.toDegrees(place.longitude);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The height of the ground in view, for the look's cloud rule: the depth of
|
||||
@@ -109,8 +103,12 @@ export async function createNavaraGlobe({
|
||||
});
|
||||
// The sun follows the engine clock (one time source for layers and the
|
||||
// look); the look only runs in preRender, after the engine exists.
|
||||
const look = buildNavaraLook(view, plugin, plan, () =>
|
||||
engine.clock.frameTime(),
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin,
|
||||
plan,
|
||||
() => engine.clock.frameTime(),
|
||||
{ forEachLuminanceUniform },
|
||||
);
|
||||
engine = createNavaraEngine(view, {
|
||||
hooks: navaraHooks(view),
|
||||
@@ -142,8 +140,14 @@ export async function createNavaraGlobe({
|
||||
engine.frames.preRender.addEventListener(() => {
|
||||
if (!moving && !groundSettled && engine.surface.tilesLoaded())
|
||||
readGround();
|
||||
const placed = readCameraPlace(camera);
|
||||
if (
|
||||
look.frame(cameraHeight(camera), moving, ground, cameraPlace(camera))
|
||||
look.frame(
|
||||
placed ? lookCarto.height : Number.NaN,
|
||||
moving,
|
||||
ground,
|
||||
placed ? lookPlace : null,
|
||||
)
|
||||
)
|
||||
engine.render.request('navara-look');
|
||||
}),
|
||||
|
||||
@@ -65,6 +65,71 @@ export function isIdle(view) {
|
||||
return view._isIdle === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cinematic look's atmosphere scale (look.js createLuminanceScaler):
|
||||
* the radiance-to-luminance factor uniforms of the takram materials behind
|
||||
* the look's sky, stars, aerial perspective and clouds handles. Private:
|
||||
* - sky, stars: handle.ref.raw (the three object) .material.uniforms;
|
||||
* - aerial perspective: handle.ref.raw.rawEffect.uniforms (a Map);
|
||||
* - clouds: handle.ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms
|
||||
* (the clouds swap that material when their quality changes).
|
||||
* Calls `visit(key, uniform, scaled)` for each, scaled false for the stars.
|
||||
* A handle whose object is built but has lost a path fails loudly: it
|
||||
* throws in the dev server and warns once in a build. Upstream ask: a sky
|
||||
* luminance or atmosphere exposure that leaves unlit content alone.
|
||||
*/
|
||||
export const LUMINANCE_UNIFORMS = Object.freeze([
|
||||
'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE',
|
||||
'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE',
|
||||
]);
|
||||
const DEV = Boolean(import.meta.env?.DEV);
|
||||
const reported = new Set();
|
||||
|
||||
function luminancePathLost(name) {
|
||||
const message = `Navara ${NAVARA_INTERNALS_VERSION}: the ${name} luminance uniforms are not where internals.js expects them`;
|
||||
if (DEV) throw new Error(message);
|
||||
if (reported.has(name)) return;
|
||||
reported.add(name);
|
||||
console.warn(`[globe] ${message}; the atmosphere stays unscaled there`);
|
||||
}
|
||||
|
||||
function visitLuminance(name, raw, uniforms, scaled, visit) {
|
||||
if (!raw) return; // not built yet
|
||||
const get = (key) =>
|
||||
uniforms instanceof Map ? uniforms.get(key) : uniforms?.[key];
|
||||
const sun = get(LUMINANCE_UNIFORMS[0]);
|
||||
const sky = get(LUMINANCE_UNIFORMS[1]);
|
||||
if (!sun?.value?.isVector3 || !sky?.value?.isVector3) {
|
||||
luminancePathLost(name);
|
||||
return;
|
||||
}
|
||||
visit(LUMINANCE_UNIFORMS[0], sun, scaled);
|
||||
visit(LUMINANCE_UNIFORMS[1], sky, scaled);
|
||||
}
|
||||
|
||||
export function forEachLuminanceUniform(handles, visit) {
|
||||
const sky = handles.sky?.ref?.raw;
|
||||
visitLuminance('sky', sky, sky?.material?.uniforms, true, visit);
|
||||
const stars = handles.stars?.ref?.raw;
|
||||
visitLuminance('stars', stars, stars?.material?.uniforms, false, visit);
|
||||
const aerial = handles.aerialPerspective?.ref?.raw;
|
||||
visitLuminance(
|
||||
'aerial perspective',
|
||||
aerial,
|
||||
aerial?.rawEffect?.uniforms,
|
||||
true,
|
||||
visit,
|
||||
);
|
||||
const clouds = handles.clouds?.ref?.raw;
|
||||
visitLuminance(
|
||||
'clouds',
|
||||
clouds,
|
||||
clouds?.rawEffect?.cloudsPass?.currentMaterial?.uniforms,
|
||||
true,
|
||||
visit,
|
||||
);
|
||||
}
|
||||
|
||||
/** The hooks createNavaraEngine takes. */
|
||||
export const navaraHooks = (view) => ({
|
||||
retainTileGeometry,
|
||||
|
||||
+119
-25
@@ -4,12 +4,14 @@
|
||||
// clock the HUD's SUN elevation reads unless QA moves it); the cinematic look
|
||||
// lights the globe
|
||||
// from the atmosphere when seen from space, shows its clouds only for low
|
||||
// views over low ground, brightens the atmosphere (atmosphereLuminance.js)
|
||||
// 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.
|
||||
// views over low ground and 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.
|
||||
|
||||
import { createAtmosphereLuminance } from './atmosphereLuminance.js';
|
||||
import {
|
||||
cloudsSunlitAt,
|
||||
cloudsVisibleAt,
|
||||
globeLightingAt,
|
||||
starsVisibleAt,
|
||||
@@ -25,9 +27,21 @@ import {
|
||||
* camera is moving, the height of the ground in view (metres above the
|
||||
* ellipsoid, NaN when unknown) and the camera's place (degrees, for the
|
||||
* sun's elevation there); it returns true when it changed the scene, so
|
||||
* the caller draws one more frame.
|
||||
* the caller draws one more frame. It allocates nothing on a frame that
|
||||
* changes nothing.
|
||||
* @param {() => number} [now] the sun's time (ms); the app passes the engine
|
||||
* clock's frame time (index.js), the wall clock by default and in tests
|
||||
* @param {{ forEachLuminanceUniform?: (handles: object, visit: (key: string, uniform: { value: object }, scaled: boolean) => void) => void }} [internals]
|
||||
* the takram uniforms behind the atmosphere scale (internals.js); without
|
||||
* it the scale does nothing.
|
||||
*/
|
||||
export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
|
||||
export function buildNavaraLook(
|
||||
view,
|
||||
plugin,
|
||||
plan,
|
||||
now = () => Date.now(),
|
||||
{ forEachLuminanceUniform = null } = {},
|
||||
) {
|
||||
const handles = [];
|
||||
if (plan.pluginDefault) {
|
||||
const scene = plugin.addDefaultPhotorealScene();
|
||||
@@ -50,28 +64,47 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
|
||||
const aerial = plan.globeLighting ? effect('aerialPerspective') : null;
|
||||
const clouds = effect('clouds');
|
||||
const stars = plan.daylightHidesStars ? mesh('stars') : null;
|
||||
const atmosphereHandles = {
|
||||
sky: mesh('sky'),
|
||||
aerialPerspective: effect('aerialPerspective'),
|
||||
clouds,
|
||||
};
|
||||
const brighten = createAtmosphereLuminance(plan.atmosphereLuminance ?? 1);
|
||||
const brighten = createLuminanceScaler(
|
||||
plan.atmosphereLuminance ?? 1,
|
||||
forEachLuminanceUniform,
|
||||
{
|
||||
sky: mesh('sky'),
|
||||
stars: mesh('stars'),
|
||||
aerialPerspective: effect('aerialPerspective'),
|
||||
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 starsShown = true;
|
||||
|
||||
const frame = (height, moving = false, ground = Number.NaN, place = null) => {
|
||||
// Every drawn frame: a material Navara rebuilds comes back unscaled.
|
||||
let changed = brighten(atmosphereHandles);
|
||||
let changed = brighten();
|
||||
const atmosphere = view.atmosphere;
|
||||
if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) {
|
||||
atmosphere.date = new Date(now());
|
||||
changed = true;
|
||||
}
|
||||
const elevation =
|
||||
place && typeof atmosphere?.getSunElevation === 'function'
|
||||
? atmosphere.getSunElevation(place)
|
||||
: Number.NaN;
|
||||
if (warmUp) {
|
||||
warmUp -= 1;
|
||||
aerial.update(warmUp ? IRRADIANCE_ON : IRRADIANCE_OFF);
|
||||
return true;
|
||||
}
|
||||
if (aerial) {
|
||||
const next = globeLightingAt(height, globeLit);
|
||||
// Switching recompiles the aerial perspective, a frame of about 0.8 s
|
||||
// headless: it switches on only at rest (a flight from city to city
|
||||
// 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)) {
|
||||
@@ -79,30 +112,28 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
|
||||
// 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({ aerialPerspective: { irradiance: next } });
|
||||
aerial.update(next ? IRRADIANCE_ON : IRRADIANCE_OFF);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Clouds only for a camera below them over low ground; switching them
|
||||
// costs no recompile, so it follows the camera in motion too.
|
||||
// Clouds only for a camera below them over low ground, and while the
|
||||
// sun is up at the camera (after sunset they read as dark puffs);
|
||||
// switching them costs no recompile, so it follows the camera in motion.
|
||||
if (clouds) {
|
||||
const next = cloudsVisibleAt(height, ground, cloudsShown);
|
||||
cloudsSunlit = cloudsSunlitAt(elevation, cloudsSunlit);
|
||||
const next = cloudsSunlit && cloudsVisibleAt(height, ground, cloudsShown);
|
||||
if (next !== cloudsShown) {
|
||||
cloudsShown = next;
|
||||
clouds.update({ visible: next });
|
||||
clouds.update(next ? VISIBLE : HIDDEN);
|
||||
parkCloudLayers(view.atmosphere, next);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (stars) {
|
||||
const elevation =
|
||||
place && typeof atmosphere?.getSunElevation === 'function'
|
||||
? atmosphere.getSunElevation(place)
|
||||
: Number.NaN;
|
||||
const next = starsVisibleAt(height, elevation, starsShown);
|
||||
if (next !== starsShown) {
|
||||
starsShown = next;
|
||||
stars.update({ visible: next });
|
||||
stars.update(next ? VISIBLE : HIDDEN);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -111,6 +142,69 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
|
||||
return { handles, frame };
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
/**
|
||||
* The atmosphere scale (viewOptions.js ATMOSPHERE_LUMINANCE_SCALE): each
|
||||
* luminance factor is set to `scale` times the base value recorded the
|
||||
* first time it is seen, never multiplied in place, so a vector Navara
|
||||
* shares between materials, or copies into a rebuilt one, is never scaled
|
||||
* twice. The stars keep the base value: when they share the sky's vector,
|
||||
* they get their own. Returns `apply()`, true when it changed a uniform.
|
||||
*/
|
||||
export function createLuminanceScaler(scale, forEachUniform, handles) {
|
||||
if (scale === 1 || !forEachUniform) return () => false;
|
||||
const bases = new Map(); // key -> { base, target }
|
||||
const scaledVectors = new WeakSet();
|
||||
let changed = false;
|
||||
const entry = (key, value) => {
|
||||
let found = bases.get(key);
|
||||
if (!found) {
|
||||
found = {
|
||||
base: value.clone(),
|
||||
target: value.clone().multiplyScalar(scale),
|
||||
};
|
||||
bases.set(key, found);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const record = (key, uniform) => entry(key, uniform.value);
|
||||
const scaleOne = (key, uniform, scaled) => {
|
||||
if (!scaled) return;
|
||||
const { target } = entry(key, uniform.value);
|
||||
scaledVectors.add(uniform.value);
|
||||
if (uniform.value.equals(target)) return;
|
||||
uniform.value.copy(target);
|
||||
changed = true;
|
||||
};
|
||||
const keepBase = (key, uniform, scaled) => {
|
||||
if (scaled) return;
|
||||
const { base } = entry(key, uniform.value);
|
||||
if (scaledVectors.has(uniform.value)) {
|
||||
uniform.value = base.clone();
|
||||
changed = true;
|
||||
} else if (!uniform.value.equals(base)) {
|
||||
uniform.value.copy(base);
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
changed = false;
|
||||
// Bases first, from the values as Navara built them.
|
||||
forEachUniform(handles, record);
|
||||
forEachUniform(handles, scaleOne);
|
||||
forEachUniform(handles, keepBase);
|
||||
return changed;
|
||||
};
|
||||
}
|
||||
|
||||
// The clouds hand the aerial perspective their last output through the
|
||||
// atmosphere (overlay, shadow, shadow length) once, when they first draw.
|
||||
// A hidden clouds pass leaves those in place, and the aerial perspective
|
||||
|
||||
+171
-110
@@ -37,6 +37,49 @@ const plugin = (calls) => ({
|
||||
},
|
||||
});
|
||||
|
||||
/** A cinematic look past its two warm-up frames (updates cleared). */
|
||||
function warmCinematic(view, internals) {
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
internals,
|
||||
);
|
||||
look.frame(900);
|
||||
look.frame(900);
|
||||
for (const entry of view.added) entry.updates.length = 0;
|
||||
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('the photoreal look is the default plugin scene, untouched', () => {
|
||||
const calls = [];
|
||||
const view = fakeView(0);
|
||||
@@ -68,12 +111,7 @@ 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', () => {
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 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');
|
||||
@@ -92,12 +130,7 @@ test('from space the globe is lit by the atmosphere; near the ground the tiles k
|
||||
|
||||
test('the clouds show only below their tops, switching in motion too', () => {
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
);
|
||||
const look = warmCinematic(view);
|
||||
const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds'));
|
||||
assert.equal(look.frame(900, true), false, 'shown at street level');
|
||||
assert.equal(look.frame(20_000, true), true);
|
||||
@@ -119,12 +152,7 @@ test('the clouds show only below their tops, switching in motion too', () => {
|
||||
|
||||
test('globe lighting switches on only at rest, and off even in motion', () => {
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
);
|
||||
const look = warmCinematic(view);
|
||||
const aerial = view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'aerialPerspective'),
|
||||
);
|
||||
@@ -162,7 +190,9 @@ const vec = (x, y, z) => ({
|
||||
y,
|
||||
z,
|
||||
copy(v) {
|
||||
Object.assign(this, { x: v.x, y: v.y, z: v.z });
|
||||
this.x = v.x;
|
||||
this.y = v.y;
|
||||
this.z = v.z;
|
||||
return this;
|
||||
},
|
||||
multiplyScalar(k) {
|
||||
@@ -174,103 +204,142 @@ const vec = (x, y, z) => ({
|
||||
clone() {
|
||||
return vec(this.x, this.y, this.z);
|
||||
},
|
||||
equals(v) {
|
||||
return v.x === this.x && v.y === this.y && v.z === this.z;
|
||||
},
|
||||
});
|
||||
const luminanceUniforms = () => ({
|
||||
SUN_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.3, 0.9, 0.9) },
|
||||
SKY_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.5, 0.9, 0.8) },
|
||||
const SUN = 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE';
|
||||
const SKY = 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE';
|
||||
const factors = () => ({
|
||||
[SUN]: { value: vec(1.3, 0.9, 0.9) },
|
||||
[SKY]: { value: vec(1.5, 0.9, 0.8) },
|
||||
});
|
||||
|
||||
/** Gives the cinematic handles the raw objects Navara 0.2.1 builds. */
|
||||
function withRawAtmosphere(view) {
|
||||
const find = (name) =>
|
||||
view.added.find(({ desc }) => Object.hasOwn(desc, name));
|
||||
const sky = luminanceUniforms();
|
||||
const aerial = new Map(Object.entries(luminanceUniforms()));
|
||||
const clouds = luminanceUniforms();
|
||||
const stars = luminanceUniforms();
|
||||
find('sky').ref = { raw: { material: { uniforms: sky } } };
|
||||
find('stars').ref = { raw: { material: { uniforms: stars } } };
|
||||
find('aerialPerspective').ref = { raw: { rawEffect: { uniforms: aerial } } };
|
||||
find('clouds').ref = {
|
||||
raw: {
|
||||
rawEffect: { cloudsPass: { currentMaterial: { uniforms: clouds } } },
|
||||
/** A stand-in for internals.js forEachLuminanceUniform over fake materials. */
|
||||
function fakeInternals(materials) {
|
||||
return {
|
||||
forEachLuminanceUniform(handles, visit) {
|
||||
for (const [name, scaled] of [
|
||||
['sky', true],
|
||||
['stars', false],
|
||||
['aerialPerspective', true],
|
||||
['clouds', true],
|
||||
]) {
|
||||
if (!handles[name]) continue;
|
||||
const uniforms = materials[name];
|
||||
if (!uniforms) continue;
|
||||
for (const key of [SUN, SKY]) visit(key, uniforms[key], scaled);
|
||||
}
|
||||
},
|
||||
};
|
||||
return { sky, aerial, clouds, stars };
|
||||
}
|
||||
|
||||
test('the cinematic atmosphere is brighter, the stars and tiles are not', () => {
|
||||
const k = navaraScenePlan('cinematic').atmosphereLuminance;
|
||||
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-12, `${a} vs ${b}`);
|
||||
|
||||
test('the atmosphere factors are set to scale × base, never compounded', () => {
|
||||
const materials = {
|
||||
sky: factors(),
|
||||
stars: factors(),
|
||||
aerialPerspective: factors(),
|
||||
clouds: factors(),
|
||||
};
|
||||
const view = fakeView(0);
|
||||
const plan = navaraScenePlan('cinematic');
|
||||
const look = buildNavaraLook(view, plugin([]), plan, () => 0);
|
||||
const raw = withRawAtmosphere(view);
|
||||
assert.equal(look.frame(900), true, 'the first frame applies the scale');
|
||||
const k = plan.atmosphereLuminance;
|
||||
const sky = (u) => u.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value;
|
||||
const sun = (u) => u.SUN_SPECTRAL_RADIANCE_TO_LUMINANCE.value;
|
||||
assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12);
|
||||
assert.ok(Math.abs(sun(raw.sky).x - 1.3 * k) < 1e-12);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
raw.aerial.get('SKY_SPECTRAL_RADIANCE_TO_LUMINANCE').value.x - 1.5 * k,
|
||||
) < 1e-12,
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
fakeInternals(materials),
|
||||
);
|
||||
assert.ok(Math.abs(sky(raw.clouds).z - 0.8 * k) < 1e-12);
|
||||
assert.equal(
|
||||
sky(raw.stars).x,
|
||||
1.5,
|
||||
'only the sky, aerial perspective and clouds are walked',
|
||||
);
|
||||
assert.equal(look.frame(900), false, 'applied once, not compounded');
|
||||
assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12);
|
||||
// Navara rebuilds a material (a cloud quality change): the new one is scaled.
|
||||
const fresh = luminanceUniforms();
|
||||
view.added.find(({ desc }) =>
|
||||
Object.hasOwn(desc, 'clouds'),
|
||||
).ref.raw.rawEffect.cloudsPass.currentMaterial = { uniforms: fresh };
|
||||
look.frame(900);
|
||||
near(materials.sky[SKY].value.x, 1.5 * k);
|
||||
near(materials.sky[SUN].value.x, 1.3 * k);
|
||||
near(materials.aerialPerspective[SKY].value.z, 0.8 * k);
|
||||
near(materials.clouds[SKY].value.x, 1.5 * k);
|
||||
assert.equal(materials.stars[SKY].value.x, 1.5, 'the stars keep the base');
|
||||
look.frame(900);
|
||||
look.frame(900);
|
||||
assert.equal(look.frame(900), false, 'nothing to change once applied');
|
||||
near(materials.sky[SKY].value.x, 1.5 * k);
|
||||
// A rebuilt material copying the scaled vector is not scaled again.
|
||||
materials.clouds = {
|
||||
[SUN]: { value: materials.sky[SUN].value.clone() },
|
||||
[SKY]: { value: materials.sky[SKY].value.clone() },
|
||||
};
|
||||
assert.equal(look.frame(900), false);
|
||||
near(materials.clouds[SKY].value.x, 1.5 * k);
|
||||
// One built from scratch is.
|
||||
materials.clouds = factors();
|
||||
assert.equal(look.frame(900), true);
|
||||
assert.ok(Math.abs(sky(fresh).x - 1.5 * k) < 1e-12);
|
||||
near(materials.clouds[SKY].value.x, 1.5 * k);
|
||||
});
|
||||
|
||||
test('a look without the scale and an atmosphere not built yet are left alone', () => {
|
||||
test('vectors shared between materials are scaled once; stars sharing them get their own', () => {
|
||||
// Separate uniforms holding one vector each, as Navara 0.2.1 builds them.
|
||||
const shared = factors();
|
||||
const sharing = () => ({
|
||||
[SUN]: { value: shared[SUN].value },
|
||||
[SKY]: { value: shared[SKY].value },
|
||||
});
|
||||
const materials = {
|
||||
sky: shared,
|
||||
stars: sharing(),
|
||||
aerialPerspective: sharing(),
|
||||
clouds: sharing(),
|
||||
};
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
fakeInternals(materials),
|
||||
);
|
||||
look.frame(900);
|
||||
look.frame(900);
|
||||
near(shared[SKY].value.x, 1.5 * k);
|
||||
assert.notEqual(materials.stars[SKY].value, shared[SKY].value);
|
||||
assert.equal(materials.stars[SKY].value.x, 1.5);
|
||||
assert.equal(materials.stars[SUN].value.x, 1.3);
|
||||
});
|
||||
|
||||
test('a look without the scale, or without internals, leaves the factors alone', () => {
|
||||
const materials = { sky: factors(), stars: factors() };
|
||||
const view = fakeView(0);
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('minimal'),
|
||||
() => 0,
|
||||
fakeInternals(materials),
|
||||
);
|
||||
const sky = luminanceUniforms();
|
||||
view.added[0].ref = { raw: { material: { uniforms: sky } } };
|
||||
assert.equal(look.frame(900), false);
|
||||
assert.equal(sky.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x, 1.5);
|
||||
const cinematic = fakeView(0);
|
||||
const other = buildNavaraLook(
|
||||
cinematic,
|
||||
assert.equal(materials.sky[SKY].value.x, 1.5);
|
||||
const other = fakeView(0);
|
||||
const cinematic = buildNavaraLook(
|
||||
other,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
);
|
||||
assert.equal(other.frame(900), false, 'no raw objects yet: nothing to scale');
|
||||
cinematic.frame(900);
|
||||
cinematic.frame(900);
|
||||
assert.equal(cinematic.frame(900), false, 'no internals: no scale');
|
||||
});
|
||||
|
||||
test('the cinematic stars hide in a daylit sky and show at night and from space', () => {
|
||||
const view = fakeView(0);
|
||||
let elevation = 40;
|
||||
view.atmosphere.getSunElevation = () => elevation;
|
||||
const look = buildNavaraLook(
|
||||
view,
|
||||
plugin([]),
|
||||
navaraScenePlan('cinematic'),
|
||||
() => 0,
|
||||
);
|
||||
const look = warmCinematic(view);
|
||||
const stars = view.added.find(({ desc }) => Object.hasOwn(desc, 'stars'));
|
||||
const place = { lat: 40.7, lng: -74 };
|
||||
assert.equal(look.frame(900, false, 0, place), true);
|
||||
assert.deepEqual(stars.updates, [{ visible: false }]);
|
||||
assert.equal(look.frame(900, false, 0, place), false);
|
||||
elevation = -10;
|
||||
assert.equal(look.frame(900, false, 0, place), true);
|
||||
look.frame(900, false, 0, place);
|
||||
assert.deepEqual(stars.updates.at(-1), { visible: true });
|
||||
elevation = 40;
|
||||
look.frame(900, false, 0, place);
|
||||
@@ -279,11 +348,31 @@ test('the cinematic stars hide in a daylit sky and show at night and from space'
|
||||
const count = stars.updates.length;
|
||||
look.frame(900, false, 0);
|
||||
assert.equal(stars.updates.length, count, 'no place keeps them');
|
||||
assert.equal(look.frame(900, false, 0, place), true, 'back down by day');
|
||||
look.frame(900, false, 0, place);
|
||||
assert.deepEqual(stars.updates.at(-1), { visible: false });
|
||||
});
|
||||
|
||||
test('the minimal look keeps its stars whatever the sun', () => {
|
||||
test('the cinematic clouds hide after sunset at the camera', () => {
|
||||
const view = fakeView(0);
|
||||
let elevation = 20;
|
||||
view.atmosphere.getSunElevation = () => elevation;
|
||||
const look = warmCinematic(view);
|
||||
const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds'));
|
||||
const place = { lat: 40.7, lng: -74 };
|
||||
assert.equal(look.frame(900, false, 0, place), true, 'the stars hide');
|
||||
assert.deepEqual(clouds.updates, []);
|
||||
elevation = -1;
|
||||
assert.equal(look.frame(900, false, 0, place), true);
|
||||
assert.deepEqual(clouds.updates, [{ visible: false }]);
|
||||
elevation = 1;
|
||||
look.frame(900, false, 0, place);
|
||||
assert.deepEqual(clouds.updates, [{ visible: false }], 'hysteresis');
|
||||
elevation = 3;
|
||||
look.frame(900, false, 0, place);
|
||||
assert.deepEqual(clouds.updates.at(-1), { visible: true });
|
||||
});
|
||||
|
||||
test('the minimal look keeps its stars and clouds whatever the sun', () => {
|
||||
const view = fakeView(0);
|
||||
view.atmosphere.getSunElevation = () => 40;
|
||||
const look = buildNavaraLook(
|
||||
@@ -295,31 +384,3 @@ test('the minimal look keeps its stars whatever the sun', () => {
|
||||
look.frame(900, false, 0, { lat: 0, lng: 0 });
|
||||
assert.deepEqual(view.added[1].updates, []);
|
||||
});
|
||||
|
||||
test('a factor vector the materials share is scaled once', () => {
|
||||
const view = fakeView(0);
|
||||
const plan = navaraScenePlan('cinematic');
|
||||
const look = buildNavaraLook(view, plugin([]), plan, () => 0);
|
||||
const shared = luminanceUniforms();
|
||||
const find = (name) =>
|
||||
view.added.find(({ desc }) => Object.hasOwn(desc, name));
|
||||
find('sky').ref = { raw: { material: { uniforms: shared } } };
|
||||
find('aerialPerspective').ref = {
|
||||
raw: { rawEffect: { uniforms: new Map(Object.entries(shared)) } },
|
||||
};
|
||||
find('clouds').ref = {
|
||||
raw: {
|
||||
rawEffect: {
|
||||
cloudsPass: { currentMaterial: { uniforms: { ...shared } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
look.frame(900);
|
||||
look.frame(900);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
shared.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x -
|
||||
1.5 * plan.atmosphereLuminance,
|
||||
) < 1e-12,
|
||||
);
|
||||
});
|
||||
@@ -34,6 +34,7 @@ const fragmentShader = /* glsl */ `
|
||||
uniform float amount;
|
||||
uniform sampler2D tSceneDepth;
|
||||
uniform bool uSkipSky;
|
||||
const float SKY_DEPTH = 0.99999;
|
||||
|
||||
vec3 encodeGamma(vec3 color) {
|
||||
return pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
@@ -44,7 +45,8 @@ vec3 sampleGamma(vec2 uv) {
|
||||
}
|
||||
|
||||
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
|
||||
if (uSkipSky && unpackRGBAToDepth(texture2D(tSceneDepth, uv)) >= 1.0) {
|
||||
// The far plane, with a margin: packing to RGBA8 rounds near 1.0.
|
||||
if (uSkipSky && unpackRGBAToDepth(texture2D(tSceneDepth, uv)) > SKY_DEPTH) {
|
||||
outputColor = inputColor;
|
||||
return;
|
||||
}
|
||||
|
||||
+75
-42
@@ -58,25 +58,23 @@ const b = new Vector3();
|
||||
const c = new Vector3();
|
||||
const point = new Vector3();
|
||||
|
||||
// Meshes with CPU positions under the tile roots, minus `exclude`: the
|
||||
// shown ones, and the loaded ones Navara hides (outside the frustum, or
|
||||
// parents of the tiles it shows), which still hold their geometry.
|
||||
function tileMeshes(roots, exclude) {
|
||||
const shown = [];
|
||||
const hidden = [];
|
||||
const stack = roots
|
||||
.filter((root) => !exclude.has(root))
|
||||
.map((root) => [root, root.visible]);
|
||||
while (stack.length) {
|
||||
const [object, visible] = stack.pop();
|
||||
if (object.isMesh && object.geometry?.attributes?.position?.array)
|
||||
(visible ? shown : hidden).push(object);
|
||||
for (const child of object.children)
|
||||
if (!exclude.has(child)) stack.push([child, visible && child.visible]);
|
||||
}
|
||||
return { shown, hidden };
|
||||
const hasPositions = (object) =>
|
||||
object.isMesh && Boolean(object.geometry?.attributes?.position?.array);
|
||||
|
||||
// The meshes with CPU positions under `object`, minus `exclude`, into
|
||||
// `out`; with `shownOnly`, only through visible objects.
|
||||
function collectMeshes(object, exclude, shownOnly, out) {
|
||||
if (exclude.has(object) || (shownOnly && !object.visible)) return;
|
||||
if (hasPositions(object)) out.push(object);
|
||||
const children = object.children;
|
||||
for (let i = 0; i < children.length; i++)
|
||||
collectMeshes(children[i], exclude, shownOnly, out);
|
||||
}
|
||||
|
||||
// Reused between calls (sampleHeight runs per frame in the ground floor).
|
||||
const shownMeshes = [];
|
||||
const tileMeshesScratch = [];
|
||||
|
||||
// Nearest hit along worldRay with one mesh, in metres. Not Mesh.raycast: that
|
||||
// interpolates UVs and normals, whose arrays Navara has released.
|
||||
function meshHitDistance(mesh, far) {
|
||||
@@ -137,33 +135,65 @@ function nearestHit(meshes, ray) {
|
||||
return nearest;
|
||||
}
|
||||
|
||||
// Among hidden tiles several levels of detail overlap: the hit on the
|
||||
// finest tiles (bounding radius within 1.5 times the smallest hit: a
|
||||
// quadtree parent is about twice its child), the top one
|
||||
// among them. The tiles the ray passes are tried finest first, and coarser
|
||||
// ones stop being tried once a hit sets the limit, so the coarse parents'
|
||||
// triangles are never walked.
|
||||
function finestHit(meshes, ray) {
|
||||
worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z);
|
||||
worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z);
|
||||
const candidates = [];
|
||||
for (const mesh of meshes) {
|
||||
// A tile's extent: the bounding sphere around all its meshes, in
|
||||
// Earth-fixed metres. Navara 0.2.1 exposes no geometric error or level on a
|
||||
// tile, and a quadtree tile's extent halves with each level, so the extent
|
||||
// orders them. Tiles do not move once loaded: computed once per tile.
|
||||
const extents = new WeakMap();
|
||||
function tileExtent(root) {
|
||||
let extent = extents.get(root);
|
||||
if (extent) return extent;
|
||||
tileMeshesScratch.length = 0;
|
||||
collectMeshes(root, NO_EXCLUDE, false, tileMeshesScratch);
|
||||
if (!tileMeshesScratch.length) return null;
|
||||
const bounds = new Sphere();
|
||||
for (const mesh of tileMeshesScratch) {
|
||||
const geometry = mesh.geometry;
|
||||
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
|
||||
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
|
||||
if (worldRay.intersectsSphere(sphere))
|
||||
candidates.push([sphere.radius, mesh]);
|
||||
if (bounds.isEmpty()) bounds.copy(sphere);
|
||||
else bounds.union(sphere);
|
||||
}
|
||||
candidates.sort((x, y) => x[0] - y[0]);
|
||||
extent = bounds;
|
||||
extents.set(root, extent);
|
||||
return extent;
|
||||
}
|
||||
const NO_EXCLUDE = new Set();
|
||||
|
||||
// Where the shown tiles miss, the loaded tiles Navara hides (outside the
|
||||
// frustum, or parents of the tiles it shows) answer. Several levels of
|
||||
// detail overlap there: the hit on the finest tiles (extent within 1.5
|
||||
// times the finest tile hit; a quadtree parent is about twice its child),
|
||||
// the top one among them. Tiles the ray passes are tried finest first, and
|
||||
// coarser ones stop being tried once a hit sets the limit, so the coarse
|
||||
// parents' triangles are never walked.
|
||||
const hiddenTiles = [];
|
||||
function finestHiddenHit(roots, exclude, ray) {
|
||||
worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z);
|
||||
worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z);
|
||||
hiddenTiles.length = 0;
|
||||
for (let i = 0; i < roots.length; i++) {
|
||||
const root = roots[i];
|
||||
if (root.visible || exclude.has(root)) continue;
|
||||
const extent = tileExtent(root);
|
||||
if (extent && worldRay.intersectsSphere(extent)) hiddenTiles.push(root);
|
||||
}
|
||||
hiddenTiles.sort((x, y) => extents.get(x).radius - extents.get(y).radius);
|
||||
let limit = Infinity;
|
||||
let nearest = Infinity;
|
||||
for (const [radius, mesh] of candidates) {
|
||||
for (const root of hiddenTiles) {
|
||||
const radius = extents.get(root).radius;
|
||||
if (radius > limit) break;
|
||||
const distance = meshHitDistance(mesh, ray.far);
|
||||
if (!Number.isFinite(distance)) continue;
|
||||
tileMeshesScratch.length = 0;
|
||||
collectMeshes(root, exclude, false, tileMeshesScratch);
|
||||
let hit = Infinity;
|
||||
for (const mesh of tileMeshesScratch)
|
||||
hit = Math.min(hit, meshHitDistance(mesh, ray.far));
|
||||
if (!Number.isFinite(hit)) continue;
|
||||
if (limit === Infinity) limit = 1.5 * radius;
|
||||
nearest = Math.min(nearest, distance);
|
||||
nearest = Math.min(nearest, hit);
|
||||
}
|
||||
hiddenTiles.length = 0;
|
||||
return nearest;
|
||||
}
|
||||
|
||||
@@ -194,14 +224,17 @@ export function createNavaraSurface(view, hooks) {
|
||||
const sampleHeight = (place, exclude) => {
|
||||
if (!supported) return undefined;
|
||||
const ray = surfaceRay(place);
|
||||
const { shown, hidden } = tileMeshes(
|
||||
hooks.tileRoots(),
|
||||
new Set(exclude ?? []),
|
||||
);
|
||||
const roots = hooks.tileRoots();
|
||||
const excluded = exclude ? new Set(exclude) : NO_EXCLUDE;
|
||||
// The tiles on screen first; where they do not reach (the ground under
|
||||
// a camera looking ahead is outside the frustum), the loaded hidden ones.
|
||||
let distance = nearestHit(shown, ray);
|
||||
if (!Number.isFinite(distance)) distance = finestHit(hidden, ray);
|
||||
// a camera looking ahead is outside the frustum), the hidden ones.
|
||||
shownMeshes.length = 0;
|
||||
for (let i = 0; i < roots.length; i++)
|
||||
collectMeshes(roots[i], excluded, true, shownMeshes);
|
||||
let distance = nearestHit(shownMeshes, ray);
|
||||
shownMeshes.length = 0;
|
||||
if (!Number.isFinite(distance))
|
||||
distance = finestHiddenHit(roots, excluded, ray);
|
||||
return heightAlongRay(ray, distance) ?? terrainHeight(place);
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -190,3 +190,28 @@ test('tilesLoaded follows Navara idle; a failed geometry patch means unsupported
|
||||
assert.equal(surface.supported, false);
|
||||
assert.equal(surface.clampToHeightSupported, false);
|
||||
});
|
||||
|
||||
test('among hidden tiles the finest tile wins by its extent, not by a small mesh in a coarse tile', () => {
|
||||
// A coarse hidden tile (800 m across) carries a small 20 m mesh 5 m above
|
||||
// the fine hidden tile's ground: by mesh size the coarse tile's small mesh
|
||||
// would win; by tile extent the fine tile does.
|
||||
const fine = tileAt(10, 45, 40);
|
||||
const coarse = tileAt(10, 45, 30, 400);
|
||||
const coarseRoof = tileAt(10, 45, 45, 10);
|
||||
// One coarse tile with two meshes, both relative to its own centre.
|
||||
coarseRoof.tile.remove(coarseRoof.mesh);
|
||||
coarseRoof.mesh.position
|
||||
.copy(coarseRoof.tile.position)
|
||||
.sub(coarse.tile.position);
|
||||
coarse.tile.add(coarseRoof.mesh);
|
||||
coarse.tile.updateMatrixWorld(true);
|
||||
fine.tile.visible = false;
|
||||
coarse.tile.visible = false;
|
||||
const { surface } = setup({ tiles: [coarse.tile, fine.tile] });
|
||||
near(
|
||||
surface.sampleHeight(cartographic.fromDegrees(10, 45, 0)),
|
||||
40,
|
||||
0.05,
|
||||
'the fine tile',
|
||||
);
|
||||
});
|
||||
@@ -100,11 +100,10 @@ export function cloudsVisibleAt(height, ground, current = true) {
|
||||
// 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; Navara's stars share them) by this scale instead of raising
|
||||
// perspective; 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;
|
||||
const STAR_INTENSITY = 40;
|
||||
|
||||
// Stars show from space or once the sun is well below the horizon at the
|
||||
// camera (civil twilight), and hide in a daylit sky. Gaps keep a camera
|
||||
@@ -114,6 +113,19 @@ export const STARS_HIDE_SUN_ABOVE_DEG = -4;
|
||||
export const STARS_SPACE_ENTER_M = 100_000;
|
||||
export const STARS_SPACE_EXIT_M = 80_000;
|
||||
|
||||
// The cinematic clouds hide once the sun at the camera sets (below 0°):
|
||||
// unlit, they read as dark puffs over a dusk sky. They show again above 2°.
|
||||
export const CLOUDS_HIDE_SUN_BELOW_DEG = 0;
|
||||
export const CLOUDS_SHOW_SUN_ABOVE_DEG = 2;
|
||||
|
||||
/** Whether the sun is up enough for the cinematic clouds; NaN keeps `current`. */
|
||||
export function cloudsSunlitAt(sunElevation, current = true) {
|
||||
if (!Number.isFinite(sunElevation)) return current;
|
||||
return current
|
||||
? sunElevation >= CLOUDS_HIDE_SUN_BELOW_DEG
|
||||
: sunElevation > CLOUDS_SHOW_SUN_ABOVE_DEG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cinematic stars show for a camera at `height` (metres above
|
||||
* the ellipsoid) with the sun `sunElevation` degrees above its horizon; an
|
||||
@@ -140,13 +152,13 @@ export function sunDateStale(atmosphereMs, nowMs, toleranceMs = 60_000) {
|
||||
const cinematic = (mobile) => ({
|
||||
pluginDefault: false,
|
||||
// Brighter, larger stars (defaults 10 and 1 px) so the night sky and the
|
||||
// globe from space keep a visible starfield next to a sunlit Earth: 40,
|
||||
// divided by the atmosphere scale the stars share.
|
||||
// globe from space keep a visible starfield next to a sunlit Earth. The
|
||||
// atmosphere scale leaves the stars at their base factor (look.js).
|
||||
meshes: [
|
||||
{ sky: {} },
|
||||
{
|
||||
stars: {
|
||||
intensity: STAR_INTENSITY / ATMOSPHERE_LUMINANCE_SCALE,
|
||||
intensity: 120,
|
||||
pointSize: 2,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
STARS_SPACE_ENTER_M,
|
||||
STARS_SPACE_EXIT_M,
|
||||
starsVisibleAt,
|
||||
CLOUDS_HIDE_SUN_BELOW_DEG,
|
||||
CLOUDS_SHOW_SUN_ABOVE_DEG,
|
||||
cloudsSunlitAt,
|
||||
} from './viewOptions.js';
|
||||
|
||||
test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => {
|
||||
@@ -84,7 +87,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no
|
||||
assert.equal(plan.pluginDefault, false);
|
||||
assert.deepEqual(plan.meshes, [
|
||||
{ sky: {} },
|
||||
{ stars: { intensity: 40 / ATMOSPHERE_LUMINANCE_SCALE, pointSize: 2 } },
|
||||
{ stars: { intensity: 120, pointSize: 2 } },
|
||||
]);
|
||||
assert.deepEqual(plan.lights, [
|
||||
{ skyLightProbe: {} },
|
||||
@@ -214,3 +217,12 @@ test('stars show at night and from space, not in a daylit sky, with hysteresis',
|
||||
assert.ok(STARS_SHOW_SUN_BELOW_DEG < STARS_HIDE_SUN_ABOVE_DEG);
|
||||
assert.ok(STARS_SPACE_EXIT_M < STARS_SPACE_ENTER_M);
|
||||
});
|
||||
|
||||
test('the clouds hide once the sun sets at the camera, with hysteresis', () => {
|
||||
assert.equal(cloudsSunlitAt(40, true), true);
|
||||
assert.equal(cloudsSunlitAt(CLOUDS_HIDE_SUN_BELOW_DEG - 0.1, true), false);
|
||||
assert.equal(cloudsSunlitAt(1, false), false, 'inside the gap');
|
||||
assert.equal(cloudsSunlitAt(1, true), true, 'inside the gap');
|
||||
assert.equal(cloudsSunlitAt(CLOUDS_SHOW_SUN_ABOVE_DEG + 0.1, false), true);
|
||||
assert.equal(cloudsSunlitAt(Number.NaN, false), false, 'unknown keeps it');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
backoffMs,
|
||||
createMissLoader,
|
||||
fetchTile,
|
||||
tileCacheFile,
|
||||
} from '../../scripts/qa/tileCache.mjs';
|
||||
@@ -69,3 +70,46 @@ test('a tile fetch retries 429s, stops after four retries, keeps Accept', async
|
||||
assert.equal(limited.status, 429);
|
||||
assert.equal(calls, 5);
|
||||
});
|
||||
|
||||
test('misses: at most two fetches in flight, identical ones merged, a contact UA', async () => {
|
||||
let inFlight = 0;
|
||||
let most = 0;
|
||||
const calls = [];
|
||||
const releases = [];
|
||||
const loader = createMissLoader({
|
||||
fetchImpl: (url, init) => {
|
||||
calls.push([url, init.headers['User-Agent']]);
|
||||
inFlight++;
|
||||
most = Math.max(most, inFlight);
|
||||
return new Promise((resolve) =>
|
||||
releases.push(() => {
|
||||
inFlight--;
|
||||
resolve(response(200, { 'content-type': 'image/png' }));
|
||||
}),
|
||||
);
|
||||
},
|
||||
wait: async () => {},
|
||||
});
|
||||
const loads = [
|
||||
loader.load('a', null),
|
||||
loader.load('a', null),
|
||||
loader.load('b', null),
|
||||
loader.load('c', null),
|
||||
loader.load('a', 'image/webp'),
|
||||
];
|
||||
for (let round = 0; round < 10 && releases.length; round++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
releases.splice(0).forEach((release) => release());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
const tiles = await Promise.all(loads);
|
||||
assert.ok(tiles.every((tile) => tile.status === 200));
|
||||
assert.equal(tiles[0], tiles[1], 'one fetch for two identical misses');
|
||||
assert.equal(calls.length, 4, 'a, b, c and a with another Accept');
|
||||
assert.ok(most <= 2, `${most} fetches in flight`);
|
||||
assert.ok(
|
||||
calls.every(([, agent]) =>
|
||||
agent.includes('https://github.com/bilawalsidhu/gods-eye-view'),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -32,12 +32,11 @@ export class VisualEffects {
|
||||
createStage = (options) => new PostProcessStage(options),
|
||||
}) {
|
||||
this.viewer = viewer;
|
||||
// Cesium sharpens with its own post stage below; an engine without a
|
||||
// viewer (Navara) sharpens in its render pipeline.
|
||||
// Cesium sharpens with its own post stage below, and the engine's
|
||||
// render.sharpen() mirrors that stage (the Cesium engine keeps the state
|
||||
// only); an engine without a viewer (Navara) sharpens in its pipeline.
|
||||
this.engineRender =
|
||||
!viewer && typeof engine?.render?.setSharpen === 'function'
|
||||
? engine.render
|
||||
: null;
|
||||
typeof engine?.render?.setSharpen === 'function' ? engine.render : null;
|
||||
this.requestRender = requestRender;
|
||||
this.holdRender = holdRender;
|
||||
this.releaseRender = releaseRender;
|
||||
@@ -118,6 +117,7 @@ export class VisualEffects {
|
||||
});
|
||||
this.sharpenStage.enabled = false;
|
||||
this.viewer.scene.postProcessStages.add(this.sharpenStage);
|
||||
this.engineRender?.setSharpen({ enabled: false });
|
||||
this.applySharpenIntensity(sharpenIntensity);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,14 +223,32 @@ test('without a viewer the engine sharpens with the same amount scale', () => {
|
||||
effects.destroy();
|
||||
});
|
||||
|
||||
test('with a viewer the Cesium stage sharpens and the engine is left alone', () => {
|
||||
let calls = 0;
|
||||
const f = fixture({ render: { setSharpen: () => calls++ } });
|
||||
test('with a viewer the Cesium stage sharpens and the engine mirrors its state', () => {
|
||||
let state = { enabled: false, amount: 1 };
|
||||
const engine = {
|
||||
render: {
|
||||
setSharpen: (options) => {
|
||||
state = { ...state, ...options };
|
||||
},
|
||||
sharpen: () => ({ ...state }),
|
||||
},
|
||||
};
|
||||
const f = fixture(engine);
|
||||
f.effects.initPostProcess(0.49);
|
||||
assert.deepEqual(engine.render.sharpen(), {
|
||||
enabled: false,
|
||||
amount: f.effects.sharpenStage.uniforms.amount,
|
||||
});
|
||||
f.effects.applySharpenIntensity(0.8);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(f.effects.sharpenStage.uniforms.amount, sharpenAmount(0.8));
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
assert.deepEqual(engine.render.sharpen(), {
|
||||
enabled: true,
|
||||
amount: sharpenAmount(0.8),
|
||||
});
|
||||
f.effects.setSharpenEnabled(false);
|
||||
assert.equal(engine.render.sharpen().enabled, false);
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user