From 7c03d68a2f8a01260d287bd64222541cb8a504e9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 21:36:38 +0400 Subject: [PATCH] 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. --- scripts/qa-tiles-fixture.mjs | 163 ++++++++++++++++++++- scripts/qa-tiles-fixture.test.mjs | 226 ++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 7 deletions(-) diff --git a/scripts/qa-tiles-fixture.mjs b/scripts/qa-tiles-fixture.mjs index 6662c4005..f47274abf 100644 --- a/scripts/qa-tiles-fixture.mjs +++ b/scripts/qa-tiles-fixture.mjs @@ -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 + 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) { diff --git a/scripts/qa-tiles-fixture.test.mjs b/scripts/qa-tiles-fixture.test.mjs index 41cb0614e..30d9d0b0b 100644 --- a/scripts/qa-tiles-fixture.test.mjs +++ b/scripts/qa-tiles-fixture.test.mjs @@ -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); +});