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); +});