From d8ef366ab1bdc567b12d887fa8df107262d1f291 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:30:27 +0400 Subject: [PATCH] Classify the Navara launch pad zone onto terrain and 3D tiles with Cesium's fill, rim and emission The zone was a flat disc at ellipsoid height in Navara's opaque scene: it floated over low pads and sank under raised ground or 3D tiles, with hard edges and an unlit rim. A screen-space pass now paints the ground pixels within the radius of the pad from the scene depth, as Cesium's GroundPrimitive classifies them, using one style shared by both engines (LAUNCH_PAD_ZONE_STYLE). --- scripts/package-boundaries.json | 1 + src/layers/launches/policy.js | 16 ++ src/layers/launches/render/cesium.js | 15 +- src/layers/launches/render/navara.js | 128 ++----------- src/layers/launches/render/navara.test.mjs | 110 +++++++++-- src/layers/launches/render/navaraPadGround.js | 179 ++++++++++++++++++ 6 files changed, 317 insertions(+), 132 deletions(-) create mode 100644 src/layers/launches/render/navaraPadGround.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2b60e4ac0..4c770e1fc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3689,6 +3689,7 @@ "src/layers/localAdsb/render/navara.js", "src/layers/vessels/render/navara.js", "src/layers/launches/render/navara.js", + "src/layers/launches/render/navaraPadGround.js", "src/layers/satellites/render/navara.js", "src/layers/shared/render/engineView.js", "src/celestialRing/render/cesium.js", diff --git a/src/layers/launches/policy.js b/src/layers/launches/policy.js index 07a27f688..aed95cfab 100644 --- a/src/layers/launches/policy.js +++ b/src/layers/launches/policy.js @@ -88,6 +88,22 @@ export const MISSION_ORBIT_DASHES_PER_GROUP = 100; export const LAUNCH_PAD_ZONE_RADIUS_M = 500; +/** + * The pad zone's look on both engines, as a fraction of the radius: a faint + * fill, a bright rim between rimStart and rimEnd whose colour gains the + * emission, and a soft edge from rimEnd to the radius. The zone is classified + * onto the surface under the pad (terrain and 3D tiles), never drawn at a + * height. + */ +export const LAUNCH_PAD_ZONE_STYLE = Object.freeze({ + color: '#22e6e6', + fillAlpha: 0.105, + rimAlpha: 0.72, + rimStart: 0.952, + rimEnd: 0.985, + emission: 0.35, +}); + export const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; export const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; diff --git a/src/layers/launches/render/cesium.js b/src/layers/launches/render/cesium.js index 8889db475..09a02ae97 100644 --- a/src/layers/launches/render/cesium.js +++ b/src/layers/launches/render/cesium.js @@ -5,8 +5,11 @@ import { MISSION_ORBIT_PATTERN_GROUPS, MISSION_ORBIT_DASHES_PER_GROUP, LAUNCH_PAD_ZONE_RADIUS_M, + LAUNCH_PAD_ZONE_STYLE, } from '../policy.js'; const HOVER_PICK_INTERVAL_MS = 120; +/** A GLSL float literal (always with a decimal point). */ +const glslFloat = (value) => value.toFixed(4); /** * Bound hover picking independently of precise click selection. A move inside @@ -209,19 +212,19 @@ export function createLaunchesCesiumRenderer(engine, { viewer } = {}) { fabric: { type: 'GevLaunchPadZone', uniforms: { - color: Cesium.Color.fromCssColorString('#22e6e6'), - fillAlpha: 0.105, - rimAlpha: 0.72, + color: Cesium.Color.fromCssColorString(LAUNCH_PAD_ZONE_STYLE.color), + fillAlpha: LAUNCH_PAD_ZONE_STYLE.fillAlpha, + rimAlpha: LAUNCH_PAD_ZONE_STYLE.rimAlpha, }, source: ` czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; float radius = length(centered); - float inside = 1.0 - smoothstep(0.985, 1.0, radius); - float rim = smoothstep(0.952, 0.985, radius) * inside; + float inside = 1.0 - smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)}, 1.0, radius); + float rim = smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimStart)}, ${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)}, radius) * inside; material.diffuse = color.rgb; - material.emission = color.rgb * rim * 0.35; + material.emission = color.rgb * rim * ${glslFloat(LAUNCH_PAD_ZONE_STYLE.emission)}; material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); return material; }`, diff --git a/src/layers/launches/render/navara.js b/src/layers/launches/render/navara.js index 48a1421fc..edbaf4126 100644 --- a/src/layers/launches/render/navara.js +++ b/src/layers/launches/render/navara.js @@ -1,21 +1,11 @@ -// Mission points, paths and the documented flat pad disc on Navara. +// Mission points, paths and the pad zone on Navara. import * as geo from '../../../geo/index.js'; -import { - Group, - Mesh, - CircleGeometry, - RingGeometry, - MeshBasicMaterial, - DoubleSide, -} from 'three'; import { createMoverBank } from '../../aircraft/render/navara.js'; import { createEngineLayerView } from '../../shared/render/engineView.js'; import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; -import { LAUNCH_PAD_ZONE_RADIUS_M } from '../policy.js'; +import { createPadGround } from './navaraPadGround.js'; -const PAD_KIND = 'gevLaunchPad'; -const registered = new WeakSet(); const read = (v, time) => typeof v?.getValue === 'function' ? v.getValue(time) : v; const property = (v) => @@ -23,7 +13,7 @@ const property = (v) => const rgba = (c) => (c ? [c.red, c.green, c.blue, c.alpha] : [1, 1, 1, 1]); export function createLaunchesNavaraRenderer( engine, - { view: nativeView, MeshDesc } = {}, + { view: nativeView, EffectDesc, NavaraEffect } = {}, ) { let destroyed = false, sequence = 0, @@ -355,113 +345,31 @@ export function createLaunchesNavaraRenderer( return source; }; const createPad = (launch) => { - const group = new Group(); - group.matrixAutoUpdate = false; - group.frustumCulled = false; - const fill = new Mesh( - new CircleGeometry(LAUNCH_PAD_ZONE_RADIUS_M, 96), - new MeshBasicMaterial({ - color: 0x22e6e6, - transparent: true, - opacity: 0.105, - side: DoubleSide, - depthWrite: false, - polygonOffset: true, - polygonOffsetFactor: -1, - polygonOffsetUnits: -4, - }), - ); - const rim = new Mesh( - new RingGeometry( - LAUNCH_PAD_ZONE_RADIUS_M * 0.952, - LAUNCH_PAD_ZONE_RADIUS_M * 0.985, - 96, - ), - new MeshBasicMaterial({ - color: 0x22e6e6, - transparent: true, - opacity: 0.687, - side: DoubleSide, - depthWrite: false, - polygonOffset: true, - polygonOffsetFactor: -1, - polygonOffsetUnits: -4, - }), - ); - fill.frustumCulled = false; - rim.frustumCulled = false; - rim.renderOrder = 1; - group.add(fill, rim); - let padHeightM = 0; + // Classified onto the surface under the pad as Cesium's GroundPrimitive + // (render/navaraPadGround.js); nothing is drawn at a height. + const ground = + nativeView?.registerEffect && EffectDesc && NavaraEffect + ? createPadGround(nativeView, { EffectDesc, NavaraEffect }, launch) + : null; let dead = false, - freed = false; - const dispose = () => { - if (freed) return; - freed = true; - for (const mesh of [fill, rim]) { - mesh.geometry.dispose(); - mesh.material.dispose(); - } - }; - const place = (height) => { - if (dead) return; - padHeightM = Number.isFinite(height) ? height : 0; - group.matrix.fromArray( - geo.transforms.eastNorthUpToFixedFrame( - geo.cartesian3.fromDegrees( - launch.lon, - launch.lat, - Number.isFinite(height) ? height : 0, - ), - geo.ellipsoid.WGS84, - ), - ); - group.matrixWorldNeedsUpdate = true; - engine.render.request('launch-pad'); - }; - // W1 parity decision: a flat ellipsoid disc, not terrain classification. - // A coarse tile height sampled during the focus flight can be kilometres off. - place(0); - let handle; - if (nativeView) { - if (!registered.has(nativeView)) { - registered.add(nativeView); - nativeView.registerMesh( - PAD_KIND, - class extends MeshDesc { - constructor(view, ctx, config) { - super(view, ctx, config); - this.pad = config[PAD_KIND]; - } - createMesh() { - return this.pad.group; - } - onDestroy() { - this.pad.dispose(); - super.onDestroy(); - } - }, - ); - } - handle = nativeView.addMesh({ [PAD_KIND]: { group, dispose } }); - } + shown = true; + engine.render.request('launch-pad'); const pad = { - get heightM() { - return padHeightM; - }, + /** Classified, as on Cesium: the zone has no height of its own. */ + heightM: null, get show() { - return group.visible; + return shown; }, set show(value) { - if (group.visible === Boolean(value)) return; - group.visible = Boolean(value); + if (shown === Boolean(value)) return; + shown = Boolean(value); + if (ground) ground.visible = shown; engine.render.request('launch-pad'); }, destroy() { if (dead) return; dead = true; - handle?.delete(); - dispose(); + ground?.destroy(); primitives.delete(pad); engine.render.request('launch-pad'); }, diff --git a/src/layers/launches/render/navara.test.mjs b/src/layers/launches/render/navara.test.mjs index 85385636e..1edef52a6 100644 --- a/src/layers/launches/render/navara.test.mjs +++ b/src/layers/launches/render/navara.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import * as geo from '../../../geo/index.js'; import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; import { WHITE } from '../../shared/color.js'; +import { PerspectiveCamera, RGBADepthPacking } from 'three'; import { createLaunchesNavaraRenderer } from './navara.js'; +import { PAD_GROUND_KEY, padGroundShader } from './navaraPadGround.js'; +import { LAUNCH_PAD_ZONE_STYLE } from '../policy.js'; const position = geo.cartesian3.fromDegrees(0, 0, 1000); test('mission bank growth, callbacks and source visibility write actual mover stores', async () => { @@ -130,41 +133,116 @@ test('mission hover uses engine drillPick and obeys disabled state', () => { engine.destroy(); }); -test('flat pad uses a native mesh description, ellipsoid ENU placement and idempotent disposal', () => { +test("the pad zone is classified onto the surface with Cesium's fill, rim and emission", () => { const engine = createFakeEngine(); engine.fake.script('sampleHeight', 125); - let config, - removed = 0; + const effects = []; + let Desc; + class EffectDesc { + constructor(view, ctx, config) { + this.view = view; + this.ctx = ctx; + this.config = config; + } + find() { + return mrt; + } + onDestroy() {} + } + class NavaraEffect { + constructor(camera, effect) { + this.effect = effect; + } + } + const mrt = { + depthBuffer: { isTexture: true }, + depthBufferPacking: RGBADepthPacking, + }; + const camera = new PerspectiveCamera(); + camera.position.set(6378137 + 2000, 0, 0); + camera.updateMatrixWorld(); const view = { - registerMesh() {}, - addMesh(value) { - config = value; + camera: { raw: camera }, + registerEffect(key, desc) { + assert.equal(key, PAD_GROUND_KEY); + assert.ok(desc.insertAfter.includes('toneMapping')); + Desc = desc; + }, + addEffect(config) { + const desc = new Desc( + view, + { getRenderTarget: () => ({ depthTexture: {} }) }, + config, + ); + desc.createPass(); + const record = { desc, visible: config.visible, deleted: false }; + effects.push(record); return { - delete() { - removed++; + update: ({ visible }) => (record.visible = visible), + delete: () => { + record.deleted = true; + desc.onDestroy(); }, }; }, }; const renderer = createLaunchesNavaraRenderer(engine, { view, - MeshDesc: class {}, + EffectDesc, + NavaraEffect, }); const pad = renderer.createPad({ id: 'x', lon: 0, lat: 0 }); assert.equal( engine.fake.callsOf('surface.sampleHeight').length, 0, - 'flat pad must not sample a coarse tile', + 'classified onto the surface: no coarse tile height is sampled', ); - const group = config.gevLaunchPad.group; - assert.equal(group.children.length, 2); - assert.equal(group.matrix.elements[12], 6378137); - assert.equal(group.children[0].material.polygonOffsetUnits, -4); + assert.equal(pad.heightM, null, 'no height of its own, as on Cesium'); + assert.equal(effects.length, 1); + const { desc } = effects[0]; + desc.updateFrame(); + const u = desc.effect.uniforms; + // The pad 2 km below the camera, the ENU axes of lon 0, lat 0. + assert.deepEqual(u.get('uPadOffset').value.toArray(), [-2000, 0, 0]); + assert.deepEqual(u.get('uUp').value.toArray(), [1, 0, 0]); + assert.deepEqual( + u + .get('uEast') + .value.toArray() + .map((v) => Math.round(v) + 0), + [0, 1, 0], + ); + assert.deepEqual( + u + .get('uNorth') + .value.toArray() + .map((v) => Math.round(v) + 0), + [0, 0, 1], + ); + assert.equal(u.get('tAllDepth').value, mrt.depthBuffer); + // Cesium's GevLaunchPadZone material, from the same style. + const rgb = u + .get('uPadColor') + .value.toArray() + .map((v) => Math.round(v * 255)); + assert.deepEqual(rgb, [0x22, 0xe6, 0xe6]); + for (const part of [ + `smoothstep(${LAUNCH_PAD_ZONE_STYLE.rimEnd.toFixed(4)},1.0,radius)`, + `smoothstep(${LAUNCH_PAD_ZONE_STYLE.rimStart.toFixed(4)},${LAUNCH_PAD_ZONE_STYLE.rimEnd.toFixed(4)},radius)`, + `mix(${LAUNCH_PAD_ZONE_STYLE.fillAlpha.toFixed(4)},${LAUNCH_PAD_ZONE_STYLE.rimAlpha.toFixed(4)},rim)`, + `(1.0+${LAUNCH_PAD_ZONE_STYLE.emission.toFixed(4)}*rim)`, + '/500.0000', + 'if(depth e.deleted).length, 1); renderer.destroy(); engine.destroy(); }); diff --git a/src/layers/launches/render/navaraPadGround.js b/src/layers/launches/render/navaraPadGround.js new file mode 100644 index 000000000..1f5c59c50 --- /dev/null +++ b/src/layers/launches/render/navaraPadGround.js @@ -0,0 +1,179 @@ +// The launch pad zone on Navara, classified onto the surface as Cesium's +// GroundPrimitive does (render/cesium.js): a screen-space pass reads the depth +// of each pixel, puts it back in the world and paints the pixels of the +// ground (terrain or 3D tiles, not a model in front of them) that lie within +// the zone's radius of the pad, with Cesium's fill, rim and emission. Nothing +// is drawn at a height, so the zone neither floats over nor sinks under the +// ground, and no height is sampled. +import { Effect, EffectAttribute } from 'postprocessing'; +import { Matrix3, Uniform, Vector3 } from 'three'; +import * as geo from '../../../geo/index.js'; +import { + groundSurfaceUniforms, + updateGroundSurface, +} from '../../shared/render/navaraGroundSurface.js'; +import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js'; + +export const PAD_GROUND_KEY = 'gevLaunchPadGround'; +// The surface above or below the pad that the zone still classifies, as +// Cesium's classification volume spans the terrain heights there. +export const PAD_GROUND_BAND_M = 9000; +const registered = new WeakSet(); +const glslFloat = (value) => Number(value).toFixed(4); +/** A #rrggbb colour as display (sRGB) values, as Cesium's material reads it. */ +const displayRgb = (hex) => + [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); + +export const padGroundShader = /* glsl */ ` +uniform sampler2D tAllDepth; +uniform sampler2D tSurfaceDepth; +uniform mat4 uInverseProjection; +uniform float uLogDepthFC; +uniform mat3 uViewToWorld; +uniform vec3 uPadOffset; +uniform vec3 uEast; +uniform vec3 uNorth; +uniform vec3 uUp; +uniform vec3 uPadColor; +vec3 toDisplay(vec3 c) { + return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c)); +} +vec3 toLinear(vec3 c) { + return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c)); +} +void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { + outputColor=inputColor; + float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv)); + if(depth>=1.0) return; + // Only the ground: a mesh or model in front of it keeps its own colour. + float surface=texture2D(tSurfaceDepth,uv).r; + if(depth${glslFloat(PAD_GROUND_BAND_M)}) return; + float radius=length(vec2(dot(rel,uEast),dot(rel,uNorth)))/${glslFloat(LAUNCH_PAD_ZONE_RADIUS_M)}; + if(radius>=1.0) return; + // Cesium's GevLaunchPadZone material, blended as in its framebuffer. + float inside=1.0-smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)},1.0,radius); + float rim=smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimStart)},${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)},radius)*inside; + float alpha=inside*mix(${glslFloat(LAUNCH_PAD_ZONE_STYLE.fillAlpha)},${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimAlpha)},rim); + vec3 color=min(uPadColor*(1.0+${glslFloat(LAUNCH_PAD_ZONE_STYLE.emission)}*rim),vec3(1.0)); + outputColor=vec4(toLinear(mix(toDisplay(inputColor.rgb),color,alpha)),inputColor.a); +} +`; + +/** The pad's ECEF position and its east, north and up axes. */ +export function padFrame(launch) { + const m = geo.transforms.eastNorthUpToFixedFrame( + geo.cartesian3.fromDegrees(launch.lon, launch.lat, 0), + geo.ellipsoid.WGS84, + ); + return { + origin: [m[12], m[13], m[14]], + east: [m[0], m[1], m[2]], + north: [m[4], m[5], m[6]], + up: [m[8], m[9], m[10]], + }; +} + +function padGroundClass(EffectDesc, NavaraEffect) { + return class LaunchPadGroundDesc extends EffectDesc { + static key = PAD_GROUND_KEY; + static insertAfter = [ + 'gevEarthquakeGround', + 'gevObservedWeather', + 'gevNepalEvent', + 'gevRecentImagery', + 'gevFirmsGround', + 'toneMapping', + ]; + static insertBefore = [ + 'gevPreSharpenOverlay', + 'gevSharpen', + 'smaa', + 'fxaa', + 'gevOverlay', + 'final', + ]; + constructor(view, context, config) { + super(view, context, config); + this.owner = config[PAD_GROUND_KEY].owner; + } + createPass() { + this.effect = new Effect('GevLaunchPadGround', padGroundShader, { + attributes: EffectAttribute.CONVOLUTION, + uniforms: new Map([ + ...groundSurfaceUniforms(), + ['uViewToWorld', new Uniform(new Matrix3())], + ['uPadOffset', new Uniform(new Vector3())], + ['uEast', new Uniform(new Vector3())], + ['uNorth', new Uniform(new Vector3())], + ['uUp', new Uniform(new Vector3())], + [ + 'uPadColor', + new Uniform( + new Vector3(...displayRgb(LAUNCH_PAD_ZONE_STYLE.color)), + ), + ], + ]), + }); + return new NavaraEffect(this.view.camera.raw, this.effect); + } + updateFrame() { + const frame = this.owner.frame, + camera = this.view.camera.raw; + if (!this.effect || !frame || !camera) return; + updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); + const u = this.effect.uniforms; + u.get('uViewToWorld').value.setFromMatrix4(camera.matrixWorld); + const e = camera.matrixWorld.elements; + u.get('uPadOffset').value.set( + frame.origin[0] - e[12], + frame.origin[1] - e[13], + frame.origin[2] - e[14], + ); + u.get('uEast').value.fromArray(frame.east); + u.get('uNorth').value.fromArray(frame.north); + u.get('uUp').value.fromArray(frame.up); + } + onDestroy() { + this.effect?.dispose(); + super.onDestroy(); + } + }; +} + +/** + * Show one pad zone on a Navara view; `show` toggles it, `destroy` removes it. + * @param {object} view The Navara view. + * @param {{ EffectDesc: Function, NavaraEffect: Function }} sdk + * @param {{ lon: number, lat: number }} launch + */ +export function createPadGround(view, { EffectDesc, NavaraEffect }, launch) { + if (!registered.has(view)) { + view.registerEffect( + PAD_GROUND_KEY, + padGroundClass(EffectDesc, NavaraEffect), + ); + registered.add(view); + } + const owner = { frame: padFrame(launch) }; + let handle = view.addEffect({ + [PAD_GROUND_KEY]: { owner }, + visible: true, + }); + return { + owner, + set visible(value) { + handle?.update({ visible: Boolean(value) }); + }, + destroy() { + handle?.delete(); + handle = null; + owner.frame = null; + }, + }; +}