Report the depths behind each drape sample in the tiles scenario

With --report the scenario prints, on Navara, the scene, globe and
combined depths at the sample pixel with the drape hidden, shown and
hidden again, the buffer the ground pass classifies it as, and whether a
shown tile lies on the ray, so a drape on the wrong surface explains
itself.
This commit is contained in:
German Khokhlov committed 2026-11-10 21:36:38 +04:00
1 parent 555906da6b
commit 7c03d68a2f
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 assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import * as geo from '../src/geo/index.js'; import * as geo from '../src/geo/index.js';
import { GROUND_SURFACE_GLSL } from '../src/globe/navara/ground/surface.js';
import { import {
assertKeylessPreview, assertKeylessPreview,
createStrict, createStrict,
@@ -237,13 +238,22 @@ await strict.run({}, async (browser) => {
// Map imagery and classification shaders may still be arriving before ready. // Map imagery and classification shaders may still be arriving before ready.
await page.evaluate(() => window.qaDrape.show(false)); await page.evaluate(() => window.qaDrape.show(false));
await loaded(page); await loaded(page);
const baseline = await pixels(page, position); const before = await pixels(page, position);
await page.evaluate(() => window.qaDrape.show(true)); 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)); 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 drawn = rgb[0] > 180 && rgb[1] < 80 && rgb[2] > 180;
const sample = { surface, drape, baseline, rgb, restored, drawn }; 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); drapes.push(sample);
console.log( console.log(
`${report ? 'REPORT' : 'DRAPE'} ${kind} ${JSON.stringify(sample)}`, `${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. // Read actual canvas pixels during postRender; Cesium discards them afterwards.
async function pixels(page, position) { 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 { engine } = window.__godsEyeView;
const readDepths =
diagnostic && engine.kind === 'navara'
? new Function(`return (${diagnostic})`)()
: null;
let depths;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = canvas.height = 5; canvas.width = canvas.height = 5;
const context = canvas.getContext('2d', { willReadFrequently: true }); const context = canvas.getContext('2d', { willReadFrequently: true });
for (let frame = 0; frame < 2; frame++) for (let frame = 0; frame < 2; frame++)
await new Promise((resolve) => { await new Promise((resolve, reject) => {
const off = engine.frames.postRender.addEventListener(() => { const off = engine.frames.postRender.addEventListener(() => {
off(); off();
const point = engine.projector.worldToWindow(position); const point = engine.projector.worldToWindow(position);
@@ -343,19 +364,147 @@ async function pixels(page, position) {
5, 5,
5, 5,
); );
try {
if (readDepths)
depths = readDepths(engine, point, classification);
} catch (error) {
reject(error);
return;
}
resolve(); resolve();
}); });
engine.render.request('qa-drape-pixels'); engine.render.request('qa-drape-pixels');
}); });
const data = context.getImageData(0, 0, 5, 5).data; const data = context.getImageData(0, 0, 5, 5).data;
return [0, 1, 2].map( const rgb = [0, 1, 2].map(
(channel) => (channel) =>
data.reduce( data.reduce(
(sum, value, i) => sum + (i % 4 === channel ? value : 0), (sum, value, i) => sum + (i % 4 === channel ? value : 0),
0, 0,
) / 25, ) / 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) { async function checkPoints(page, kind) {
+226
View File
@@ -5,6 +5,7 @@ import { Cesium3DTileset } from 'cesium';
import { Core, initSync } from '@navaramap/engine'; import { Core, initSync } from '@navaramap/engine';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Group, Raycaster, Vector3 } from 'three'; import { Group, Raycaster, Vector3 } from 'three';
import { DepthCopyPass } from 'postprocessing';
import * as geo from '../src/geo/index.js'; import * as geo from '../src/geo/index.js';
import { buildTilesFixture } from './build-tiles3d-fixture.mjs'; import { buildTilesFixture } from './build-tiles3d-fixture.mjs';
import { localTilesetPath, qaTilesetUrl } from '../src/maps/qaTileset.js'; 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 { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
import { createNavaraMaps } from '../src/globe/navara/maps.js'; import { createNavaraMaps } from '../src/globe/navara/maps.js';
import { createNavaraSurface } from '../src/globe/navara/surface.js'; import { createNavaraSurface } from '../src/globe/navara/surface.js';
import { GROUND_SURFACE_GLSL } from '../src/globe/navara/ground/surface.js';
import { import {
assertKeylessPreview, assertKeylessPreview,
TILES_FIXTURE_PATH, 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);
});