Merge branch 'german/refactor-navara-tiles-drape-2' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 21:36:39 +04:00
commit 4bbf9949d5
2 files changed
+382 -7

No files matched your search

+156 -7
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as geo from '../src/geo/index.js';
import { GROUND_SURFACE_GLSL } from '../src/globe/navara/ground/surface.js';
import {
assertKeylessPreview,
createStrict,
@@ -237,13 +238,22 @@ await strict.run({}, async (browser) => {
// Map imagery and classification shaders may still be arriving before ready.
await page.evaluate(() => window.qaDrape.show(false));
await loaded(page);
const baseline = await pixels(page, position);
const before = await pixels(page, position);
await page.evaluate(() => window.qaDrape.show(true));
const rgb = await pixels(page, position);
const shown = await pixels(page, position);
await page.evaluate(() => window.qaDrape.show(false));
const restored = await pixels(page, position);
const after = await pixels(page, position);
const baseline = before.rgb;
const rgb = shown.rgb;
const restored = after.rgb;
const drawn = rgb[0] > 180 && rgb[1] < 80 && rgb[2] > 180;
const sample = { surface, drape, baseline, rgb, restored, drawn };
if (report && kind === 'navara')
sample.depths = {
baseline: before.depths,
shown: shown.depths,
restored: after.depths,
};
drapes.push(sample);
console.log(
`${report ? 'REPORT' : 'DRAPE'} ${kind} ${JSON.stringify(sample)}`,
@@ -320,13 +330,24 @@ async function loaded(page) {
// Read actual canvas pixels during postRender; Cesium discards them afterwards.
async function pixels(page, position) {
return page.evaluate(async (position) => {
const input = {
position,
diagnostic: report ? readNavaraDrape.toString() : null,
classification: GROUND_SURFACE_GLSL,
};
const sample = await page.evaluate(async (input) => {
const { position, diagnostic, classification } = input;
const { engine } = window.__godsEyeView;
const readDepths =
diagnostic && engine.kind === 'navara'
? new Function(`return (${diagnostic})`)()
: null;
let depths;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 5;
const context = canvas.getContext('2d', { willReadFrequently: true });
for (let frame = 0; frame < 2; frame++)
await new Promise((resolve) => {
await new Promise((resolve, reject) => {
const off = engine.frames.postRender.addEventListener(() => {
off();
const point = engine.projector.worldToWindow(position);
@@ -343,19 +364,147 @@ async function pixels(page, position) {
5,
5,
);
try {
if (readDepths)
depths = readDepths(engine, point, classification);
} catch (error) {
reject(error);
return;
}
resolve();
});
engine.render.request('qa-drape-pixels');
});
const data = context.getImageData(0, 0, 5, 5).data;
return [0, 1, 2].map(
const rgb = [0, 1, 2].map(
(channel) =>
data.reduce(
(sum, value, i) => sum + (i % 4 === channel ? value : 0),
0,
) / 25,
);
}, position);
return { rgb, depths };
}, input);
if (sample.depths) {
const place =
sample.depths.position &&
geo.cartographic.fromCartesian(sample.depths.position);
const detail =
place &&
(await page.evaluate(
(place) =>
window.__godsEyeView.engine.surface.sampleHeightDetailed(place),
place,
));
sample.depths.source = detail?.source ?? null;
sample.depths.height = detail?.height ?? null;
}
return sample;
}
// Runs in the page, after rendering. Read the centre of the same 5x5 colour
// sample, using the ground effect's actual uniforms and shader when shown.
function readNavaraDrape(engine, point, classification) {
const view = engine.extensions.navara.view;
const ctx = view.viewContext;
const renderer = ctx.getRenderer();
const mrt = ctx.findEffect('mrt');
const effect = ctx.findEffect('gevGround')?.effect;
const names = ['tAllDepth', 'tSurfaceDepth', 'tGlobeDepth'];
const textures = effect
? names.map((name) => effect.uniforms.get(name).value)
: [
mrt.depthBuffer,
ctx.getRenderTarget().depthTexture,
mrt.globeDepthBuffer,
];
if (effect)
classification = effect
.getFragmentShader()
.match(/float groundSurfaceType\([\s\S]*?\n}/)?.[0];
if (!classification) throw new Error('Missing ground classification shader');
const scale = engine.host.canvas.width / engine.host.size().width;
const canvasPixel = [
Math.round(point.x * scale),
Math.round(point.y * scale),
];
const size = textures[0].image;
const pixel = [
Math.floor(
((canvasPixel[0] + 0.5) * size.width) / engine.host.canvas.width,
),
Math.floor(
((engine.host.canvas.height - canvasPixel[1] - 0.5) * size.height) /
engine.host.canvas.height,
),
];
// Use the installed SDK's depth-copy pass and Three packing. Four RGBA8
// pixels preserve the three depths and the shader's classification result.
const copy = new mrt.raw.globeDepthCopyPass.constructor();
const previous = renderer.getRenderTarget();
try {
copy.setSize(4, 1);
const material = copy.fullscreenMaterial;
material.uniforms = Object.fromEntries(
names.map((name, i) => [name, { value: textures[i] }]),
);
material.uniforms.pixel = { value: pixel };
material.fragmentShader = `
#include <packing>
uniform sampler2D tAllDepth;
uniform sampler2D tSurfaceDepth;
uniform sampler2D tGlobeDepth;
uniform ivec2 pixel;
${classification}
void main() {
float depth = unpackRGBAToDepth(texelFetch(tAllDepth, pixel, 0));
float surface = texelFetch(tSurfaceDepth, pixel, 0).r;
float globe = unpackRGBAToDepth(texelFetch(tGlobeDepth, pixel, 0));
int column = int(gl_FragCoord.x);
gl_FragColor = column == 3
? vec4(groundSurfaceType(depth, surface, globe) / 255.0, 0.0, 0.0, 1.0)
: packDepthToRGBA(column == 0 ? depth : column == 1 ? surface : globe);
}
`;
copy.render(renderer);
const bytes = new Uint8Array(16);
renderer.readRenderTargetPixels(copy.renderTarget, 0, 0, 4, 1, bytes);
const gl = renderer.getContext();
const error = gl.getError();
if (error !== gl.NO_ERROR)
throw new Error(`Depth diagnostic WebGL error: ${error}`);
const unpack = (offset) =>
bytes[offset] / 256 +
bytes[offset + 1] / 65536 +
bytes[offset + 2] / 16777216 +
bytes[offset + 3] / (255 * 16777216);
const screen = {
x: (canvasPixel[0] + 0.5) / scale,
y: (canvasPixel[1] + 0.5) / scale,
};
const ray = engine.camera.pickRay(screen);
const hit = engine.surface.raycast({ ...ray, far: view.camera.raw.far });
return {
pixel,
bindings: effect ? 'ground' : 'mrt',
allDepth: unpack(0),
sceneDepth: unpack(4),
globeDepth: unpack(8),
classification: ['none', 'terrain', 'tiles'][bytes[12]],
buffer: ['none', 'globe', 'scene'][bytes[12]],
position: hit?.position ?? engine.camera.pickEllipsoid(screen),
shownTileAtRay: Boolean(hit),
photorealActive: engine.maps.photorealActive(),
textures: textures.map((texture) => ({
id: texture.id,
width: texture.image.width,
height: texture.image.height,
})),
};
} finally {
renderer.setRenderTarget(previous);
copy.dispose();
}
}
async function checkPoints(page, kind) {
+226
View File
@@ -5,6 +5,7 @@ import { Cesium3DTileset } from 'cesium';
import { Core, initSync } from '@navaramap/engine';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Group, Raycaster, Vector3 } from 'three';
import { DepthCopyPass } from 'postprocessing';
import * as geo from '../src/geo/index.js';
import { buildTilesFixture } from './build-tiles3d-fixture.mjs';
import { localTilesetPath, qaTilesetUrl } from '../src/maps/qaTileset.js';
@@ -13,6 +14,7 @@ import { MapSourceController } from '../src/maps/controller.js';
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
import { createNavaraMaps } from '../src/globe/navara/maps.js';
import { createNavaraSurface } from '../src/globe/navara/surface.js';
import { GROUND_SURFACE_GLSL } from '../src/globe/navara/ground/surface.js';
import {
assertKeylessPreview,
TILES_FIXTURE_PATH,
@@ -374,3 +376,227 @@ test('harness exposes exactly the five binary-safe fixture routes on the preview
);
}
});
// Load only the page callbacks, without launching the command-line scenario.
const scenario = readFileSync(
new URL('./qa-tiles-fixture.mjs', import.meta.url),
'utf8',
);
const diagnosticSource = scenario.slice(
scenario.indexOf('function readNavaraDrape('),
scenario.indexOf('async function checkPoints('),
);
const readNavaraDrape = new Function(`return (${diagnosticSource})`)();
const pixelsSource = scenario.slice(
scenario.indexOf('async function pixels('),
scenario.indexOf('// Runs in the page, after rendering.'),
);
const reportPixels = new Function(
'geo',
'report',
'readNavaraDrape',
'GROUND_SURFACE_GLSL',
`return (${pixelsSource})`,
)(geo, true, readNavaraDrape, GROUND_SURFACE_GLSL);
function depthDiagnostic(t) {
const copies = [];
class Copy extends DepthCopyPass {
constructor() {
super();
this.disposed = false;
this.renderTarget.addEventListener('dispose', () => {
this.disposed = true;
});
copies.push(this);
}
}
const bytes = new Uint8Array(16);
// Exact RGBA depths: 0.25 composed, 0.25 scene, 0.5 globe; tile classification.
bytes.set([64, 0, 0, 0, 64, 0, 0, 0, 128, 0, 0, 0, 2, 0, 0, 255]);
let error = 0;
const gl = { NO_ERROR: 0, getError: () => error };
const textures = Array.from({ length: 3 }, (_, id) => ({
id,
image: { width: 1280, height: 800 },
}));
const previous = {};
const renderer = {
getContext: () => gl,
getRenderTarget: () => previous,
render: t.mock.fn(),
readRenderTargetPixels: (_, x, y, width, height, buffer) =>
buffer.set(bytes),
setRenderTarget: t.mock.fn(),
};
let effect = {
uniforms: new Map(
['tAllDepth', 'tSurfaceDepth', 'tGlobeDepth'].map((name, i) => [
name,
{ value: textures[i] },
]),
),
getFragmentShader: () => GROUND_SURFACE_GLSL,
};
const ctx = {
getRenderer: () => renderer,
findEffect: (key) =>
key === 'gevGround'
? { effect }
: {
depthBuffer: textures[0],
globeDepthBuffer: textures[2],
raw: { globeDepthCopyPass: { constructor: Copy } },
},
getRenderTarget: () => ({ depthTexture: textures[1] }),
};
let tiles = true;
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 50);
let listener;
const engine = {
kind: 'navara',
extensions: {
navara: { view: { viewContext: ctx, camera: { raw: { far: 1e8 } } } },
},
host: {
canvas: { width: 2560, height: 1600 },
size: () => ({ width: 1280, height: 800 }),
},
projector: { worldToWindow: () => ({ x: 120, y: 70 }) },
camera: {
pickRay: t.mock.fn(() => ({
origin: { x: 1, y: 2, z: 3 },
direction: { x: 0, y: 0, z: -1 },
})),
pickEllipsoid: () => position,
},
surface: {
raycast: t.mock.fn(() => (tiles ? { position } : undefined)),
sampleHeightDetailed: t.mock.fn(() => ({
height: tiles ? 50 : 0,
source: tiles ? 'tiles' : 'terrain',
})),
},
maps: { photorealActive: () => tiles },
frames: {
postRender: {
addEventListener: (fn) => {
listener = fn;
return () => {};
},
},
},
render: { request: () => queueMicrotask(() => listener()) },
};
const color = Uint8ClampedArray.from(
{ length: 100 },
(_, i) => [231, 226, 223, 255][i % 4],
);
globals(t, {
window: { __godsEyeView: { engine } },
document: {
createElement: () => ({
getContext: () => ({
clearRect() {},
drawImage() {},
getImageData: () => ({ data: color }),
}),
}),
},
});
return {
engine,
copies,
renderer,
previous,
position,
textures,
page: { evaluate: async (fn, ...args) => fn(...args) },
hideGround() {
effect = null;
},
removeTiles() {
tiles = false;
bytes.set([128, 0, 0, 0, 255, 255, 255, 255, 128, 0, 0, 0, 1, 0, 0, 255]);
},
fail() {
error = 'INVALID_OPERATION';
},
};
}
test('drape report distinguishes depths and shown tile rays when colour is identical', async (t) => {
const h = depthDiagnostic(t);
const shown = await reportPixels(h.page, h.position);
assert.deepEqual(shown.rgb, [231, 226, 223]);
assert.equal(shown.depths.allDepth, 0.25);
assert.equal(shown.depths.sceneDepth, 0.25);
assert.equal(shown.depths.globeDepth, 0.5);
assert.equal(shown.depths.buffer, 'scene');
assert.equal(shown.depths.classification, 'tiles');
assert.equal(shown.depths.source, 'tiles');
assert.equal(shown.depths.shownTileAtRay, true);
assert.equal(shown.depths.bindings, 'ground');
assert.deepEqual(shown.depths.pixel, [120, 729]);
assert.deepEqual(h.engine.camera.pickRay.mock.calls[0].arguments[0], {
x: 120.25,
y: 70.25,
});
assert.equal(h.engine.surface.raycast.mock.calls[0].arguments[0].far, 1e8);
const place =
h.engine.surface.sampleHeightDetailed.mock.calls[0].arguments[0];
assert.ok(Math.abs(place.longitude - geo.math.toRadians(-97.7)) < 1e-10);
assert.ok(Math.abs(place.latitude - geo.math.toRadians(30.2)) < 1e-10);
h.hideGround();
h.removeTiles();
const hidden = await reportPixels(h.page, h.position);
assert.deepEqual(hidden.rgb, shown.rgb);
assert.equal(hidden.depths.allDepth, 0.5);
assert.equal(hidden.depths.sceneDepth, 1);
assert.equal(hidden.depths.globeDepth, 0.5);
assert.equal(hidden.depths.buffer, 'globe');
assert.equal(hidden.depths.classification, 'terrain');
assert.equal(hidden.depths.source, 'terrain');
assert.equal(hidden.depths.shownTileAtRay, false);
assert.equal(hidden.depths.photorealActive, false);
assert.equal(hidden.depths.bindings, 'mrt');
assert.equal(h.copies.length, 4);
assert.ok(h.copies.every((copy) => copy.disposed));
assert.equal(
h.copies[0].fullscreenMaterial.uniforms.tGlobeDepth.value,
h.textures[2],
);
assert.equal(
h.renderer.setRenderTarget.mock.calls.at(-1).arguments[0],
h.previous,
);
});
test('drape report rejects a failed GPU read and releases temporary resources', async (t) => {
const h = depthDiagnostic(t);
h.fail();
await assert.rejects(
reportPixels(h.page, h.position),
/WebGL error: INVALID_OPERATION/,
);
assert.equal(h.copies.length, 1);
assert.ok(h.copies[0].disposed);
assert.equal(
h.renderer.setRenderTarget.mock.calls.at(-1).arguments[0],
h.previous,
);
});
test('drape report never reads native Navara state on Cesium', async (t) => {
const h = depthDiagnostic(t);
h.engine.kind = 'cesium';
Object.defineProperty(h.engine, 'extensions', {
get() {
throw new Error('Native read');
},
});
const sample = await reportPixels(h.page, h.position);
assert.deepEqual(sample.rgb, [231, 226, 223]);
assert.equal(sample.depths, undefined);
assert.equal(h.copies.length, 0);
});