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).
This commit is contained in:
German Khokhlov committed 2026-11-03 21:30:36 +04:00
1 parent f0d38897f4
commit d8ef366ab1
6 files changed
+317 -132

No files matched your search

+1
View File
@@ -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",
+16
View File
@@ -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;
+9 -6
View File
@@ -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;
}`,
+18 -110
View File
@@ -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');
},
+94 -16
View File
@@ -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<surface-2e-7) return;',
])
assert.ok(padGroundShader.includes(part), part);
pad.show = false;
assert.equal(group.visible, false);
assert.equal(effects[0].visible, false);
pad.show = true;
assert.equal(effects[0].visible, true);
pad.destroy();
pad.destroy();
assert.equal(removed, 1);
assert.equal(effects[0].deleted, true);
assert.equal(effects.filter((e) => e.deleted).length, 1);
renderer.destroy();
engine.destroy();
});
@@ -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<surface-2e-7) return;
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
vec3 point=ray.xyz*(distance/-ray.z);
// Relative to the pad, from offsets small enough for single precision.
vec3 rel=uViewToWorld*point-uPadOffset;
if(abs(dot(rel,uUp))>${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;
},
};
}