fix(globe): the cinematic look's atmosphere scale, warm-up and sunset clouds

- The takram uniform paths move to internals.js (forEachLuminanceUniform,
  loud in dev when a path is gone); look.js sets each factor to scale x
  the recorded base value, so shared or copied vectors are never scaled
  twice, and the stars keep the base value (own vector when shared).
  Stars at intensity 120: a starfield shows from space.
- Both night-side shader variants compile on the first two frames.
- The clouds hide once the sun sets at the camera (dark puffs at dusk).
- No allocation on look frames that change nothing (frozen updates, a
  scratch camera place from livePosition).
Gallery regenerated.
This commit is contained in:
German Khokhlov committed 2026-10-16 15:06:15 +04:00
1 parent 26968f4903
commit 5188e8869d
9 files changed
+407 -225

No files matched your search

-1
View File
@@ -1033,7 +1033,6 @@
"src/globe/cesium/render.js", "src/globe/cesium/render.js",
"src/globe/cesium/surface.js", "src/globe/cesium/surface.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/globe/navara/atmosphereLuminance.js",
"src/globe/navara/cameraMath.js", "src/globe/navara/cameraMath.js",
"src/globe/navara/cameraMath.test.mjs", "src/globe/navara/cameraMath.test.mjs",
"src/globe/navara/cameraModel.js", "src/globe/navara/cameraModel.js",
-1
View File
@@ -1760,7 +1760,6 @@
"src/globe/cesium/render.js", "src/globe/cesium/render.js",
"src/globe/cesium/surface.js", "src/globe/cesium/surface.js",
"src/globe/cesium/engineFromViewer.js", "src/globe/cesium/engineFromViewer.js",
"src/globe/navara/atmosphereLuminance.js",
"src/globe/navara/cameraMath.js", "src/globe/navara/cameraMath.js",
"src/globe/navara/cameraModel.js", "src/globe/navara/cameraModel.js",
"src/globe/navara/createNavaraEngine.js", "src/globe/navara/createNavaraEngine.js",
-61
View File
@@ -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;
};
}
+26 -24
View File
@@ -6,7 +6,7 @@ import ThreeView from '@navaramap/three';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { createNavaraEngine } from './createNavaraEngine.js'; import { createNavaraEngine } from './createNavaraEngine.js';
import { navaraHooks } from './internals.js'; import { forEachLuminanceUniform, navaraHooks } from './internals.js';
import { buildNavaraLook } from './look.js'; import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js'; import { createNavaraSharpen } from './sharpen.js';
import { import {
@@ -33,28 +33,22 @@ function preservedCanvas(container) {
return canvas; return canvas;
} }
/** The camera's place in degrees (for the sun's elevation), or null. */ // The camera's place for the look, refreshed in place each frame (no
function cameraPlace(camera) { // allocation): degrees for the sun's elevation, and the height.
try { const lookCarto = { longitude: 0, latitude: 0, height: 0 };
const place = camera.pose().cartographic; const lookPlace = { lat: 0, lng: 0 };
return place
? {
lat: geo.math.toDegrees(place.latitude),
lng: geo.math.toDegrees(place.longitude),
}
: null;
} catch {
return null;
}
}
/** The camera height, or NaN while the camera has no position yet. */ /** Fills lookPlace from the camera; false while it has no position yet. */
function cameraHeight(camera) { function readCameraPlace(camera) {
try { const place = geo.cartographic.fromCartesian(
return camera.height(); camera.livePosition,
} catch { geo.ellipsoid.WGS84,
return Number.NaN; 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 // The height of the ground in view, for the look's cloud rule: the depth of
@@ -103,7 +97,9 @@ export async function createNavaraGlobe({
const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), {
mobile: Boolean(view.isMobileOptimized?.()), mobile: Boolean(view.isMobileOptimized?.()),
}); });
const look = buildNavaraLook(view, plugin, plan); const look = buildNavaraLook(view, plugin, plan, undefined, {
forEachLuminanceUniform,
});
engine = createNavaraEngine(view, { engine = createNavaraEngine(view, {
hooks: navaraHooks(view), hooks: navaraHooks(view),
container, container,
@@ -133,8 +129,14 @@ export async function createNavaraGlobe({
engine.frames.preRender.addEventListener(() => { engine.frames.preRender.addEventListener(() => {
if (!moving && !groundSettled && engine.surface.tilesLoaded()) if (!moving && !groundSettled && engine.surface.tilesLoaded())
readGround(); readGround();
const placed = readCameraPlace(camera);
if ( 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'); engine.render.request('navara-look');
}), }),
+65
View File
@@ -65,6 +65,71 @@ export function isIdle(view) {
return view._isIdle === true; 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. */ /** The hooks createNavaraEngine takes. */
export const navaraHooks = (view) => ({ export const navaraHooks = (view) => ({
retainTileGeometry, retainTileGeometry,
+115 -22
View File
@@ -2,12 +2,14 @@
// view and keeps it current while the view renders: the sun follows the wall // view and keeps it current while the view renders: the sun follows the wall
// clock as the HUD's SUN elevation does; the cinematic look lights the globe // clock as the HUD's SUN elevation does; the cinematic look lights the globe
// from the atmosphere when seen from space, shows its clouds only for low // from the atmosphere when seen from space, shows its clouds only for low
// views over low ground, brightens the atmosphere (atmosphereLuminance.js) // views over low ground and while the sun is up, brightens the atmosphere
// and shows its stars only at night or from space. All of it runs only on frames the view draws // (createLuminanceScaler, uniforms from internals.js), compiles both
// anyway, so an idle view stays idle. // 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 { import {
cloudsSunlitAt,
cloudsVisibleAt, cloudsVisibleAt,
globeLightingAt, globeLightingAt,
starsVisibleAt, starsVisibleAt,
@@ -23,9 +25,20 @@ import {
* camera is moving, the height of the ground in view (metres above the * 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 * 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 * 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 wall clock (ms), a test seam
* @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 = []; const handles = [];
if (plan.pluginDefault) { if (plan.pluginDefault) {
const scene = plugin.addDefaultPhotorealScene(); const scene = plugin.addDefaultPhotorealScene();
@@ -48,28 +61,47 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
const aerial = plan.globeLighting ? effect('aerialPerspective') : null; const aerial = plan.globeLighting ? effect('aerialPerspective') : null;
const clouds = effect('clouds'); const clouds = effect('clouds');
const stars = plan.daylightHidesStars ? mesh('stars') : null; const stars = plan.daylightHidesStars ? mesh('stars') : null;
const atmosphereHandles = { const brighten = createLuminanceScaler(
plan.atmosphereLuminance ?? 1,
forEachLuminanceUniform,
{
sky: mesh('sky'), sky: mesh('sky'),
stars: mesh('stars'),
aerialPerspective: effect('aerialPerspective'), aerialPerspective: effect('aerialPerspective'),
clouds, clouds,
}; },
const brighten = createAtmosphereLuminance(plan.atmosphereLuminance ?? 1); );
let globeLit = false; 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 cloudsShown = true;
let cloudsSunlit = true;
let starsShown = true; let starsShown = true;
const frame = (height, moving = false, ground = Number.NaN, place = null) => { const frame = (height, moving = false, ground = Number.NaN, place = null) => {
// Every drawn frame: a material Navara rebuilds comes back unscaled. // Every drawn frame: a material Navara rebuilds comes back unscaled.
let changed = brighten(atmosphereHandles); let changed = brighten();
const atmosphere = view.atmosphere; const atmosphere = view.atmosphere;
if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) { if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) {
atmosphere.date = new Date(now()); atmosphere.date = new Date(now());
changed = true; 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) { if (aerial) {
const next = globeLightingAt(height, globeLit); const next = globeLightingAt(height, globeLit);
// Switching recompiles the aerial perspective, a frame of about 0.8 s // Switching rebuilds the aerial perspective's shader (warmed up
// headless: it switches on only at rest (a flight from city to city // 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 // through space never switches) and off as soon as the camera comes
// down, before the tiles would show relit. // down, before the tiles would show relit.
if (next !== globeLit && !(next && moving)) { if (next !== globeLit && !(next && moving)) {
@@ -77,30 +109,28 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
// Only the aerial perspective changes: the tiles already draw plain // Only the aerial perspective changes: the tiles already draw plain
// albedo, so view.lit stays (toggling it recompiles every tile // albedo, so view.lit stays (toggling it recompiles every tile
// material, a frame of about 0.8 s mid-flight). // material, a frame of about 0.8 s mid-flight).
aerial.update({ aerialPerspective: { irradiance: next } }); aerial.update(next ? IRRADIANCE_ON : IRRADIANCE_OFF);
changed = true; changed = true;
} }
} }
// Clouds only for a camera below them over low ground; switching them // Clouds only for a camera below them over low ground, and while the
// costs no recompile, so it follows the camera in motion too. // 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) { if (clouds) {
const next = cloudsVisibleAt(height, ground, cloudsShown); cloudsSunlit = cloudsSunlitAt(elevation, cloudsSunlit);
const next = cloudsSunlit && cloudsVisibleAt(height, ground, cloudsShown);
if (next !== cloudsShown) { if (next !== cloudsShown) {
cloudsShown = next; cloudsShown = next;
clouds.update({ visible: next }); clouds.update(next ? VISIBLE : HIDDEN);
parkCloudLayers(view.atmosphere, next); parkCloudLayers(view.atmosphere, next);
changed = true; changed = true;
} }
} }
if (stars) { if (stars) {
const elevation =
place && typeof atmosphere?.getSunElevation === 'function'
? atmosphere.getSunElevation(place)
: Number.NaN;
const next = starsVisibleAt(height, elevation, starsShown); const next = starsVisibleAt(height, elevation, starsShown);
if (next !== starsShown) { if (next !== starsShown) {
starsShown = next; starsShown = next;
stars.update({ visible: next }); stars.update(next ? VISIBLE : HIDDEN);
changed = true; changed = true;
} }
} }
@@ -109,6 +139,69 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) {
return { handles, frame }; 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 // The clouds hand the aerial perspective their last output through the
// atmosphere (overlay, shadow, shadow length) once, when they first draw. // atmosphere (overlay, shadow, shadow length) once, when they first draw.
// A hidden clouds pass leaves those in place, and the aerial perspective // A hidden clouds pass leaves those in place, and the aerial perspective
+171 -110
View File
@@ -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', () => { test('the photoreal look is the default plugin scene, untouched', () => {
const calls = []; const calls = [];
const view = fakeView(0); 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', () => { test('from space the globe is lit by the atmosphere; near the ground the tiles keep their own light', () => {
const view = fakeView(0); const view = fakeView(0);
const look = buildNavaraLook( const look = warmCinematic(view);
view,
plugin([]),
navaraScenePlan('cinematic'),
() => 0,
);
const effect = (name) => const effect = (name) =>
view.added.find(({ desc }) => Object.hasOwn(desc, name)); view.added.find(({ desc }) => Object.hasOwn(desc, name));
assert.equal(look.frame(900), false, 'nothing changes at street level'); 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', () => { test('the clouds show only below their tops, switching in motion too', () => {
const view = fakeView(0); const view = fakeView(0);
const look = buildNavaraLook( const look = warmCinematic(view);
view,
plugin([]),
navaraScenePlan('cinematic'),
() => 0,
);
const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); 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(900, true), false, 'shown at street level');
assert.equal(look.frame(20_000, true), true); 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', () => { test('globe lighting switches on only at rest, and off even in motion', () => {
const view = fakeView(0); const view = fakeView(0);
const look = buildNavaraLook( const look = warmCinematic(view);
view,
plugin([]),
navaraScenePlan('cinematic'),
() => 0,
);
const aerial = view.added.find(({ desc }) => const aerial = view.added.find(({ desc }) =>
Object.hasOwn(desc, 'aerialPerspective'), Object.hasOwn(desc, 'aerialPerspective'),
); );
@@ -162,7 +190,9 @@ const vec = (x, y, z) => ({
y, y,
z, z,
copy(v) { 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; return this;
}, },
multiplyScalar(k) { multiplyScalar(k) {
@@ -174,103 +204,142 @@ const vec = (x, y, z) => ({
clone() { clone() {
return vec(this.x, this.y, this.z); 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 = () => ({ const SUN = 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE';
SUN_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.3, 0.9, 0.9) }, const SKY = 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE';
SKY_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.5, 0.9, 0.8) }, 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. */ /** A stand-in for internals.js forEachLuminanceUniform over fake materials. */
function withRawAtmosphere(view) { function fakeInternals(materials) {
const find = (name) => return {
view.added.find(({ desc }) => Object.hasOwn(desc, name)); forEachLuminanceUniform(handles, visit) {
const sky = luminanceUniforms(); for (const [name, scaled] of [
const aerial = new Map(Object.entries(luminanceUniforms())); ['sky', true],
const clouds = luminanceUniforms(); ['stars', false],
const stars = luminanceUniforms(); ['aerialPerspective', true],
find('sky').ref = { raw: { material: { uniforms: sky } } }; ['clouds', true],
find('stars').ref = { raw: { material: { uniforms: stars } } }; ]) {
find('aerialPerspective').ref = { raw: { rawEffect: { uniforms: aerial } } }; if (!handles[name]) continue;
find('clouds').ref = { const uniforms = materials[name];
raw: { if (!uniforms) continue;
rawEffect: { cloudsPass: { currentMaterial: { uniforms: clouds } } }, 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 view = fakeView(0);
const plan = navaraScenePlan('cinematic'); const look = buildNavaraLook(
const look = buildNavaraLook(view, plugin([]), plan, () => 0); view,
const raw = withRawAtmosphere(view); plugin([]),
assert.equal(look.frame(900), true, 'the first frame applies the scale'); navaraScenePlan('cinematic'),
const k = plan.atmosphereLuminance; () => 0,
const sky = (u) => u.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value; fakeInternals(materials),
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,
); );
assert.ok(Math.abs(sky(raw.clouds).z - 0.8 * k) < 1e-12); look.frame(900);
assert.equal( near(materials.sky[SKY].value.x, 1.5 * k);
sky(raw.stars).x, near(materials.sky[SUN].value.x, 1.3 * k);
1.5, near(materials.aerialPerspective[SKY].value.z, 0.8 * k);
'only the sky, aerial perspective and clouds are walked', near(materials.clouds[SKY].value.x, 1.5 * k);
); assert.equal(materials.stars[SKY].value.x, 1.5, 'the stars keep the base');
assert.equal(look.frame(900), false, 'applied once, not compounded'); look.frame(900);
assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12); look.frame(900);
// Navara rebuilds a material (a cloud quality change): the new one is scaled. assert.equal(look.frame(900), false, 'nothing to change once applied');
const fresh = luminanceUniforms(); near(materials.sky[SKY].value.x, 1.5 * k);
view.added.find(({ desc }) => // A rebuilt material copying the scaled vector is not scaled again.
Object.hasOwn(desc, 'clouds'), materials.clouds = {
).ref.raw.rawEffect.cloudsPass.currentMaterial = { uniforms: fresh }; [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.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 view = fakeView(0);
const look = buildNavaraLook( const look = buildNavaraLook(
view, view,
plugin([]), plugin([]),
navaraScenePlan('minimal'), navaraScenePlan('minimal'),
() => 0, () => 0,
fakeInternals(materials),
); );
const sky = luminanceUniforms();
view.added[0].ref = { raw: { material: { uniforms: sky } } };
assert.equal(look.frame(900), false); assert.equal(look.frame(900), false);
assert.equal(sky.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x, 1.5); assert.equal(materials.sky[SKY].value.x, 1.5);
const cinematic = fakeView(0); const other = fakeView(0);
const other = buildNavaraLook( const cinematic = buildNavaraLook(
cinematic, other,
plugin([]), plugin([]),
navaraScenePlan('cinematic'), navaraScenePlan('cinematic'),
() => 0, () => 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', () => { test('the cinematic stars hide in a daylit sky and show at night and from space', () => {
const view = fakeView(0); const view = fakeView(0);
let elevation = 40; let elevation = 40;
view.atmosphere.getSunElevation = () => elevation; view.atmosphere.getSunElevation = () => elevation;
const look = buildNavaraLook( const look = warmCinematic(view);
view,
plugin([]),
navaraScenePlan('cinematic'),
() => 0,
);
const stars = view.added.find(({ desc }) => Object.hasOwn(desc, 'stars')); const stars = view.added.find(({ desc }) => Object.hasOwn(desc, 'stars'));
const place = { lat: 40.7, lng: -74 }; const place = { lat: 40.7, lng: -74 };
assert.equal(look.frame(900, false, 0, place), true); assert.equal(look.frame(900, false, 0, place), true);
assert.deepEqual(stars.updates, [{ visible: false }]); assert.deepEqual(stars.updates, [{ visible: false }]);
assert.equal(look.frame(900, false, 0, place), false); assert.equal(look.frame(900, false, 0, place), false);
elevation = -10; 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 }); assert.deepEqual(stars.updates.at(-1), { visible: true });
elevation = 40; elevation = 40;
look.frame(900, false, 0, place); 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; const count = stars.updates.length;
look.frame(900, false, 0); look.frame(900, false, 0);
assert.equal(stars.updates.length, count, 'no place keeps them'); 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 }); 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); const view = fakeView(0);
view.atmosphere.getSunElevation = () => 40; view.atmosphere.getSunElevation = () => 40;
const look = buildNavaraLook( 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 }); look.frame(900, false, 0, { lat: 0, lng: 0 });
assert.deepEqual(view.added[1].updates, []); 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,
);
});
+17 -5
View File
@@ -100,11 +100,10 @@ export function cloudsVisibleAt(height, ground, current = true) {
// came out dark navy (sRGB about 39, 58, 90 at Manhattan facing the // came out dark navy (sRGB about 39, 58, 90 at Manhattan facing the
// horizon), and the stars showed through it. The cinematic look multiplies // horizon), and the stars showed through it. The cinematic look multiplies
// the atmosphere's radiance-to-luminance factors (sky, clouds, aerial // 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 // the exposure, so the sky turns daylight blue and the tiles keep their
// colours. // colours.
export const ATMOSPHERE_LUMINANCE_SCALE = 3; 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 // 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 // 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_ENTER_M = 100_000;
export const STARS_SPACE_EXIT_M = 80_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 * Whether the cinematic stars show for a camera at `height` (metres above
* the ellipsoid) with the sun `sunElevation` degrees above its horizon; an * 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) => ({ const cinematic = (mobile) => ({
pluginDefault: false, pluginDefault: false,
// Brighter, larger stars (defaults 10 and 1 px) so the night sky and the // 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, // globe from space keep a visible starfield next to a sunlit Earth. The
// divided by the atmosphere scale the stars share. // atmosphere scale leaves the stars at their base factor (look.js).
meshes: [ meshes: [
{ sky: {} }, { sky: {} },
{ {
stars: { stars: {
intensity: STAR_INTENSITY / ATMOSPHERE_LUMINANCE_SCALE, intensity: 120,
pointSize: 2, pointSize: 2,
}, },
}, },
+13 -1
View File
@@ -22,6 +22,9 @@ import {
STARS_SPACE_ENTER_M, STARS_SPACE_ENTER_M,
STARS_SPACE_EXIT_M, STARS_SPACE_EXIT_M,
starsVisibleAt, starsVisibleAt,
CLOUDS_HIDE_SUN_BELOW_DEG,
CLOUDS_SHOW_SUN_ABOVE_DEG,
cloudsSunlitAt,
} from './viewOptions.js'; } from './viewOptions.js';
test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => { 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.equal(plan.pluginDefault, false);
assert.deepEqual(plan.meshes, [ assert.deepEqual(plan.meshes, [
{ sky: {} }, { sky: {} },
{ stars: { intensity: 40 / ATMOSPHERE_LUMINANCE_SCALE, pointSize: 2 } }, { stars: { intensity: 120, pointSize: 2 } },
]); ]);
assert.deepEqual(plan.lights, [ assert.deepEqual(plan.lights, [
{ skyLightProbe: {} }, { 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_SHOW_SUN_BELOW_DEG < STARS_HIDE_SUN_ABOVE_DEG);
assert.ok(STARS_SPACE_EXIT_M < STARS_SPACE_ENTER_M); 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');
});