mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
Treat contacts with the Cyber sonar sweep on Navara behind an optional render.setContactSonar capability
The sprite and point mover shaders rate each contact by its anchor against the sweep with the cyberSonar.js sampler maths; the engine sonar scene feeds the theme, settings and shared sweep angle each drawn frame and keeps the fleet cadence only while the sweep runs over contacts.
This commit is contained in:
1 parent
c2d7a6dbb0
commit
1a3f6f2486
16 files changed
+1040
-6
No files matched your search
@@ -2608,6 +2608,7 @@
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/placeNames.js",
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonarEngine.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
@@ -3600,6 +3601,7 @@
|
||||
"src/globe/navara/bloom.js",
|
||||
"src/globe/navara/bloomSettings.js",
|
||||
"src/globe/navara/cockpitClouds.js",
|
||||
"src/globe/navara/sonar.js",
|
||||
"src/globe/navara/surface.js",
|
||||
"src/globe/navara/viewOptions.js",
|
||||
"src/app/engineChoice.js",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// The Cyber HUD sonar on Navara (Stage 6 C2): with the Cyber HUD on, the
|
||||
// sweep lights the contacts it passes and dims the rest to the opacity
|
||||
// floor, by each contact's place on screen exactly as the CPU sampler of
|
||||
// src/cyberSonar.js rates it; the sweep turns; with the sweep off every
|
||||
// contact takes the neutral factor; a parked scene draws at the fleet
|
||||
// cadence only while the sweep runs over contacts; leaving the Cyber HUD
|
||||
// gives the contacts back unchanged. On Cesium the command adapter keeps the
|
||||
// treatment and the engine draws none. The contacts are an engine point set
|
||||
// (engine.movers) in a ring around the screen centre. Keyless preview under
|
||||
// the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-sonar.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs';
|
||||
import { sonarFactorAt } from '../src/globe/navara/sonar.js';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const WIDTH = 1280;
|
||||
const HEIGHT = 800;
|
||||
|
||||
// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is
|
||||
// always well inside the 24° sector), and one outside the keyhole.
|
||||
function contactPlaces() {
|
||||
const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5;
|
||||
const places = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const angle = (i / 32) * Math.PI * 2;
|
||||
places.push({
|
||||
x: Math.round(WIDTH / 2 + Math.sin(angle) * radius),
|
||||
y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius),
|
||||
});
|
||||
}
|
||||
places.push({ x: 120, y: 110 });
|
||||
return places;
|
||||
}
|
||||
|
||||
// The luma at each contact (a 3×3 mean) of the next drawn frame, and the
|
||||
// sonar state that frame drew with.
|
||||
const read = (page, places) =>
|
||||
page.evaluate(async (places) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = document.createElement('canvas');
|
||||
const source = engine.host.canvas;
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
let sonar = null;
|
||||
await new Promise((resolve) => {
|
||||
const off = engine.frames.postRender.addEventListener(() => {
|
||||
off();
|
||||
ctx.drawImage(source, 0, 0);
|
||||
sonar = engine.render.contactSonar?.() ?? null;
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-sonar');
|
||||
});
|
||||
const scale = source.width / engine.host.size().width;
|
||||
const luma = places.map(({ x, y }) => {
|
||||
const data = ctx.getImageData(
|
||||
Math.round(x * scale) - 1,
|
||||
Math.round(y * scale) - 1,
|
||||
3,
|
||||
3,
|
||||
).data;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||
return sum / 9;
|
||||
});
|
||||
return { luma, sonar };
|
||||
}, places);
|
||||
|
||||
// Frames the engine draws in `ms` without being asked.
|
||||
const framesIn = (page, ms) =>
|
||||
page.evaluate(async (ms) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
let frames = 0;
|
||||
const off = engine.frames.postRender.addEventListener(() => frames++);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
off();
|
||||
return frames;
|
||||
}, ms);
|
||||
|
||||
const mean = (values) =>
|
||||
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
|
||||
|
||||
await strict.run(
|
||||
{ args: ['--use-angle=metal', '--enable-gpu'] },
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: WIDTH, height: HEIGHT });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const places = contactPlaces();
|
||||
const placed = await page.evaluate((places) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const set = engine.movers.createSet({
|
||||
id: 'qa-sonar-contacts',
|
||||
kind: 'point',
|
||||
capacity: places.length,
|
||||
depthTest: 'always',
|
||||
point: { sizePx: 14, outlinePx: 0 },
|
||||
});
|
||||
window.__qaSonarSet = set;
|
||||
let missing = 0;
|
||||
places.forEach((place, index) => {
|
||||
const position = engine.camera.pickEllipsoid(place);
|
||||
if (!position) missing++;
|
||||
set.set(index, { position, color: [1, 1, 1, 1] });
|
||||
});
|
||||
set.setCount(places.length);
|
||||
set.commit();
|
||||
return { missing, sonar: engine.render.contactSonar?.() ?? null };
|
||||
}, places);
|
||||
check(
|
||||
'Navara treats contacts itself; the sonar starts off',
|
||||
placed.missing === 0 && placed.sonar?.enabled === false,
|
||||
placed,
|
||||
);
|
||||
const baseline = await read(page, places);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setHudVariant('cyber');
|
||||
// Normal keeps the contacts' own colours; the HUD stays on (in Normal
|
||||
// it would hide itself, and the sweep with it).
|
||||
styleManager.setStyle('normal');
|
||||
styleManager.hud.setMode('on');
|
||||
styleManager._setCyberSonarEnabled(true);
|
||||
styleManager._setCyberSonarSetting('opacity', 35);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
const theme = await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.uiTheme,
|
||||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||||
hud: document.getElementById('intel-hud')?.classList.contains('active'),
|
||||
}));
|
||||
check(
|
||||
'the Cyber HUD runs with the contact adapter',
|
||||
theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud,
|
||||
theme,
|
||||
);
|
||||
|
||||
const sweeps = [];
|
||||
for (const wait of [0, 1300, 1300]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
const { luma, sonar } = await read(page, places);
|
||||
const expected = places.map(({ x, y }) =>
|
||||
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
|
||||
);
|
||||
const lit = luma.filter((_, i) => expected[i] > 0.9);
|
||||
const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100);
|
||||
sweeps.push({
|
||||
spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)),
|
||||
angle: sonar.angleDeg,
|
||||
lit: lit.length,
|
||||
litLuma: mean(lit),
|
||||
dimLuma: mean(dim),
|
||||
brightest: luma.indexOf(Math.max(...luma)),
|
||||
expectedBrightest: expected.indexOf(Math.max(...expected)),
|
||||
sonar,
|
||||
});
|
||||
check(
|
||||
`the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`,
|
||||
sonar.enabled &&
|
||||
sonar.active &&
|
||||
sonar.opacity === 35 &&
|
||||
lit.length >= 1 &&
|
||||
dim.length >= 8 &&
|
||||
mean(lit) > mean(dim) + 20 &&
|
||||
expected[places.length - 1] === 0.35,
|
||||
sweeps.at(-1),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'the sweep turns: another contact is lit',
|
||||
new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 &&
|
||||
new Set(sweeps.map((s) => s.brightest)).size > 1,
|
||||
sweeps.map(({ angle, brightest, expectedBrightest }) => ({
|
||||
angle,
|
||||
brightest,
|
||||
expectedBrightest,
|
||||
})),
|
||||
);
|
||||
const cadence = await framesIn(page, 1500);
|
||||
check(
|
||||
'a parked scene draws at the fleet cadence while the sweep runs over contacts',
|
||||
cadence >= 8,
|
||||
{ frames: cadence },
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._setCyberSonarEnabled(false),
|
||||
);
|
||||
const neutral = await read(page, places);
|
||||
const spread =
|
||||
Math.max(...neutral.luma.slice(0, 32)) -
|
||||
Math.min(...neutral.luma.slice(0, 32));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const idle = await framesIn(page, 1500);
|
||||
check(
|
||||
'with the sweep off every contact takes the neutral factor and the scene rests',
|
||||
neutral.sonar.enabled &&
|
||||
!neutral.sonar.active &&
|
||||
// What still differs is the map under the translucent contacts.
|
||||
spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 &&
|
||||
idle <= 3,
|
||||
{ spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle },
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setCyberSonarEnabled(true);
|
||||
styleManager._setHudVariant('tactical');
|
||||
styleManager.setStyle('normal');
|
||||
styleManager.hud.setMode('auto');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
const after = await read(page, places);
|
||||
const drift = Math.max(
|
||||
...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])),
|
||||
);
|
||||
check(
|
||||
'leaving the Cyber HUD gives the contacts back unchanged',
|
||||
after.sonar.enabled === false && drift < 3,
|
||||
{ drift },
|
||||
);
|
||||
await page.evaluate(() => window.__qaSonarSet.destroy());
|
||||
|
||||
// Cesium keeps its command adapter; its engine draws no sonar.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setHudVariant('cyber');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.cyberSonarGpu,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const cesium = await page.evaluate(() => ({
|
||||
setContactSonar:
|
||||
typeof window.__godsEyeView.engine.render.setContactSonar,
|
||||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||||
}));
|
||||
check(
|
||||
'Cesium: the command adapter treats contacts, the engine none',
|
||||
cesium.setContactSonar === 'undefined' && cesium.gpu === 'supported',
|
||||
cesium,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._setHudVariant('tactical'),
|
||||
);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'sonar.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
import { createCyberSonarEngineScene } from '../cyberSonarEngine.js';
|
||||
import { panelAwareVisibility } from './embed.js';
|
||||
import { attachScenePart, currentSceneOf } from './sceneParts.js';
|
||||
|
||||
@@ -104,9 +105,11 @@ export function createApplicationData({
|
||||
nextCatalog.weatherClock,
|
||||
);
|
||||
styleManager.attachDataManager(dataManager);
|
||||
// The sonar scene draws on the viewer; the Navara engine has none.
|
||||
// The sonar treats contacts on the viewer, or through an engine
|
||||
// that draws it (render.setContactSonar, Navara).
|
||||
if (next.viewer)
|
||||
release(createCyberSonarScene(next.viewer, dataManager));
|
||||
else release(createCyberSonarEngineScene(next.engine, dataManager));
|
||||
if (!binding) return;
|
||||
// A teardown or switch during the attachment must not wait for slow
|
||||
// layer attachments: it ends this wait, and the detach releases the
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// The Cyber HUD sonar's contact treatment on a globe engine that draws it
|
||||
// itself (render.setContactSonar, the `contactSonar` capability: Navara's
|
||||
// sprite and point movers), the counterpart of src/cyberSonarScene.js on a
|
||||
// Cesium viewer. Each drawn frame hands the engine the theme's state, the
|
||||
// sweep's settings and its angle on the shared clock (the CSS sweep's), so
|
||||
// the contacts and the rings turn together; while the sweep runs over
|
||||
// visible contacts a parked scene draws at the fleet cadence, as on Cesium.
|
||||
|
||||
import { engineCapabilities } from './globe/contract.js';
|
||||
import {
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
cyberSonarAngleDeg,
|
||||
isCyberContactSonarActive,
|
||||
isCyberContactThemeActive,
|
||||
isCyberSonarActive,
|
||||
readCyberSonarSettings,
|
||||
syncCyberSonarAnimation,
|
||||
} from './cyberSonar.js';
|
||||
|
||||
/** The engine's sonar state for this moment (render.setContactSonar). */
|
||||
export function cyberSonarEngineState(
|
||||
nowMs,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
const settings = readCyberSonarSettings(root);
|
||||
return {
|
||||
enabled: isCyberContactThemeActive({ root }),
|
||||
active: isCyberContactSonarActive({ root }),
|
||||
angleDeg: cyberSonarAngleDeg(nowMs),
|
||||
range: settings.range,
|
||||
opacity: settings.opacity,
|
||||
sector: settings.sector,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sprite and point instances the engine draws (models keep their colours). */
|
||||
const contactCount = (engine) =>
|
||||
engine.movers
|
||||
.list()
|
||||
.filter((set) => set.kind !== 'model')
|
||||
.reduce((count, set) => count + set.count, 0);
|
||||
|
||||
/**
|
||||
* @param {import('./globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ subscribe?: Function }} [manager] The layer manager: a
|
||||
* visibility change draws a frame.
|
||||
* @returns {() => void} Removes the treatment.
|
||||
*/
|
||||
export function createCyberSonarEngineScene(
|
||||
engine,
|
||||
manager,
|
||||
{
|
||||
now = () => performance.now(),
|
||||
root = globalThis.document?.documentElement,
|
||||
setTimer = (callback, ms) => setTimeout(callback, ms),
|
||||
clearTimer = (id) => clearTimeout(id),
|
||||
} = {},
|
||||
) {
|
||||
if (!engineCapabilities(engine).contactSonar) return () => {};
|
||||
let timer = null;
|
||||
let disposed = false;
|
||||
// The Cyber contact theme counts on the contact adapter (cyberSonar.js
|
||||
// isCyberContactThemeActive): this engine has one.
|
||||
if (root?.dataset) root.dataset.cyberSonarGpu = 'supported';
|
||||
const removeRender = engine.frames.preRender.addEventListener(() => {
|
||||
if (isCyberSonarActive({ root })) syncCyberSonarAnimation(root);
|
||||
engine.render.setContactSonar(cyberSonarEngineState(now(), root));
|
||||
});
|
||||
const removePostRender = engine.frames.postRender.addEventListener(() => {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
if (isCyberContactSonarActive({ root }) && contactCount(engine))
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
if (!disposed && isCyberContactSonarActive({ root }))
|
||||
engine.render.request('cyber-sonar');
|
||||
}, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
});
|
||||
const unsubscribe = manager?.subscribe?.((change) => {
|
||||
if (change.type === 'visibility') engine.render.request('cyber-sonar');
|
||||
});
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
removeRender();
|
||||
removePostRender();
|
||||
unsubscribe?.();
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
if (root?.dataset) delete root.dataset.cyberSonarGpu;
|
||||
if (!engine.isDestroyed?.())
|
||||
engine.render.setContactSonar({ enabled: false, active: false });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
import { CYBER_SONAR_RENDER_INTERVAL_MS } from './cyberSonar.js';
|
||||
import {
|
||||
createCyberSonarEngineScene,
|
||||
cyberSonarEngineState,
|
||||
} from './cyberSonarEngine.js';
|
||||
|
||||
// The DOM the Cyber theme reads: the root's dataset, the body's cockpit
|
||||
// class, the HUD's visibility and the reduced-motion preference.
|
||||
function withCyberPage(run) {
|
||||
const names = ['document', 'matchMedia'];
|
||||
const saved = Object.fromEntries(names.map((n) => [n, globalThis[n]]));
|
||||
const root = {
|
||||
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '40' },
|
||||
style: {
|
||||
setProperty() {},
|
||||
removeProperty() {},
|
||||
getPropertyValue: () => '',
|
||||
},
|
||||
querySelector: () => null,
|
||||
};
|
||||
const hud = { active: true };
|
||||
globalThis.document = {
|
||||
documentElement: root,
|
||||
body: { classList: { contains: () => false } },
|
||||
getElementById: (id) =>
|
||||
id === 'intel-hud'
|
||||
? { classList: { contains: (name) => name === 'active' && hud.active } }
|
||||
: null,
|
||||
};
|
||||
globalThis.matchMedia = () => ({ matches: false });
|
||||
return Promise.resolve(run({ root, hud })).finally(() => {
|
||||
for (const [name, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete globalThis[name];
|
||||
else globalThis[name] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('without the contactSonar capability the engine scene does nothing', () =>
|
||||
withCyberPage(({ root }) => {
|
||||
const dispose = createCyberSonarEngineScene(createFakeEngine(), null, {
|
||||
root,
|
||||
});
|
||||
assert.equal(root.dataset.cyberSonarGpu, undefined);
|
||||
dispose();
|
||||
}));
|
||||
|
||||
test('each drawn frame hands the engine the theme, the settings and the sweep angle', () =>
|
||||
withCyberPage(async ({ root, hud }) => {
|
||||
const engine = createFakeEngine({ kind: 'navara', contactSonar: true });
|
||||
let now = 1300;
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const dispose = createCyberSonarEngineScene(engine, null, {
|
||||
root,
|
||||
now: () => now,
|
||||
setTimer: (callback, ms) => {
|
||||
timers.set(nextTimer, [callback, ms]);
|
||||
return nextTimer++;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
});
|
||||
assert.equal(root.dataset.cyberSonarGpu, 'supported');
|
||||
engine.fake.frame();
|
||||
assert.deepEqual(engine.render.contactSonar(), {
|
||||
enabled: true,
|
||||
active: true,
|
||||
angleDeg: 90,
|
||||
range: 100,
|
||||
opacity: 40,
|
||||
sector: 24,
|
||||
});
|
||||
assert.deepEqual(
|
||||
engine.render.contactSonar(),
|
||||
cyberSonarEngineState(now, root),
|
||||
);
|
||||
// No contacts: no render demand of its own.
|
||||
assert.equal(timers.size, 0);
|
||||
const set = engine.movers.createSet({
|
||||
id: 'qa-contacts',
|
||||
kind: 'point',
|
||||
capacity: 2,
|
||||
point: { sizePx: 8 },
|
||||
});
|
||||
set.set(0, { position: { x: 6378137, y: 0, z: 0 } });
|
||||
set.setCount(1);
|
||||
set.commit();
|
||||
now = 2600;
|
||||
engine.fake.frame();
|
||||
assert.equal(engine.render.contactSonar().angleDeg, 180);
|
||||
assert.equal(timers.size, 1);
|
||||
const [[callback, ms]] = timers.values();
|
||||
assert.equal(ms, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
const before = engine.fake.renderRequests;
|
||||
callback();
|
||||
assert.equal(engine.fake.renderRequests, before + 1);
|
||||
// The HUD hidden: the treatment ends on the next frame.
|
||||
hud.active = false;
|
||||
engine.fake.frame();
|
||||
assert.equal(engine.render.contactSonar().enabled, false);
|
||||
dispose();
|
||||
assert.equal(root.dataset.cyberSonarGpu, undefined);
|
||||
assert.equal(engine.render.contactSonar().active, false);
|
||||
const calls = engine.fake.calls.length;
|
||||
engine.fake.frame();
|
||||
assert.equal(
|
||||
engine.fake.calls
|
||||
.slice(calls)
|
||||
.filter(({ method }) => method === 'render.setContactSonar').length,
|
||||
0,
|
||||
'nothing after dispose',
|
||||
);
|
||||
}));
|
||||
@@ -269,6 +269,27 @@
|
||||
* mode the cockpit draws its own cloud pass.
|
||||
* @property {() => { coverage: number | null }} [cloudCover] The cover last
|
||||
* set (a copy).
|
||||
* @property {(options: Partial<ContactSonar>) => void} [setContactSonar]
|
||||
* Optional, with contactSonar (capability `contactSonar`): the Cyber HUD
|
||||
* sonar's treatment of contacts (sprite and point movers): while
|
||||
* `enabled`, each contact's colour moves toward the scanner tint and its
|
||||
* alpha scales by its factor, from its anchor's place on screen against the
|
||||
* sweep (src/cyberSonar.js createCyberSonarSampler maths); with the sweep
|
||||
* off (`active` false) every contact takes the neutral factor. Positions
|
||||
* and picks are unchanged. Omitted fields keep their value; a bad value
|
||||
* throws a TypeError. Takes effect on the next drawn frame (a call outside
|
||||
* a frame requests one). Off until set. Cesium has none: in Cesium mode
|
||||
* src/cyberSonarScene.js adapts Cesium's own draw commands.
|
||||
* @property {() => ContactSonar} [contactSonar] The sonar last set (a copy).
|
||||
*
|
||||
* @typedef {object} ContactSonar
|
||||
* @property {boolean} enabled The Cyber contact treatment applies.
|
||||
* @property {boolean} active The sweep runs (else the neutral factor).
|
||||
* @property {number} angleDeg The sweep's heading, clockwise from screen up.
|
||||
* @property {number} range The keyhole radius in percent (100: the smaller
|
||||
* of 40 % of the width and 52 % of the height).
|
||||
* @property {number} opacity The factor outside the sweep, percent (0..100).
|
||||
* @property {number} sector The sector's width in degrees (0..360].
|
||||
*
|
||||
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
|
||||
*
|
||||
@@ -695,6 +716,10 @@ const OPTIONAL_SHAPE = {
|
||||
'render.setCloudCover': isFunction,
|
||||
'render.cloudCover': isFunction,
|
||||
},
|
||||
contactSonar: {
|
||||
'render.setContactSonar': isFunction,
|
||||
'render.contactSonar': isFunction,
|
||||
},
|
||||
};
|
||||
|
||||
/** The optional capability flags, e.g. 'visualStyles'. */
|
||||
@@ -876,3 +901,43 @@ export function mergeCloudCover(current, { coverage } = {}) {
|
||||
);
|
||||
return { coverage: coverage === undefined ? current.coverage : coverage };
|
||||
}
|
||||
|
||||
/** The contact sonar an engine starts with (GlobeRender.contactSonar): off. */
|
||||
export const CONTACT_SONAR_INITIAL = Object.freeze({
|
||||
enabled: false,
|
||||
active: false,
|
||||
angleDeg: 0,
|
||||
range: 100,
|
||||
opacity: 84,
|
||||
sector: 24,
|
||||
});
|
||||
|
||||
/**
|
||||
* GlobeRender.setContactSonar's argument merged onto `current`: a new
|
||||
* state, or a TypeError for a non-boolean flag, a non-finite angle, a range
|
||||
* that is not positive, an opacity outside 0..100 or a sector outside
|
||||
* (0, 360].
|
||||
*/
|
||||
export function mergeContactSonar(current, options = {}) {
|
||||
const next = { ...current };
|
||||
for (const key of ['enabled', 'active']) {
|
||||
if (options[key] === undefined) continue;
|
||||
if (!isBoolean(options[key]))
|
||||
throw new TypeError(`Sonar ${key} must be a boolean: ${options[key]}`);
|
||||
next[key] = options[key];
|
||||
}
|
||||
const numbers = {
|
||||
angleDeg: () => true,
|
||||
range: (value) => value > 0,
|
||||
opacity: (value) => value >= 0 && value <= 100,
|
||||
sector: (value) => value > 0 && value <= 360,
|
||||
};
|
||||
for (const [key, valid] of Object.entries(numbers)) {
|
||||
const value = options[key];
|
||||
if (value === undefined) continue;
|
||||
if (!isNumber(value) || !valid(value))
|
||||
throw new TypeError(`Sonar ${key} is out of range: ${value}`);
|
||||
next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MODIFIERS,
|
||||
BLOOM_INITIAL,
|
||||
CLOUD_COVER_INITIAL,
|
||||
CONTACT_SONAR_INITIAL,
|
||||
ENGINE_CAPABILITIES,
|
||||
OPTIONAL_ENGINE_MEMBERS,
|
||||
SHARPEN_INITIAL,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
isEngineEvent,
|
||||
mergeBloom,
|
||||
mergeCloudCover,
|
||||
mergeContactSonar,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from './contract.js';
|
||||
@@ -502,3 +504,34 @@ test('the cloudCover capability: whole or absent, null for the engine own', asyn
|
||||
assert.equal(checks.length, 1);
|
||||
for (const check of checks) await t.test(check.name, () => check.run());
|
||||
});
|
||||
|
||||
test('the contactSonar capability: whole or absent, merged and checked', async (t) => {
|
||||
assert.deepEqual(ENGINE_CAPABILITIES, [
|
||||
'visualStyles',
|
||||
'bloom',
|
||||
'cloudCover',
|
||||
'contactSonar',
|
||||
]);
|
||||
const engine = createFakeEngine({ contactSonar: true });
|
||||
assert.equal(engineCapabilities(engine).contactSonar, true);
|
||||
assert.deepEqual(engine.render.contactSonar(), CONTACT_SONAR_INITIAL);
|
||||
const half = {
|
||||
...engine,
|
||||
render: { ...engine.render, contactSonar: undefined },
|
||||
};
|
||||
assert.throws(() => assertGlobeEngine(half), /render\.contactSonar\b/);
|
||||
assert.deepEqual(
|
||||
mergeContactSonar(CONTACT_SONAR_INITIAL, { enabled: true, angleDeg: 400 }),
|
||||
{ ...CONTACT_SONAR_INITIAL, enabled: true, angleDeg: 400 },
|
||||
);
|
||||
for (const bad of [{ opacity: -1 }, { range: -5 }, { active: 'no' }])
|
||||
assert.throws(
|
||||
() => mergeContactSonar(CONTACT_SONAR_INITIAL, bad),
|
||||
TypeError,
|
||||
);
|
||||
const checks = createContractSuite(engine, {
|
||||
waitFrames: async (count) => engine.fake.frames(count),
|
||||
}).filter(({ name }) => /\(contactSonar\)/.test(name));
|
||||
assert.equal(checks.length, 1);
|
||||
for (const check of checks) await t.test(check.name, () => check.run());
|
||||
});
|
||||
@@ -10,11 +10,13 @@ import * as geo from '../../geo/index.js';
|
||||
import {
|
||||
BLOOM_INITIAL,
|
||||
CLOUD_COVER_INITIAL,
|
||||
CONTACT_SONAR_INITIAL,
|
||||
SHARPEN_INITIAL,
|
||||
STYLE_INITIAL,
|
||||
createEngineEvent,
|
||||
mergeBloom,
|
||||
mergeCloudCover,
|
||||
mergeContactSonar,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from '../contract.js';
|
||||
@@ -96,6 +98,7 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
||||
* sharpen?: { set: (state: { enabled: boolean, amount: number }) => void, destroy: () => void },
|
||||
* bloom?: { set: (state: { enabled: boolean, strength: number }) => void, destroy: () => void },
|
||||
* clouds?: { set: (state: { coverage: number | null }) => void, destroy: () => void },
|
||||
* sonar?: { set: (state: import('../contract.js').ContactSonar) => void, destroy: () => void },
|
||||
* styles?: { names: readonly string[], uniformNames: (name: string) => string[], defaults: (name: string) => Record<string, number>, set: (name: string, state: { intensity: number, uniforms: Record<string, number> }) => void, destroy: () => void },
|
||||
* }} options `hooks` are internals.js; `container` defaults to the
|
||||
* canvas's parent; `sharpen` draws render.setSharpen (sharpen.js; without
|
||||
@@ -105,7 +108,9 @@ function rayRaisedEllipsoid({ origin, direction }, height) {
|
||||
* draws render.setBloom (bloom.js; without it no bloom capability), also
|
||||
* destroyed with the engine; `clouds` draws render.setCloudCover
|
||||
* (cockpitClouds.js; without it, as on a look without clouds, no
|
||||
* cloudCover capability), destroyed with the engine;
|
||||
* cloudCover capability), destroyed with the engine; `sonar` draws
|
||||
* render.setContactSonar (sonar.js; without it no contactSonar
|
||||
* capability), destroyed with the engine;
|
||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
@@ -127,6 +132,7 @@ export function createNavaraEngine(
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
clouds: cloudsPort = null,
|
||||
sonar: sonarPort = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!view?.camera?.raw)
|
||||
@@ -185,12 +191,16 @@ export function createNavaraEngine(
|
||||
});
|
||||
// Defined below, with the surface and input it needs.
|
||||
let groundFloor = null;
|
||||
// Between the view's preRender and postRender: a change made then shows
|
||||
// in the frame being drawn and asks for no other.
|
||||
let drawing = false;
|
||||
const handlers = {
|
||||
preUpdate: (time) => {
|
||||
engineClock.beginFrame();
|
||||
frames.preUpdate.raiseEvent(time);
|
||||
},
|
||||
preRender: (time) => {
|
||||
drawing = true;
|
||||
model.sync();
|
||||
// After the user's camera is read, before anything draws.
|
||||
followStep.raiseEvent();
|
||||
@@ -198,6 +208,7 @@ export function createNavaraEngine(
|
||||
frames.preRender.raiseEvent(time);
|
||||
},
|
||||
postRender: (time) => {
|
||||
drawing = false;
|
||||
model.afterFrame();
|
||||
frames.postRender.raiseEvent(time);
|
||||
},
|
||||
@@ -351,6 +362,22 @@ export function createNavaraEngine(
|
||||
cloudCover: () => ({ ...cloudCover }),
|
||||
}
|
||||
: {};
|
||||
let contactSonar = { ...CONTACT_SONAR_INITIAL };
|
||||
const sonarRender = sonarPort
|
||||
? {
|
||||
setContactSonar(options) {
|
||||
const next = mergeContactSonar(contactSonar, options);
|
||||
const changed = Object.keys(next).some(
|
||||
(key) => next[key] !== contactSonar[key],
|
||||
);
|
||||
contactSonar = next;
|
||||
if (!changed || destroyed) return;
|
||||
sonarPort.set({ ...next });
|
||||
if (!drawing) view.forceUpdate();
|
||||
},
|
||||
contactSonar: () => ({ ...contactSonar }),
|
||||
}
|
||||
: {};
|
||||
return {
|
||||
kind: 'navara',
|
||||
host: { container: container ?? canvas.parentElement, canvas, size },
|
||||
@@ -382,6 +409,7 @@ export function createNavaraEngine(
|
||||
...styleRender,
|
||||
...bloomRender,
|
||||
...cloudRender,
|
||||
...sonarRender,
|
||||
},
|
||||
surface,
|
||||
input: input.input,
|
||||
@@ -405,6 +433,7 @@ export function createNavaraEngine(
|
||||
stylesPort?.destroy();
|
||||
bloomPort?.destroy();
|
||||
cloudsPort?.destroy();
|
||||
sonarPort?.destroy();
|
||||
moversPort?.destroy();
|
||||
linesPort?.destroy();
|
||||
},
|
||||
|
||||
@@ -314,11 +314,46 @@ test('setCloudCover hands changed covers to the clouds port and draws a frame',
|
||||
assert.equal(destroyed, 1);
|
||||
});
|
||||
|
||||
test('setContactSonar hands changes to the sonar port, asking a frame only outside one', () => {
|
||||
const view = fakeView();
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
hooks,
|
||||
sonar: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
assertGlobeEngine(engine);
|
||||
assert.equal(engineCapabilities(engine).contactSonar, true);
|
||||
assert.equal(engine.render.contactSonar().enabled, false);
|
||||
engine.render.setContactSonar({ enabled: true, active: true });
|
||||
assert.deepEqual(view.calls.at(-1), ['forceUpdate']);
|
||||
assert.equal(shown.length, 1);
|
||||
// Inside a drawn frame the change shows in that frame: no other frame.
|
||||
engine.frames.preRender.addEventListener(() =>
|
||||
engine.render.setContactSonar({ angleDeg: 45 }),
|
||||
);
|
||||
const calls = view.calls.length;
|
||||
view.emit('preRender', 1);
|
||||
view.emit('postRender', 2);
|
||||
assert.equal(shown.at(-1).angleDeg, 45);
|
||||
assert.equal(
|
||||
view.calls.slice(calls).filter(([name]) => name === 'forceUpdate').length,
|
||||
0,
|
||||
);
|
||||
assert.throws(
|
||||
() => engine.render.setContactSonar({ opacity: 120 }),
|
||||
TypeError,
|
||||
);
|
||||
engine.destroy();
|
||||
assert.equal(destroyed, 1);
|
||||
});
|
||||
|
||||
test('without a styles port the engine has no visualStyles capability', () => {
|
||||
const { engine } = setup();
|
||||
assert.equal(engineCapabilities(engine).visualStyles, false);
|
||||
assert.equal(engineCapabilities(engine).bloom, false);
|
||||
assert.equal(engineCapabilities(engine).cloudCover, false);
|
||||
assert.equal(engineCapabilities(engine).contactSonar, false);
|
||||
assert.equal(engine.render.setStyle, undefined);
|
||||
assert.equal(engine.render.setBloom, undefined);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createNavaraSharpen } from './sharpen.js';
|
||||
import { createNavaraStyles } from './stylePasses.js';
|
||||
import { createNavaraBloom } from './bloom.js';
|
||||
import { createNavaraCloudCover } from './cockpitClouds.js';
|
||||
import { createNavaraSonar } from './sonar.js';
|
||||
import { retroShader } from '../../styles/retro.js';
|
||||
import { nightVisionShader } from '../../styles/surveillance.js';
|
||||
import { thermalShader } from '../../styles/thermal.js';
|
||||
@@ -161,6 +162,7 @@ export async function createNavaraGlobe({
|
||||
sharpen: createNavaraSharpen(view),
|
||||
bloom: createNavaraBloom(view),
|
||||
clouds: createNavaraCloudCover(look),
|
||||
sonar: createNavaraSonar(view),
|
||||
styles: createNavaraStyles(view, {
|
||||
shaders: STYLE_SHADERS,
|
||||
onStarsDimmed: (amount) => look.setStarsDimmed(amount),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
OVERLAY_DEPTH_MODES,
|
||||
OVERLAY_DEPTH_VERTEX,
|
||||
} from '../overlayDepth.js';
|
||||
import { DETACHED_SONAR, SONAR_VERTEX } from '../sonar.js';
|
||||
|
||||
const VERTEX_HEAD = /* glsl */ `
|
||||
attribute vec4 gevStyle;
|
||||
@@ -48,7 +49,10 @@ varying vec2 vGevLocal;
|
||||
varying float vGevOutline;
|
||||
varying float vGevPixelDistance;
|
||||
varying float vGevTranslucency;
|
||||
varying float vGevSonarFactor;
|
||||
uniform vec2 uGevResolution;
|
||||
${OVERLAY_DEPTH_VERTEX}
|
||||
${SONAR_VERTEX}
|
||||
`;
|
||||
|
||||
const VERTEX_PROJECT = /* glsl */ `
|
||||
@@ -96,7 +100,10 @@ vec2 gevTurned = vec2(
|
||||
vec4 mvPosition = mvCenter;
|
||||
mvPosition.xy += gevTurned * gevSizePx * uGevFootprint * max(-mvCenter.z, 0.0);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
vGevColor = gevColor;
|
||||
// The Cyber sonar (sonar.js): by the anchor's place on screen, never the
|
||||
// corner's, so a contact is treated whole; the position stays as it is.
|
||||
vGevSonarFactor = gevSonarFactor(projectionMatrix * mvCenter, uGevResolution);
|
||||
vGevColor = gevSonarColor(gevColor, vGevSonarFactor);
|
||||
vGevLocal = gevCorner;
|
||||
#ifndef GEV_ALWAYS_VISIBLE
|
||||
vGevDepthFree = gevDistance < uGevDisableWithinM ? 1.0 : 0.0;
|
||||
@@ -117,6 +124,7 @@ varying vec2 vGevLocal;
|
||||
varying float vGevOutline;
|
||||
varying float vGevPixelDistance;
|
||||
varying float vGevTranslucency;
|
||||
varying float vGevSonarFactor;
|
||||
${OVERLAY_DEPTH_FRAGMENT}
|
||||
`;
|
||||
|
||||
@@ -135,7 +143,8 @@ float gevDistanceToCenter = gevRadius * 0.5;
|
||||
float gevWholeAlpha = 1.0 - smoothstep(gevMaxDistance, 0.5, gevDistanceToCenter);
|
||||
float gevInner = 1.0 - vGevOutline;
|
||||
float gevInnerAlpha = 1.0 - smoothstep(gevMaxDistance * gevInner, 0.5 * gevInner, gevDistanceToCenter);
|
||||
diffuseColor *= sRGBTransferEOTF(mix(uGevOutlineColor, vGevColor, gevInnerAlpha));
|
||||
vec4 gevOutline = vec4(uGevOutlineColor.rgb, uGevOutlineColor.a * vGevSonarFactor);
|
||||
diffuseColor *= sRGBTransferEOTF(mix(gevOutline, vGevColor, gevInnerAlpha));
|
||||
diffuseColor.a *= gevWholeAlpha * vGevTranslucency;
|
||||
if (diffuseColor.a < 0.005) discard;
|
||||
`
|
||||
@@ -290,6 +299,12 @@ export function createMoverMesh(
|
||||
uGevResolution: overlayUniforms.uGevResolution,
|
||||
uGevDisplayBlend:
|
||||
overlayUniforms.uGevDisplayBlend ?? DETACHED_OVERLAY.uGevDisplayBlend,
|
||||
// Shared by every overlay of the view: the Cyber sonar (sonar.js).
|
||||
uGevSonar: overlayUniforms.uGevSonar ?? DETACHED_SONAR.uGevSonar,
|
||||
uGevSonarSector:
|
||||
overlayUniforms.uGevSonarSector ?? DETACHED_SONAR.uGevSonarSector,
|
||||
uGevSonarEnabled:
|
||||
overlayUniforms.uGevSonarEnabled ?? DETACHED_SONAR.uGevSonarEnabled,
|
||||
};
|
||||
// A canvas atlas is there at once; a URL one once its image loads.
|
||||
let textureReady = point || Boolean(options.sprite.atlas.canvas);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { withFrameUpdates } from './frameDescriptor.js';
|
||||
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
|
||||
import { GevOverlayPass } from './overlayPass.js';
|
||||
import { sonarUniformsFor } from './sonar.js';
|
||||
import { RGBADepthPacking, Scene } from 'three';
|
||||
|
||||
export const OVERLAY_EFFECT_KEY = 'gevOverlay';
|
||||
@@ -110,6 +111,8 @@ export function createNavaraOverlay(view, { beforeSharpen = false } = {}) {
|
||||
uGevDisplayBlend: { value: 0 },
|
||||
tGevSceneDepth: { value: null },
|
||||
uGevResolution: { value: [0, 0] },
|
||||
// The Cyber sonar's, shared by the view's overlays (sonar.js).
|
||||
...sonarUniformsFor(view),
|
||||
},
|
||||
attach(context) {
|
||||
ctx = context;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// The Cyber HUD's contact sonar on Navara (render.setContactSonar): the
|
||||
// sweep's treatment of contacts, drawn by the sprite and point movers'
|
||||
// shaders (movers/moverMesh.js) from uniforms every overlay of a view
|
||||
// shares. The rings and the sweep itself are the HUD's own (CSS,
|
||||
// src/cyberSonar.js); this is the contact side, the counterpart of the
|
||||
// Cesium command adapter (src/cyberSonarGpu.js), with the same maths: each
|
||||
// contact's factor from its anchor's place on screen against the sweep (the
|
||||
// sampler of src/cyberSonar.js createCyberSonarSampler), its colour pulled
|
||||
// toward the scanner tint by that factor and its alpha scaled by it.
|
||||
// Positions, picks and the instance data stay as the layers set them.
|
||||
// Models keep their colours (Cesium tints them on the CPU).
|
||||
|
||||
const views = new WeakMap();
|
||||
|
||||
/**
|
||||
* The sonar uniforms of a view, one object for all its overlays (overlay.js
|
||||
* spreads them into each overlay's uniforms):
|
||||
* - uGevSonar: angle (deg, clockwise from screen up), range (fraction of
|
||||
* the keyhole radius min(0.4 width, 0.52 height)), opacity floor (0..1),
|
||||
* sweep on (1) or off (0);
|
||||
* - uGevSonarSector: the sector's width (deg);
|
||||
* - uGevSonarEnabled: 1 while the Cyber contact treatment applies.
|
||||
*/
|
||||
export function sonarUniformsFor(view) {
|
||||
let uniforms = views.get(view);
|
||||
if (!uniforms) {
|
||||
uniforms = {
|
||||
uGevSonar: { value: [0, 1, 0.84, 0] },
|
||||
uGevSonarSector: { value: 24 },
|
||||
uGevSonarEnabled: { value: 0 },
|
||||
};
|
||||
views.set(view, uniforms);
|
||||
}
|
||||
return uniforms;
|
||||
}
|
||||
|
||||
/** Sonar uniforms of a mesh drawn outside an overlay (tests): off. */
|
||||
export const DETACHED_SONAR = Object.freeze({
|
||||
uGevSonar: { value: [0, 1, 0.84, 0] },
|
||||
uGevSonarSector: { value: 24 },
|
||||
uGevSonarEnabled: { value: 0 },
|
||||
});
|
||||
|
||||
/**
|
||||
* Vertex declarations and gevSonarFactor(anchor, resolution): the factor of
|
||||
* a contact whose anchor is at clip position `anchor`, in a target of
|
||||
* `resolution` pixels; 1 when the treatment is off.
|
||||
*/
|
||||
export const SONAR_VERTEX = /* glsl */ `
|
||||
uniform vec4 uGevSonar;
|
||||
uniform float uGevSonarSector;
|
||||
uniform float uGevSonarEnabled;
|
||||
float gevSonarFactor(vec4 anchor, vec2 resolution) {
|
||||
if (uGevSonarEnabled < 0.5) return 1.0;
|
||||
float floorValue = uGevSonar.z;
|
||||
if (uGevSonar.w < 0.5) return floorValue + (1.0 - floorValue) * 0.46;
|
||||
// Invalid projections fail open, as the CPU sampler does.
|
||||
if (anchor.w <= 0.0 || resolution.x < 1.0 || resolution.y < 1.0) return 1.0;
|
||||
vec2 pixel = anchor.xy / anchor.w * resolution * 0.5;
|
||||
float radius = min(resolution.x * 0.4, resolution.y * 0.52) * uGevSonar.y;
|
||||
float distanceSquared = dot(pixel, pixel);
|
||||
float radiusSquared = radius * radius;
|
||||
if (distanceSquared > radiusSquared) return floorValue;
|
||||
if (distanceSquared < radiusSquared * 0.000625) return 1.0;
|
||||
float delta = mod(degrees(atan(pixel.x, pixel.y)) - uGevSonar.x + 360.0, 360.0);
|
||||
if (delta > 359.9999) delta = 0.0;
|
||||
if (delta > uGevSonarSector) return floorValue;
|
||||
float feather = min(10.0, uGevSonarSector * 0.42);
|
||||
return floorValue + (1.0 - floorValue) * min(1.0, (uGevSonarSector - delta) / feather);
|
||||
}
|
||||
vec4 gevSonarColor(vec4 color, float factor) {
|
||||
if (uGevSonarEnabled < 0.5) return color;
|
||||
float floorValue = uGevSonar.z;
|
||||
float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0);
|
||||
vec3 tint = color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
|
||||
return vec4(tint, color.a * factor);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* gevSonarFactor in JS, line for line (tests hold it to the CPU sampler of
|
||||
* src/cyberSonar.js): `x`, `y` CSS pixels from the top-left of a
|
||||
* `width` × `height` view; `state` as render.setContactSonar takes it.
|
||||
*/
|
||||
export function sonarFactorAt(x, y, width, height, state) {
|
||||
if (!state.enabled) return 1;
|
||||
const floor = state.opacity / 100;
|
||||
if (!state.active) return floor + (1 - floor) * 0.46;
|
||||
if (![x, y].every(Number.isFinite) || width < 1 || height < 1) return 1;
|
||||
// Screen up is +y here, as in clip space.
|
||||
const px = x - width / 2;
|
||||
const py = height / 2 - y;
|
||||
const radius = (Math.min(width * 0.4, height * 0.52) * state.range) / 100;
|
||||
const distanceSquared = px * px + py * py;
|
||||
const radiusSquared = radius * radius;
|
||||
if (distanceSquared > radiusSquared) return floor;
|
||||
if (distanceSquared < radiusSquared * 0.000625) return 1;
|
||||
let delta =
|
||||
((((Math.atan2(px, py) * 180) / Math.PI - state.angleDeg + 360) % 360) +
|
||||
360) %
|
||||
360;
|
||||
if (delta > 359.9999) delta = 0;
|
||||
if (delta > state.sector) return floor;
|
||||
const feather = Math.min(10, state.sector * 0.42);
|
||||
return floor + (1 - floor) * Math.min(1, (state.sector - delta) / feather);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sonar port createNavaraEngine takes: `set(state)` (already checked)
|
||||
* writes the view's sonar uniforms; `destroy()` turns the treatment off.
|
||||
* @param {object} view an initialised ThreeView
|
||||
*/
|
||||
export function createNavaraSonar(view) {
|
||||
const uniforms = sonarUniformsFor(view);
|
||||
return {
|
||||
set({ enabled, active, angleDeg, range, opacity, sector }) {
|
||||
const sonar = uniforms.uGevSonar.value;
|
||||
sonar[0] = angleDeg;
|
||||
sonar[1] = range / 100;
|
||||
sonar[2] = opacity / 100;
|
||||
sonar[3] = active ? 1 : 0;
|
||||
uniforms.uGevSonarSector.value = sector;
|
||||
uniforms.uGevSonarEnabled.value = enabled ? 1 : 0;
|
||||
},
|
||||
destroy() {
|
||||
uniforms.uGevSonarEnabled.value = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createCyberSonarSampler } from '../../cyberSonar.js';
|
||||
import { patchMoverShader } from './movers/moverMesh.js';
|
||||
import {
|
||||
DETACHED_SONAR,
|
||||
SONAR_VERTEX,
|
||||
createNavaraSonar,
|
||||
sonarFactorAt,
|
||||
sonarUniformsFor,
|
||||
} from './sonar.js';
|
||||
|
||||
// A document root carrying the Cyber sonar settings, as the HUD writes them.
|
||||
const rootWith = ({ range, opacity, sector }) => ({
|
||||
dataset: {
|
||||
cyberSonarRange: String(range),
|
||||
cyberSonarOpacity: String(opacity),
|
||||
cyberSonarSector: String(sector),
|
||||
},
|
||||
});
|
||||
|
||||
test('the shader factor is the CPU sampler of cyberSonar.js', () => {
|
||||
const settings = [
|
||||
{ range: 100, opacity: 84, sector: 24 },
|
||||
{ range: 60, opacity: 35, sector: 8 },
|
||||
{ range: 120, opacity: 50, sector: 60 },
|
||||
];
|
||||
for (const { width, height } of [
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 390, height: 844 },
|
||||
])
|
||||
for (const setting of settings)
|
||||
for (const nowMs of [0, 700, 2600, 4100, 5199]) {
|
||||
const sampler = createCyberSonarSampler(
|
||||
width,
|
||||
height,
|
||||
nowMs,
|
||||
rootWith(setting),
|
||||
);
|
||||
const angleDeg = ((((nowMs % 5200) + 5200) % 5200) / 5200) * 360;
|
||||
const state = { enabled: true, active: true, angleDeg, ...setting };
|
||||
for (let y = 0; y <= height; y += height / 16)
|
||||
for (let x = 0; x <= width; x += width / 16) {
|
||||
const expected = sampler.at(x, y);
|
||||
const actual = sonarFactorAt(x, y, width, height, state);
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < 1e-9,
|
||||
`${width}x${height} ${JSON.stringify(setting)} t=${nowMs} (${x}, ${y}): ${actual} vs ${expected}`,
|
||||
);
|
||||
}
|
||||
// The sweep off: the sampler's neutral factor everywhere.
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
sonarFactorAt(10, 10, width, height, { ...state, active: false }) -
|
||||
sampler.neutral,
|
||||
) < 1e-12,
|
||||
);
|
||||
}
|
||||
// Off: contacts unchanged.
|
||||
assert.equal(
|
||||
sonarFactorAt(5, 5, 100, 100, { enabled: false, active: true }),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('the GLSL carries the same rule as its JS twin', () => {
|
||||
for (const term of [
|
||||
'radiusSquared * 0.000625',
|
||||
'min(resolution.x * 0.4, resolution.y * 0.52) * uGevSonar.y',
|
||||
'floorValue + (1.0 - floorValue) * 0.46',
|
||||
'min(10.0, uGevSonarSector * 0.42)',
|
||||
'degrees(atan(pixel.x, pixel.y)) - uGevSonar.x + 360.0',
|
||||
'color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72',
|
||||
'color.a * factor',
|
||||
])
|
||||
assert.ok(SONAR_VERTEX.includes(term), term);
|
||||
});
|
||||
|
||||
test('the mover shaders treat contacts by their anchor and keep their position', () => {
|
||||
for (const point of [false, true]) {
|
||||
const shader = patchMoverShader(
|
||||
{
|
||||
uniforms: {},
|
||||
vertexShader:
|
||||
'void main() {\n#include <uv_vertex>\n#include <project_vertex>\n}',
|
||||
fragmentShader:
|
||||
'void main() {\n#include <color_fragment>\n#include <colorspace_fragment>\n}',
|
||||
},
|
||||
{},
|
||||
{ point },
|
||||
);
|
||||
assert.match(
|
||||
shader.vertexShader,
|
||||
/vGevSonarFactor = gevSonarFactor\(projectionMatrix \* mvCenter, uGevResolution\);/,
|
||||
);
|
||||
assert.match(
|
||||
shader.vertexShader,
|
||||
/vGevColor = gevSonarColor\(gevColor, vGevSonarFactor\);/,
|
||||
);
|
||||
// gl_Position comes from the corner before the sonar runs.
|
||||
assert.ok(
|
||||
shader.vertexShader.indexOf(
|
||||
'gl_Position = projectionMatrix * mvPosition',
|
||||
) < shader.vertexShader.indexOf('gevSonarFactor(projectionMatrix'),
|
||||
);
|
||||
if (point)
|
||||
assert.match(
|
||||
shader.fragmentShader,
|
||||
/uGevOutlineColor\.a \* vGevSonarFactor/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('one set of sonar uniforms per view, written by the port', () => {
|
||||
const view = {};
|
||||
const uniforms = sonarUniformsFor(view);
|
||||
assert.equal(sonarUniformsFor(view), uniforms, 'shared by its overlays');
|
||||
assert.notEqual(sonarUniformsFor({}), uniforms);
|
||||
assert.equal(DETACHED_SONAR.uGevSonarEnabled.value, 0);
|
||||
const port = createNavaraSonar(view);
|
||||
port.set({
|
||||
enabled: true,
|
||||
active: true,
|
||||
angleDeg: 120,
|
||||
range: 80,
|
||||
opacity: 50,
|
||||
sector: 30,
|
||||
});
|
||||
assert.deepEqual(uniforms.uGevSonar.value, [120, 0.8, 0.5, 1]);
|
||||
assert.equal(uniforms.uGevSonarSector.value, 30);
|
||||
assert.equal(uniforms.uGevSonarEnabled.value, 1);
|
||||
port.destroy();
|
||||
assert.equal(uniforms.uGevSonarEnabled.value, 0);
|
||||
});
|
||||
@@ -2774,9 +2774,76 @@ export function createContractSuite(
|
||||
if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle));
|
||||
if (capabilities.cloudCover)
|
||||
checks.push(...cloudCoverChecks(engine.render, settle));
|
||||
if (capabilities.contactSonar)
|
||||
checks.push(...contactSonarChecks(engine.render, settle));
|
||||
return checks.map(([name, run]) => ({ name, run }));
|
||||
}
|
||||
|
||||
// The optional contactSonar capability (render.setContactSonar).
|
||||
function contactSonarChecks(render, settle) {
|
||||
return [
|
||||
[
|
||||
'render.setContactSonar (contactSonar): merges, copies, rejects bad values, draws',
|
||||
async () => {
|
||||
const previous = render.contactSonar();
|
||||
ensure(
|
||||
previous.enabled === false,
|
||||
`the sonar starts ${JSON.stringify(previous)}`,
|
||||
);
|
||||
try {
|
||||
render.setContactSonar({
|
||||
enabled: true,
|
||||
active: true,
|
||||
angleDeg: 90,
|
||||
range: 100,
|
||||
opacity: 40,
|
||||
sector: 30,
|
||||
});
|
||||
await settle(2);
|
||||
render.setContactSonar({ angleDeg: 180 });
|
||||
const state = render.contactSonar();
|
||||
ensure(
|
||||
state.enabled && state.active && state.angleDeg === 180,
|
||||
`after an angle alone: ${JSON.stringify(state)}`,
|
||||
);
|
||||
state.angleDeg = 0;
|
||||
ensure(
|
||||
render.contactSonar().angleDeg === 180,
|
||||
'contactSonar() is a copy',
|
||||
);
|
||||
for (const bad of [
|
||||
{ enabled: 'yes' },
|
||||
{ active: 1 },
|
||||
{ angleDeg: Number.NaN },
|
||||
{ range: 0 },
|
||||
{ opacity: 101 },
|
||||
{ sector: 0 },
|
||||
{ sector: 400 },
|
||||
]) {
|
||||
let error = null;
|
||||
try {
|
||||
render.setContactSonar(bad);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
ensure(
|
||||
error instanceof TypeError,
|
||||
`setContactSonar(${JSON.stringify(bad)}) did not throw a TypeError`,
|
||||
);
|
||||
}
|
||||
ensure(
|
||||
render.contactSonar().opacity === 40,
|
||||
'a rejected call changed the sonar',
|
||||
);
|
||||
} finally {
|
||||
render.setContactSonar(previous);
|
||||
await settle(1);
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// The optional cloudCover capability (render.setCloudCover).
|
||||
function cloudCoverChecks(render, settle) {
|
||||
return [
|
||||
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
normalizeInputOptions,
|
||||
BLOOM_INITIAL,
|
||||
CLOUD_COVER_INITIAL,
|
||||
CONTACT_SONAR_INITIAL,
|
||||
SHARPEN_INITIAL,
|
||||
STYLE_INITIAL,
|
||||
createEngineEvent,
|
||||
mergeBloom,
|
||||
mergeCloudCover,
|
||||
mergeContactSonar,
|
||||
mergeSharpen,
|
||||
mergeStyle,
|
||||
} from '../contract.js';
|
||||
@@ -233,6 +235,19 @@ function rectangleFromCartographics(points) {
|
||||
return { west, south, east, north };
|
||||
}
|
||||
|
||||
// render.setContactSonar/contactSonar when asked; nothing otherwise.
|
||||
function fakeContactSonar(enabled, record) {
|
||||
if (!enabled) return {};
|
||||
let sonar = { ...CONTACT_SONAR_INITIAL };
|
||||
return {
|
||||
setContactSonar(options) {
|
||||
record('render.setContactSonar', [options]);
|
||||
sonar = mergeContactSonar(sonar, options);
|
||||
},
|
||||
contactSonar: () => ({ ...sonar }),
|
||||
};
|
||||
}
|
||||
|
||||
// render.setCloudCover/cloudCover when asked; nothing otherwise.
|
||||
function fakeCloudCover(enabled, record) {
|
||||
if (!enabled) return {};
|
||||
@@ -295,7 +310,7 @@ function fakeStyles(visualStyles, record) {
|
||||
* container?: object, canvas?: object, nowMs?: number,
|
||||
* appClock?: import('../../time/clock.js').AppClock,
|
||||
* visualStyles?: Record<string, string[]>, bloom?: boolean,
|
||||
* cloudCover?: boolean }} [options]
|
||||
* cloudCover?: boolean, contactSonar?: boolean }} [options]
|
||||
* Canvas size in CSS px, angles in radians; `container`/`canvas` stand in
|
||||
* for the host elements (DOM mocks for shell tests). The initial pose looks straight
|
||||
* down from `altitude` metres (10 000 km). The engine clock reads
|
||||
@@ -305,7 +320,8 @@ function fakeStyles(visualStyles, record) {
|
||||
* `visualStyles` ({ style: its uniform names }) adds the optional
|
||||
* visualStyles capability (render.setStyle), each uniform starting at 0;
|
||||
* `bloom: true` the optional bloom capability (render.setBloom);
|
||||
* `cloudCover: true` the optional cloudCover one (render.setCloudCover).
|
||||
* `cloudCover: true` the optional cloudCover one (render.setCloudCover);
|
||||
* `contactSonar: true` the contactSonar one (render.setContactSonar).
|
||||
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
||||
*/
|
||||
export function createFakeEngine(options = {}) {
|
||||
@@ -994,6 +1010,7 @@ export function createFakeEngine(options = {}) {
|
||||
...fakeStyles(options.visualStyles, record),
|
||||
...fakeBloom(options.bloom, record),
|
||||
...fakeCloudCover(options.cloudCover, record),
|
||||
...fakeContactSonar(options.contactSonar, record),
|
||||
},
|
||||
surface: {
|
||||
sampleHeight(position, exclude) {
|
||||
|
||||
Reference in new issue
Block a user