diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index d2ffaa156..c26e48001 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -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(); }, diff --git a/src/globe/navara/lines/index.js b/src/globe/navara/lines/index.js index b92872206..35c105201 100644 --- a/src/globe/navara/lines/index.js +++ b/src/globe/navara/lines/index.js @@ -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 }} 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(); }, }; diff --git a/src/globe/navara/lines/lineMesh.js b/src/globe/navara/lines/lineMesh.js index 042799a81..664acffb1 100644 --- a/src/globe/navara/lines/lineMesh.js +++ b/src/globe/navara/lines/lineMesh.js @@ -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 +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} 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(); diff --git a/src/globe/navara/lines/lineMesh.test.mjs b/src/globe/navara/lines/lineMesh.test.mjs index 3dc232bb6..130d1e650 100644 --- a/src/globe/navara/lines/lineMesh.test.mjs +++ b/src/globe/navara/lines/lineMesh.test.mjs @@ -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]); diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index b73126996..63857d5bf 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -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 }} 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, diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index 1483b5954..e8951e80d 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -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 -#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 +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 +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 ', VERTEX_PROJECT); vertex = replace(vertex, '#include ', VERTEX_UV); - vertex = replace(vertex, '#include ', 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, }); diff --git a/src/globe/navara/movers/moverMesh.test.mjs b/src/globe/navara/movers/moverMesh.test.mjs index 7f7bbd84b..a725eebe1 100644 --- a/src/globe/navara/movers/moverMesh.test.mjs +++ b/src/globe/navara/movers/moverMesh.test.mjs @@ -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 /); 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(); }); diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js new file mode 100644 index 000000000..4fda33aba --- /dev/null +++ b/src/globe/navara/overlay.js @@ -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(); + }, + }; +} diff --git a/src/globe/navara/overlayDepth.js b/src/globe/navara/overlayDepth.js new file mode 100644 index 000000000..49a0dd034 --- /dev/null +++ b/src/globe/navara/overlayDepth.js @@ -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; +`; diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js index 2ddc7d4d8..f4a445074 100644 --- a/src/globe/navara/sharpen.js +++ b/src/globe/navara/sharpen.js @@ -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) { diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index f6232feaa..386b7ad8d 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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;