fix(globe): Navara sprites, points and lines drawn after tone mapping

Review of Tasks 2–3: on Navara the overlays went through the look's
tone mapping and grading (magenta came out as 247/40/228, green as
147/228/89 in the cinematic look), where Cesium draws billboards and
polylines in the colours asked for.

src/globe/navara/overlay.js: a public Navara EffectDesc (gevOverlay)
inserted after toneMapping and before the gev sharpening, SMAA/FXAA and
final, whose pass renders one three scene over the tone-mapped input
buffer (in Navara's own Pass wrapper: Navara inserts a bare pass only
when it is an instance of its own postprocessing copy); it costs nothing
while the scene is empty. The chain has no depth buffer, so each overlay
material tests the scene depth itself (overlayDepth.js): its log depth
against Navara's MRT depth (RGBA packed), modes always / normal /
depth-fail, disableWithinM as a per-vertex escape. Sprites and points
(moverMesh.js) and line ribbons (lineMesh.js) live in that scene with
their per-frame work as overlay hooks; models stay Navara meshes, lit
and graded like the tiles. The sharpening now sits after the overlay
(Cesium's sharpen stage sharpens billboards too).

Also (review): Navara pixel dashes are measured in CSS pixels
(gl_FragCoord over uGevPixelRatio), and the line viewport uniform is
updated in place.

Measured: a magenta sprite draws 255/0/255. The contract suite's colour
checks are now within 40 of the asked colour on every channel on both
engines (were: dominant channels); Cesium 68/68, Navara 74/74 (its
sharpen-order check accepts the overlay between tone mapping and the
sharpening). Shell: cesium 23/23, cross 4/4; navara 23/25 — the two map
stack pictures from space fail on stddev 7.2/7.6 against 8, as on the
Task 3 commit run at the same hour (the cinematic sun follows the clock
into night over the reset view), not from this change.
This commit is contained in:
German Khokhlov committed 2026-10-16 19:04:57 +04:00
1 parent 3c2e924363
commit e804351205
11 files changed
+388 -125

No files matched your search

+8 -2
View File
@@ -15,6 +15,7 @@ import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js';
import { createNavaraMovers } from './movers/index.js';
import { createNavaraLines } from './lines/index.js';
import { createNavaraOverlay } from './overlay.js';
import {
NAVARA_CONTEXT_ATTRIBUTES,
navaraScenePlan,
@@ -98,6 +99,7 @@ export async function createNavaraGlobe({
const canvas = preservedCanvas(container);
let view;
let engine;
let overlay = null;
let removeLook = null;
try {
view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio }));
@@ -118,13 +120,15 @@ export async function createNavaraGlobe({
);
// The look's shadowed sun leaked a light per frame (internals.js).
keepCascadedLightIds(view);
// Sprites, points and lines, drawn after tone mapping (overlay.js).
overlay = createNavaraOverlay(view);
engine = createNavaraEngine(view, {
hooks: navaraHooks(view),
container,
...(appClock ? { appClock } : {}),
sharpen: createNavaraSharpen(view),
movers: createNavaraMovers(view),
lines: createNavaraLines(view),
movers: createNavaraMovers(view, { overlay }),
lines: createNavaraLines(view, { overlay }),
});
const camera = engine.camera;
let ground = Number.NaN;
@@ -166,6 +170,7 @@ export async function createNavaraGlobe({
} catch (error) {
removeLook?.();
engine?.destroy();
overlay?.destroy();
view?.dispose();
canvas.remove();
throw error;
@@ -179,6 +184,7 @@ export async function createNavaraGlobe({
destroyed = true;
removeLook();
engine.destroy();
overlay.destroy();
view.dispose();
canvas.remove();
},
+17 -41
View File
@@ -1,56 +1,25 @@
// engine.lines on Navara: each line is one Navara mesh descriptor (public
// MeshDesc API, the transparent pass, unlit) holding its ribbon
// (lineMesh.js). Geodesic arcs are densified on the CPU (src/globe/lines.js)
// engine.lines on Navara: each line is a ribbon (lineMesh.js) in gev's
// overlay (overlay.js: drawn after tone mapping, so its colour is exact). Geodesic arcs are densified on the CPU (src/globe/lines.js)
// since the ribbon draws straight segments. Every change asks for one frame;
// nothing animates on its own, so an idle view stays idle. Lines are not
// pickable on Navara yet (trails never take a click; see phase 3 Task 4).
import { MeshDesc } from '@navaramap/three';
import { createLineRegistry, densifyGeodesic } from '../../lines.js';
import { createLineMesh } from './lineMesh.js';
const DESC_NAME = 'gevLine';
const registered = new WeakSet();
class GevLineDesc extends MeshDesc {
constructor(view, ctx, config) {
super(view, ctx, config);
this.gev = config[DESC_NAME];
}
getPassKey() {
return 'transparent';
}
createMesh() {
return this.gev.line.group;
}
update() {
const canvas = this.ctx.getRenderer().domElement;
this.gev.line.update(canvas.clientWidth, canvas.clientHeight);
}
onDestroy() {
this.gev.line.dispose();
super.onDestroy();
}
}
/**
* @param {object} view an initialised Navara ThreeView
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay> }} ports
* @returns {{ lines: import('../../contract.js').GlobeLines, destroy: () => void }}
*/
export function createNavaraLines(view) {
if (!registered.has(view)) {
view.registerMesh(DESC_NAME, GevLineDesc);
registered.add(view);
}
export function createNavaraLines(view, { overlay }) {
const request = () => view.forceUpdate();
// What CPU picks need of each line: its drawn positions and matrix.
const drawn = new Map();
const registry = createLineRegistry((options) => {
const line = createLineMesh(options);
const line = createLineMesh(options, {
overlayUniforms: overlay.uniforms,
});
const state = {
options,
flat: new Float64Array(0),
@@ -59,8 +28,14 @@ export function createNavaraLines(view) {
};
drawn.set(options.id, state);
const canvas = view.canvas;
if (canvas) line.update(canvas.clientWidth || 1, canvas.clientHeight || 1);
const handle = view.addMesh({ lit: false, [DESC_NAME]: { line } });
const viewport = () =>
line.update(
canvas?.clientWidth || 1,
canvas?.clientHeight || 1,
overlay.ctx?.getRenderer().getPixelRatio() ?? 1,
);
viewport();
overlay.add(line.group, viewport);
return {
setPositions(flat) {
state.flat = options.arc === 'geodesic' ? densifyGeodesic(flat) : flat;
@@ -83,7 +58,8 @@ export function createNavaraLines(view) {
},
destroy() {
drawn.delete(options.id);
handle.delete();
overlay.remove(line.group);
line.dispose();
request();
},
};
+53 -16
View File
@@ -1,10 +1,12 @@
// The three side of a Navara line: a ribbon of one quad per segment whose
// vertices are pushed apart in the vertex shader to the line's width in CSS
// pixels (projected ends, the screen-space normal), so the width holds at any
// distance. A second copy of the ribbon, drawn only where the scene hides it
// (depthFunc GreaterDepth) with the alpha scaled, is depthFailAlpha. Dashes
// in pixels are cut in the fragment shader along the segment's screen
// direction (as Cesium's PolylineDash); the tactical dash is the launches'
// distance. Drawn after tone mapping (overlay.js) with the depth test in the
// shader (overlayDepth.js); a second copy of the ribbon, drawn only where the
// scene hides it with the alpha scaled, is depthFailAlpha. Dashes in CSS
// pixels are cut in the fragment shader along the segment's screen
// direction (as Cesium's PolylineDash; gl_FragCoord over the pixel ratio, so
// the period is the same on any screen); the tactical dash is the launches'
// pattern over the fraction along the whole line. Positions are relative to
// the first point (the group sits there, in doubles), the model matrix
// multiplies the group's transform (an orbit ring turned by GMST).
@@ -13,13 +15,16 @@ import {
BufferAttribute,
BufferGeometry,
DoubleSide,
GreaterDepth,
Group,
LessEqualDepth,
Matrix4,
Mesh,
MeshBasicMaterial,
} from 'three';
import {
OVERLAY_DEPTH_FRAGMENT,
OVERLAY_DEPTH_MODES,
OVERLAY_DEPTH_VERTEX,
} from '../overlayDepth.js';
const VERTEX_HEAD = /* glsl */ `
attribute vec3 gevEnd;
@@ -30,6 +35,7 @@ uniform float uGevWidthPx;
varying float vGevS;
varying float vGevT;
varying vec2 vGevDirection;
${OVERLAY_DEPTH_VERTEX}
`;
const VERTEX_PROJECT = /* glsl */ `
@@ -50,11 +56,14 @@ vec4 mvPosition = modelViewMatrix * vec4(gevSide.x < 0.5 ? position : gevEnd, 1.
vGevS = gevS;
vGevT = gevSide.y * 0.5 + 0.5;
vGevDirection = gevDirection;
vGevDepthFree = 0.0;
`;
const FRAGMENT_HEAD = /* glsl */ `
uniform vec4 uGevColor;
uniform float uGevAlphaScale;
uniform float uGevPixelRatio;
${OVERLAY_DEPTH_FRAGMENT}
uniform float uGevDash;
uniform vec2 uGevDashPx;
uniform vec2 uGevTactical;
@@ -80,12 +89,13 @@ float gevTactical() {
const FRAGMENT_COLOR = /* glsl */ `
#include <color_fragment>
if (gevOverlayHidden()) discard;
diffuseColor = uGevColor;
diffuseColor.a *= uGevAlphaScale;
if (uGevDash > 1.5) {
diffuseColor.a *= gevTactical();
} else if (uGevDash > 0.5) {
float gevAlong = dot(gl_FragCoord.xy, vGevDirection);
float gevAlong = dot(gl_FragCoord.xy / uGevPixelRatio, vGevDirection);
if (mod(gevAlong, uGevDashPx.x + uGevDashPx.y) > uGevDashPx.x) discard;
}
`;
@@ -170,11 +180,23 @@ export function buildRibbon(flat, origin) {
return geometry;
}
// Overlay uniforms of a line drawn outside an overlay (tests): no depth.
const DETACHED_OVERLAY = Object.freeze({
tGevSceneDepth: { value: null },
uGevResolution: { value: [0, 0] },
});
/** @param {ReturnType<import('../../lines.js').normalizeLineOptions>} options */
export function createLineMesh(options) {
export function createLineMesh(
options,
{ overlayUniforms = DETACHED_OVERLAY } = {},
) {
const dash = options.dash;
const shared = {
uGevViewport: { value: [1, 1] },
uGevPixelRatio: { value: 1 },
tGevSceneDepth: overlayUniforms.tGevSceneDepth,
uGevResolution: overlayUniforms.uGevResolution,
uGevWidthPx: { value: options.widthPx },
uGevColor: { value: [...options.color] },
uGevDash: { value: dash?.kind === 'tactical' ? 2 : dash ? 1 : 0 },
@@ -185,14 +207,19 @@ export function createLineMesh(options) {
value: dash?.kind === 'tactical' ? [dash.groups, dash.dashes] : [1, 1],
},
};
const material = (alphaScale, depthFunc) => {
const material = (alphaScale, depthMode) => {
const value = new MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
depthFunc,
side: DoubleSide,
});
const uniforms = { ...shared, uGevAlphaScale: { value: alphaScale } };
const uniforms = {
...shared,
uGevAlphaScale: { value: alphaScale },
uGevDepthMode: { value: depthMode },
};
value.userData.gevDepthMode = depthMode;
value.onBeforeCompile = (shader) => patchLineShader(shader, uniforms);
value.customProgramCacheKey = () => 'gev-line';
return value;
@@ -200,7 +227,10 @@ export function createLineMesh(options) {
const group = new Group();
group.matrixAutoUpdate = false;
group.frustumCulled = false;
const main = new Mesh(new BufferGeometry(), material(1, LessEqualDepth));
const main = new Mesh(
new BufferGeometry(),
material(1, OVERLAY_DEPTH_MODES.normal),
);
main.frustumCulled = false;
main.renderOrder = options.order;
group.add(main);
@@ -208,7 +238,7 @@ export function createLineMesh(options) {
if (options.depthFailAlpha > 0) {
behind = new Mesh(
main.geometry,
material(options.depthFailAlpha, GreaterDepth),
material(options.depthFailAlpha, OVERLAY_DEPTH_MODES.fail),
);
behind.frustumCulled = false;
behind.renderOrder = options.order;
@@ -247,9 +277,16 @@ export function createLineMesh(options) {
show(visible) {
group.visible = visible;
},
/** Each drawn frame: the viewport the pixel width is measured in. */
update(width, height) {
shared.uGevViewport.value = [width, height];
/**
* Each drawn frame: the viewport (CSS px) the width is measured in and
* the device pixel ratio the dashes are measured with. In place: no
* allocation per frame.
*/
update(width, height, pixelRatio = 1) {
const viewport = shared.uGevViewport.value;
viewport[0] = width;
viewport[1] = height;
shared.uGevPixelRatio.value = pixelRatio;
},
dispose() {
main.geometry.dispose();
+11 -1
View File
@@ -67,7 +67,17 @@ test('a line places its group at the first point under its model matrix', () =>
// (6378137, 0, 0) turned 90° about z.
assert.ok(Math.abs(origin.x) < 1e-6 && Math.abs(origin.y - 6_378_137) < 1e-6);
const [main, behind] = line.meshes();
assert.equal(behind.material.depthFunc, GreaterDepth);
// The overlay tests depth in the shader: normal for the line, drawn
// only where the scene is nearer for its depth-fail copy.
assert.equal(main.material.depthTest, false);
assert.equal(main.material.userData.gevDepthMode, 1);
assert.equal(behind.material.userData.gevDepthMode, 2);
// Viewport and pixel ratio update in place (no allocation per frame).
const viewport = line.uniforms.uGevViewport.value;
line.update(1280, 800, 2);
assert.equal(line.uniforms.uGevViewport.value, viewport);
assert.deepEqual(viewport, [1280, 800]);
assert.equal(line.uniforms.uGevPixelRatio.value, 2);
assert.equal(main.geometry, behind.geometry);
assert.equal(line.uniforms.uGevDash.value, 2);
assert.deepEqual(line.uniforms.uGevTactical.value, [4, 100]);
+72 -38
View File
@@ -1,10 +1,12 @@
// engine.movers on Navara: each set is one Navara mesh descriptor (public
// MeshDesc API) holding its instanced meshes (moverMesh.js, modelMover.js).
// Pickable sets register a PickableInstancedMeshWrapper so Navara's pick
// pass sees each instance; pickIds.js turns its batch ids back into
// instances. commit() uploads and asks for one frame (view.forceUpdate): a
// descriptor's update() runs only on frames the view draws anyway (S5), so
// an idle view stays idle.
// engine.movers on Navara. Sprites and points are one instanced quad mesh
// per set (moverMesh.js) in gev's overlay (overlay.js: drawn after tone
// mapping, so their colours are exact); models are a Navara mesh descriptor
// (public MeshDesc API) holding one InstancedMesh per glTF mesh
// (modelMover.js), lit and graded like the scene. Pickable sets register a
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance;
// pickIds.js turns its batch ids back into instances. commit() uploads and
// asks for one frame (view.forceUpdate); per-frame work runs only on frames
// the view draws anyway (S5), so an idle view stays idle.
import {
MeshDesc,
@@ -28,13 +30,8 @@ class GevMoverSetDesc extends MeshDesc {
this.wrappers = [];
}
// Sprites and points are screen overlays: the transparent pass, unlit.
getPassKey() {
return this.gev?.kind === 'model' ? super.getPassKey() : 'transparent';
}
createMesh() {
const { mover, pickable, onPickable, kind } = this.gev;
const { mover, pickable, onPickable } = this.gev;
const register = (mesh, key) => {
if (!pickable) return;
const wrapper = new PickableInstancedMeshWrapper(
@@ -46,10 +43,6 @@ class GevMoverSetDesc extends MeshDesc {
this.wrappers.push(key);
onPickable(wrapper);
};
if (kind !== 'model') {
register(mover.mesh, this.id);
return mover.mesh;
}
const group = new Group();
group.frustumCulled = false;
// Meshes arrive with the glTF; they join the group (and the pick pass).
@@ -65,21 +58,7 @@ class GevMoverSetDesc extends MeshDesc {
}
update() {
const raw = this.view.camera.raw;
const height = this.ctx.getRenderer().domElement.clientHeight;
if (!raw || !(height > 0)) return;
raw.updateMatrixWorld();
const e = raw.matrixWorld.elements;
const cameraPosition = { x: e[12], y: e[13], z: e[14] };
const cameraHeight =
Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z) -
EARTH_RADIUS_M;
this.gev.mover.update({
cameraPosition,
cameraHeight,
fovDeg: raw.fov,
viewportHeightPx: height,
});
updateMover(this.view, this.ctx, this.gev.mover);
}
onDestroy() {
@@ -90,13 +69,38 @@ class GevMoverSetDesc extends MeshDesc {
}
}
// The camera a mover needs each drawn frame (scratch: no allocation).
const frameCamera = {
cameraPosition: { x: 0, y: 0, z: 0 },
cameraHeight: 0,
fovDeg: 60,
viewportHeightPx: 0,
};
function updateMover(view, ctx, mover) {
const raw = view.camera.raw;
const height = ctx?.getRenderer().domElement.clientHeight;
if (!raw || !(height > 0)) return;
raw.updateMatrixWorld();
const e = raw.matrixWorld.elements;
const position = frameCamera.cameraPosition;
position.x = e[12];
position.y = e[13];
position.z = e[14];
frameCamera.cameraHeight =
Math.hypot(position.x, position.y, position.z) - EARTH_RADIUS_M;
frameCamera.fovDeg = raw.fov;
frameCamera.viewportHeightPx = height;
mover.update(frameCamera);
}
/**
* @param {object} view an initialised Navara ThreeView
* @param {{ overlay: ReturnType<import('../overlay.js').createNavaraOverlay> }} ports
* @returns {{ movers: import('../../contract.js').GlobeMovers,
* resolvePick: (batchId: number) => ({ set: string, instance: number, pickId: any } | null),
* destroy: () => void }}
*/
export function createNavaraMovers(view) {
export function createNavaraMovers(view, { overlay }) {
if (!registered.has(view)) {
view.registerMesh(DESC_NAME, GevMoverSetDesc);
registered.add(view);
@@ -108,13 +112,43 @@ export function createNavaraMovers(view) {
return loader.loadAsync(url);
};
const request = () => view.forceUpdate();
const overlaySet = (options, store) => {
const mover = createMoverMesh(options, store, {
onTextureLoad: request,
overlayUniforms: overlay.uniforms,
});
const key = `gevMovers:${options.id}`;
if (options.pickable) {
const wrapper = new PickableInstancedMeshWrapper(
mover.mesh,
options.capacity,
overlay.ctx,
);
overlay.registerPickable(key, wrapper);
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]);
}
overlay.add(mover.mesh, () => updateMover(view, overlay.ctx, mover));
return {
commit() {
mover.commit(store.dirty());
request();
},
destroy() {
pickIndex.remove(options.id);
if (options.pickable) overlay.unregisterPickable(key);
overlay.remove(mover.mesh);
mover.dispose();
request();
},
};
};
const registry = createMoverRegistry((options, store) => {
const mover =
options.kind === 'model'
? createModelMover(options, store, { load: loadGltf, onLoad: request })
: createMoverMesh(options, store, { onTextureLoad: request });
if (options.kind !== 'model') return overlaySet(options, store);
const mover = createModelMover(options, store, {
load: loadGltf,
onLoad: request,
});
const handle = view.addMesh({
...(options.kind === 'model' ? {} : { lit: false }),
[DESC_NAME]: {
mover,
kind: options.kind,
+29 -10
View File
@@ -23,6 +23,11 @@ import {
originFor,
packInstances,
} from './pack.js';
import {
OVERLAY_DEPTH_FRAGMENT,
OVERLAY_DEPTH_MODES,
OVERLAY_DEPTH_VERTEX,
} from '../overlayDepth.js';
const VERTEX_HEAD = /* glsl */ `
attribute vec4 gevStyle;
@@ -37,6 +42,7 @@ uniform float uGevOutlinePx;
varying vec4 vGevColor;
varying vec2 vGevLocal;
varying float vGevOutline;
${OVERLAY_DEPTH_VERTEX}
`;
const VERTEX_PROJECT = /* glsl */ `
@@ -66,6 +72,7 @@ gl_Position = projectionMatrix * mvPosition;
vGevColor = gevColor;
vGevLocal = gevCorner;
vGevOutline = uGevOutlinePx / max(gevSizePx, 1e-3);
vGevDepthFree = gevDistance < uGevDisableWithinM ? 1.0 : 0.0;
`;
const VERTEX_UV = /* glsl */ `
@@ -75,23 +82,18 @@ vMapUv = mix(gevUv.xy, gevUv.zw, uv);
#endif
`;
const VERTEX_DEPTH = /* glsl */ `
#include <logdepthbuf_vertex>
#ifdef USE_LOGARITHMIC_DEPTH_BUFFER
if (gevDistance < uGevDisableWithinM) vFragDepth = 1.0;
#endif
`;
const FRAGMENT_HEAD = /* glsl */ `
varying vec4 vGevColor;
varying vec2 vGevLocal;
varying float vGevOutline;
${OVERLAY_DEPTH_FRAGMENT}
`;
const fragmentColor = (point) =>
point
? /* glsl */ `
#include <color_fragment>
if (gevOverlayHidden()) discard;
float gevRadius = length(vGevLocal) * 2.0;
if (gevRadius > 1.0) discard;
diffuseColor *= vGevColor;
@@ -99,9 +101,16 @@ if (gevRadius > 1.0 - vGevOutline) diffuseColor.rgb = vec3(0.0);
`
: /* glsl */ `
#include <color_fragment>
if (gevOverlayHidden()) discard;
diffuseColor *= vGevColor;
`;
// Overlay uniforms of a mesh drawn outside an overlay (tests): no depth.
const DETACHED_OVERLAY = Object.freeze({
tGevSceneDepth: { value: null },
uGevResolution: { value: [0, 0] },
});
/** Patches three's MeshBasicMaterial shaders into the screen-space quad. */
export function patchMoverShader(shader, uniforms, { point = false } = {}) {
Object.assign(shader.uniforms, uniforms);
@@ -117,7 +126,6 @@ export function patchMoverShader(shader, uniforms, { point = false } = {}) {
);
vertex = replace(vertex, '#include <project_vertex>', VERTEX_PROJECT);
vertex = replace(vertex, '#include <uv_vertex>', VERTEX_UV);
vertex = replace(vertex, '#include <logdepthbuf_vertex>', VERTEX_DEPTH);
let fragment = replace(
shader.fragmentShader,
'void main() {',
@@ -161,7 +169,7 @@ function atlasTexture(atlas, onLoad) {
export function createMoverMesh(
options,
store,
{ onTextureLoad = () => {} } = {},
{ onTextureLoad = () => {}, overlayUniforms = DETACHED_OVERLAY } = {},
) {
const { capacity, kind, depthTest } = options;
const point = kind === 'point';
@@ -192,12 +200,23 @@ export function createMoverMesh(
value: typeof depthTest === 'object' ? depthTest.disableWithinM : -1,
},
uGevOutlinePx: { value: point ? options.point.outlinePx : 0 },
uGevDepthMode: {
value:
depthTest === 'always'
? OVERLAY_DEPTH_MODES.always
: OVERLAY_DEPTH_MODES.normal,
},
// Shared by every overlay: the scene depth and the target's size.
tGevSceneDepth: overlayUniforms.tGevSceneDepth,
uGevResolution: overlayUniforms.uGevResolution,
};
const material = new MeshBasicMaterial({
map: point ? null : atlasTexture(options.sprite.atlas, onTextureLoad),
transparent: true,
alphaTest: 0.02,
depthTest: depthTest !== 'always',
// Drawn after tone mapping, with no depth buffer: the shader tests the
// scene depth itself (overlayDepth.js).
depthTest: false,
depthWrite: false,
side: DoubleSide,
});
+10 -3
View File
@@ -32,10 +32,14 @@ test('the shader patch sizes the quad in pixels and colours it per instance', ()
/uGevFootprint \* max\(-mvCenter\.z, 0\.0\)/,
);
assert.match(shader.vertexShader, /vMapUv = mix\(gevUv\.xy, gevUv\.zw, uv\)/);
assert.match(shader.vertexShader, /vFragDepth = 1\.0;/);
assert.match(
shader.vertexShader,
/vGevDepthFree = gevDistance < uGevDisableWithinM/,
);
assert.match(shader.fragmentShader, /if \(gevOverlayHidden\(\)\) discard;/);
assert.doesNotMatch(shader.vertexShader, /#include <project_vertex>/);
assert.match(shader.fragmentShader, /diffuseColor \*= vGevColor;/);
assert.doesNotMatch(shader.fragmentShader, /discard/);
assert.doesNotMatch(shader.fragmentShader, /gevRadius > 1\.0\) discard/);
const point = patchMoverShader(basicShader(), {}, { point: true });
assert.match(point.fragmentShader, /if \(gevRadius > 1\.0\) discard;/);
});
@@ -46,7 +50,9 @@ test('a sprite mesh uploads committed instances relative to its origin', () => {
const { mesh, uniforms } = mover;
assert.equal(mesh.renderOrder, 3);
assert.equal(mesh.count, 0);
assert.equal(mesh.material.depthTest, true);
// The overlay tests depth in the shader, against the scene's.
assert.equal(mesh.material.depthTest, false);
assert.equal(uniforms.uGevDepthMode.value, 1);
assert.equal(uniforms.uGevDisableWithinM.value, 2000);
assert.deepEqual(uniforms.uGevScaleByDistance.value, [1000, 1, 100000, 0.5]);
store.set(0, { position: { x: 6_378_137, y: 10, z: 20 } });
@@ -98,6 +104,7 @@ test('points have no atlas and draw a disc', () => {
const mover = createMoverMesh(options, createMoverStore(options));
assert.equal(mover.mesh.material.map, null);
assert.equal(mover.mesh.material.depthTest, false);
assert.equal(mover.uniforms.uGevDepthMode.value, 0, "'always': no test");
assert.equal(mover.uniforms.uGevOutlinePx.value, 1);
mover.dispose();
});
+133
View File
@@ -0,0 +1,133 @@
// gev's screen overlays on Navara: sprites, points and lines (engine.movers
// and engine.lines) drawn into the frame after Navara's tone mapping and
// before its anti-aliasing (SMAA/FXAA) and the gev sharpening, so their
// colours are the colours asked for (as Cesium draws billboards and
// polylines), not tone-mapped or graded with the look. A public Navara
// EffectDesc whose pass renders one three scene over the input buffer; the
// post-processing chain has no depth buffer, so each overlay material tests
// the scene depth from Navara's MRT pass itself (overlayDepth.js). The
// pass costs nothing while the scene is empty. Models stay in Navara's
// scene, lit and graded like the 3D tiles.
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
import { Pass } from 'postprocessing';
import { RGBADepthPacking, Scene } from 'three';
export const OVERLAY_EFFECT_KEY = 'gevOverlay';
class GevOverlayPass extends Pass {
constructor(scene, camera, overlay) {
super('GevOverlayPass', scene, camera);
this.needsSwap = false;
this.overlay = overlay;
}
render(renderer, inputBuffer) {
if (!this.scene.children.length) return;
const resolution = this.overlay.uniforms.uGevResolution.value;
resolution[0] = inputBuffer?.width ?? 0;
resolution[1] = inputBuffer?.height ?? 0;
const autoClear = renderer.autoClear;
renderer.autoClear = false;
renderer.setRenderTarget(this.renderToScreen ? null : inputBuffer);
renderer.render(this.scene, this.camera);
renderer.autoClear = autoClear;
}
}
class GevOverlayEffectDesc extends EffectDesc {
static key = OVERLAY_EFFECT_KEY;
static insertAfter = ['toneMapping'];
static insertBefore = ['gevSharpen', 'smaa', 'fxaa', 'final'];
constructor(view, ctx, config) {
super(view, ctx, config);
this.overlay = config[OVERLAY_EFFECT_KEY].overlay;
this.overlay.attach(ctx);
}
createPass() {
const camera = this.view.camera.raw;
if (!camera) throw new Error('Camera not available for the overlay');
// In Navara's own Pass wrapper: Navara inserts a bare pass only when
// it is an instance of its own postprocessing copy.
return new NavaraPass(
new GevOverlayPass(this.overlay.scene, camera, this.overlay),
null,
);
}
// Each drawn frame: the scene depth the overlays test against, then each
// overlay's own per-frame work (pixel footprint, origin, viewport).
update(time) {
const mrt = this.find('mrt');
this.overlay.uniforms.tGevSceneDepth.value =
mrt?.depthBufferPacking === RGBADepthPacking ? mrt.depthBuffer : null;
this.overlay.beforeFrame(time);
}
}
/**
* The overlay of one view: add(object, onFrame) puts a three object in the
* overlay scene (onFrame runs each drawn frame before it draws);
* registerPickable/unregisterPickable reach Navara's pick pass.
* @param {object} view an initialised ThreeView
*/
export function createNavaraOverlay(view) {
view.registerEffect(OVERLAY_EFFECT_KEY, GevOverlayEffectDesc);
const scene = new Scene();
const frameHooks = new Map();
let ctx = null;
const pendingPicks = new Map();
const overlay = {
scene,
uniforms: {
tGevSceneDepth: { value: null },
uGevResolution: { value: [0, 0] },
},
attach(context) {
ctx = context;
for (const [key, wrapper] of pendingPicks)
ctx.registerPickableMesh(key, wrapper);
pendingPicks.clear();
},
beforeFrame(time) {
for (const hook of frameHooks.values()) hook(time);
},
get ctx() {
return ctx;
},
};
const handle = view.addEffect({
[OVERLAY_EFFECT_KEY]: { overlay },
visible: true,
});
return {
scene,
uniforms: overlay.uniforms,
add(object, onFrame) {
scene.add(object);
if (onFrame) frameHooks.set(object, onFrame);
},
remove(object) {
scene.remove(object);
frameHooks.delete(object);
},
registerPickable(key, wrapper) {
if (ctx) ctx.registerPickableMesh(key, wrapper);
else pendingPicks.set(key, wrapper);
},
unregisterPickable(key) {
pendingPicks.delete(key);
ctx?.unregisterPickableMesh(key);
},
get ctx() {
return ctx;
},
destroy() {
handle.delete();
scene.clear();
frameHooks.clear();
},
};
}
+43
View File
@@ -0,0 +1,43 @@
// The depth test of gev's screen overlays on Navara (sprites, points and
// lines drawn after tone mapping, overlay.js): there is no depth buffer in
// the post-processing chain, so each fragment compares its own logarithmic
// depth with the scene's, read from Navara's MRT pass (depth packed to RGBA,
// as the sharpening reads it). Modes: 0 always drawn; 1 hidden where the
// scene is nearer (normal); 2 drawn only where the scene is nearer (a
// line's depth-fail copy). `vGevDepthFree` (set by the vertex shader) lets
// an instance skip the test (disableWithinM). No three imports: GLSL only.
export const OVERLAY_DEPTH_MODES = Object.freeze({
always: 0,
normal: 1,
fail: 2,
});
/** Fragment declarations: uniforms, the varying and gevOverlayHidden(). */
export const OVERLAY_DEPTH_FRAGMENT = /* glsl */ `
uniform sampler2D tGevSceneDepth;
uniform vec2 uGevResolution;
uniform float uGevDepthMode;
varying float vGevDepthFree;
bool gevOverlayHidden() {
if (uGevDepthMode < 0.5 || vGevDepthFree > 0.5 || uGevResolution.x < 1.0)
return false;
// three's unpackRGBAToDepth (RGBADepthPacking).
float scene = dot(
texture2D(tGevSceneDepth, gl_FragCoord.xy / uGevResolution),
vec4(255.0 / 256.0, 255.0 / 65536.0, 255.0 / 16777216.0, 1.0 / 16777216.0)
);
#ifdef USE_LOGARITHMIC_DEPTH_BUFFER
float mine = log2(vFragDepth) * logDepthBufFC * 0.5;
#else
float mine = gl_FragCoord.z;
#endif
bool occluded = mine > scene + 2e-6;
return uGevDepthMode < 1.5 ? occluded : !occluded;
}
`;
/** Vertex declaration of the per-vertex escape from the depth test. */
export const OVERLAY_DEPTH_VERTEX = /* glsl */ `
varying float vGevDepthFree;
`;
+5 -3
View File
@@ -3,8 +3,10 @@
// (src/ui/visualPresets.js SHARPEN_SHADER): a 3×3 box blur, then
// center + (center − blur) × amount, clamped to [0, 1].
//
// Where it sits: right after tone mapping and before SMAA or FXAA (before
// `final` in a look without them), as Cesium runs its custom stages after
// Where it sits: right after tone mapping (after gev's overlay of sprites,
// points and lines, which Cesium's sharpen stage also sharpens) and before
// SMAA or FXAA (before `final` in a look without them), as Cesium runs its
// custom stages after
// its tone mapping and before FXAA. After tone mapping the colours are the
// display's [0, 1] range, so highlights do not blow up; anti-aliasing after
// it smooths the stair steps sharpening brings out. Cesium sharpens
@@ -84,7 +86,7 @@ class UnsharpMaskEffect extends Effect {
/** config.gevSharpen: { amount } (Cesium's `amount` uniform). */
export class SharpenEffectDesc extends EffectDesc {
static key = SHARPEN_EFFECT_KEY;
static insertAfter = ['toneMapping'];
static insertAfter = ['gevOverlay', 'toneMapping'];
static insertBefore = ['smaa', 'fxaa', 'final'];
constructor(view, ctx, config) {
+7 -11
View File
@@ -510,17 +510,13 @@ export function createContractSuite(
);
// Pixels close to a colour in a 64 px box around a point: their
// count and centroid.
// A pure colour as drawn through either engine's tone mapping and
// grading (Navara's cinematic look desaturates): every full
// channel above 170 and at least 60 over every empty one.
const like = (colour, data, i) => {
let low = 0;
let high = 255;
for (let k = 0; k < 3; k++)
if (colour[k] > 0.5) high = Math.min(high, data[i + k]);
else low = Math.max(low, data[i + k]);
return high > 170 && high - low >= 60;
};
// A colour as drawn: within 40 of the asked one on every channel
// (both engines draw overlays after tone mapping, unlit).
const like = (colour, data, i) =>
colour.every(
(channel, k) =>
k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
const find = (screen, colour) => {
const half = 32;
const x0 = Math.round(screen.x) - half;