mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
1 parent
26968f4903
commit
5188e8869d
9 files changed
+410
-228
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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -103,7 +97,9 @@ export async function createNavaraGlobe({
|
||||
const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), {
|
||||
mobile: Boolean(view.isMobileOptimized?.()),
|
||||
});
|
||||
const look = buildNavaraLook(view, plugin, plan);
|
||||
const look = buildNavaraLook(view, plugin, plan, undefined, {
|
||||
forEachLuminanceUniform,
|
||||
});
|
||||
engine = createNavaraEngine(view, {
|
||||
hooks: navaraHooks(view),
|
||||
container,
|
||||
@@ -133,8 +129,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,
|
||||
|
||||
+118
-25
@@ -2,12 +2,14 @@
|
||||
// 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
|
||||
// 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,
|
||||
@@ -23,9 +25,20 @@ 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 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 = [];
|
||||
if (plan.pluginDefault) {
|
||||
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 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)) {
|
||||
@@ -77,30 +109,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;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +139,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,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user