From 342c0d822e0ee67b18526c319fb9f407968d5449 Mon Sep 17 00:00:00 2001 From: "Nate I." Date: Tue, 13 Oct 2026 03:51:17 +0300 Subject: [PATCH] fix(city3d): fade the city in as one solid layer, dithered, never blended Between 15.5 and 16.5 (BUILDINGS_3D_FADE) every building and tree surface was blended at the fade's alpha. A pixel then mixed the front surface with whatever the city drew behind it, so walls and streets showed through the blocks at any resting zoom in the window (Madrid, owner report). The zoom fade is now the same screen door as a level's crossfade (`dithered`): each drawn entry keeps its share of the transition's pixels, scaled by the zoom's (`setEntryFade(from * opacity, to * opacity)`), so complementary level fades still partition exactly the zoom's share. The buildings and trees are opaque again and keep their depth. Each pixel is the city's front surface or the map, never a blend. The opacity uniform is gone from every material, web and native alike. Chosen over drawing the city opaque into its own target and compositing it at one alpha. The dither costs no target (a phone-sized colour+depth target is about 20 MB), no extra full-screen pass, and no depth hand-off to MapLibre. Its variants already exist and are warmed. Measured (bench:map, pixel6a profile, RTX 5090, machine shared): draws and triangles identical; GPU p50 eixample 4.6-4.8 -> 1.6 ms, manhattan 1.5-2.3 -> 1.2 ms (no blending, half the fragments). Gate: check-city3d-visible's nosky leg adds a mid-fade view at z16 that measures each pixel's coverage by the city: the city alone, shot over black and over white. Main: 37% of the city's pixels show a surface behind another (coverage 0.5, 0.8, 0.9). This fix: 0.3% (coverage 0 or 1). The limit is 2%. Co-Authored-By: Claude Opus 5.5 --- docs/MAP_3D_CITY.md | 10 +- .../map/__tests__/city3dGroundPacked.test.ts | 1 - .../map/__tests__/city3dMaterials.test.ts | 1 - .../map/__tests__/city3dNight.test.ts | 1 - .../map/__tests__/city3dShading.test.ts | 1 - .../map/__tests__/city3dTrees.test.ts | 1 - .../map/__tests__/city3dTsl.test.ts | 1 - .../map/engine/city3d/cityEngine.ts | 7 +- .../map/engine/city3d/tsl/buildingMaterial.ts | 6 +- .../map/engine/city3d/tsl/cityTileView.ts | 1 - .../map/engine/city3d/tsl/common.ts | 13 +- .../engine/city3d/tsl/skyBuildingMaterial.ts | 4 +- .../map/engine/city3d/tsl/treeMaterial.ts | 11 +- .../frontend/scripts/check-city3d-visible.ts | 160 ++++++++++++++++++ 14 files changed, 188 insertions(+), 30 deletions(-) diff --git a/docs/MAP_3D_CITY.md b/docs/MAP_3D_CITY.md index e3524c6d..80220f16 100644 --- a/docs/MAP_3D_CITY.md +++ b/docs/MAP_3D_CITY.md @@ -223,7 +223,15 @@ tile the camera sees**, each at the level of detail its distance affords (`cityMeshes.ts` → `groundTileFor`); a tile is not drawn until it is lifted. - **Fade.** The meshes grow in over exactly the zooms `building-3d` does (`BUILDINGS_3D_FADE`, 15.5 → 16.5), so switching renderers changes how - buildings look, never *when* they appear. + buildings look, never *when* they appear. The fade is the same screen door + as a level's (`tsl/common.ts` → `dithered`): at z16 half the screen's + pixels draw the city and half the map. Each pixel is the front surface of + the city or the map, never a blend. The buildings stay opaque and keep + their depth, so no wall or street shows through another surface. Blended + at the fade's alpha (as until main @52dde03), 37% of the city's pixels at + z16 mixed in a surface behind the front one: walls and streets through the + blocks (Madrid, owner report). Checked by the visibility gate's mid-fade + view (`check-city3d-visible`, nosky leg). #### Measured per tile (worker, Bun, one core) diff --git a/packages/frontend/components/map/__tests__/city3dGroundPacked.test.ts b/packages/frontend/components/map/__tests__/city3dGroundPacked.test.ts index 5d37764f..7f4a6680 100644 --- a/packages/frontend/components/map/__tests__/city3dGroundPacked.test.ts +++ b/packages/frontend/components/map/__tests__/city3dGroundPacked.test.ts @@ -72,7 +72,6 @@ const light = { uLightPosition: v(), uLightIntensity: n(0.5), uLightColor: v(), - uOpacity: n(1), uGrounded: n(0), }; const context = () => ({ diff --git a/packages/frontend/components/map/__tests__/city3dMaterials.test.ts b/packages/frontend/components/map/__tests__/city3dMaterials.test.ts index 9075556a..a64f9c26 100644 --- a/packages/frontend/components/map/__tests__/city3dMaterials.test.ts +++ b/packages/frontend/components/map/__tests__/city3dMaterials.test.ts @@ -105,7 +105,6 @@ describe('the shader', () => { uLightPosition: { value: new Vector3(0, 0, 1) }, uLightIntensity: { value: 0.5 }, uLightColor: { value: new Vector3(1, 1, 1) }, - uOpacity: { value: 1 }, uGrounded: { value: 0 }, }), sky, diff --git a/packages/frontend/components/map/__tests__/city3dNight.test.ts b/packages/frontend/components/map/__tests__/city3dNight.test.ts index a4657b4f..55253fd9 100644 --- a/packages/frontend/components/map/__tests__/city3dNight.test.ts +++ b/packages/frontend/components/map/__tests__/city3dNight.test.ts @@ -239,7 +239,6 @@ function spyMaterials() { uLightPosition: { value: new Vector3(0, 0, 1) }, uLightIntensity: { value: 0.5 }, uLightColor: { value: new Vector3(1, 1, 1) }, - uOpacity: { value: 1 }, uGrounded: { value: 0 }, }; const sky = createSkyNodes(skyRefs(true), 12); diff --git a/packages/frontend/components/map/__tests__/city3dShading.test.ts b/packages/frontend/components/map/__tests__/city3dShading.test.ts index d039bc06..9d148187 100644 --- a/packages/frontend/components/map/__tests__/city3dShading.test.ts +++ b/packages/frontend/components/map/__tests__/city3dShading.test.ts @@ -118,7 +118,6 @@ describe('the shaders', () => { uLightPosition: { value: new Vector3(0, 0, 1) }, uLightIntensity: { value: 0.5 }, uLightColor: { value: new Vector3(1, 1, 1) }, - uOpacity: { value: 1 }, uGrounded: { value: 0 }, }); diff --git a/packages/frontend/components/map/__tests__/city3dTrees.test.ts b/packages/frontend/components/map/__tests__/city3dTrees.test.ts index 32b80bcd..cc7e2c12 100644 --- a/packages/frontend/components/map/__tests__/city3dTrees.test.ts +++ b/packages/frontend/components/map/__tests__/city3dTrees.test.ts @@ -517,7 +517,6 @@ describe('tree shapes and shader', () => { uLightPosition: v(), uLightIntensity: { value: 0.5 }, uLightColor: v(), - uOpacity: { value: 1 }, uGrounded: { value: 0 }, }, THREE.BackSide, diff --git a/packages/frontend/components/map/__tests__/city3dTsl.test.ts b/packages/frontend/components/map/__tests__/city3dTsl.test.ts index 21292093..b8391344 100644 --- a/packages/frontend/components/map/__tests__/city3dTsl.test.ts +++ b/packages/frontend/components/map/__tests__/city3dTsl.test.ts @@ -145,7 +145,6 @@ describe('the TSL building material', () => { uLightPosition: { value: new Vector3(0.5, 0.5, 1) }, uLightIntensity: { value: 0.2 }, uLightColor: { value: new Vector3(1, 1, 1) }, - uOpacity: { value: 1 }, uGrounded: { value: 0 }, }); diff --git a/packages/frontend/components/map/engine/city3d/cityEngine.ts b/packages/frontend/components/map/engine/city3d/cityEngine.ts index f45eb053..3c289ec7 100644 --- a/packages/frontend/components/map/engine/city3d/cityEngine.ts +++ b/packages/frontend/components/map/engine/city3d/cityEngine.ts @@ -468,7 +468,6 @@ export function createCityEngine(options: CityEngineOptions): CityEngine { uLightPosition: { value: new THREE.Vector3(0, 0, 1) }, uLightIntensity: { value: 0.5 }, uLightColor: { value: new THREE.Vector3(1, 1, 1) }, - uOpacity: { value: 1 }, // On 3D terrain: read the per-vertex lift (`terrainGround.ts`). uGrounded: { value: 0 }, // The night seam (`setNightFactor`): read by every night-time look. @@ -1052,7 +1051,6 @@ export function createCityEngine(options: CityEngineOptions): CityEngine { const span = options.fade.end - options.fade.start; const opacity = Math.max(0, Math.min(1, (view.zoom - options.fade.start) / span)); - light.uOpacity.value = opacity; treeUniforms.uTreeMinHeight.value = treeMinHeightM(view.zoom); const center = { x: mercatorXFromLng(view.center.lng), y: mercatorYFromLat(view.center.lat) }; @@ -1142,7 +1140,10 @@ export function createCityEngine(options: CityEngineOptions): CityEngine { } placeEntry(entry, entry.placement, center); setEntryTreeDetail(entry, shapes, r(entry) < nearTreesWithin); - setEntryFade(entry, from, to); + // The zoom fade (`BUILDINGS_3D_FADE`) is the same screen door as a + // level's: its share of the transition's pixels, so the city draws as + // one solid layer, and never shows what is behind a wall through it. + setEntryFade(entry, from * opacity, to * opacity); setEntryVisible(entry, true); drawnEntries.add(entry); for (const source of entrySources.get(entry) ?? []) sources.add(source); diff --git a/packages/frontend/components/map/engine/city3d/tsl/buildingMaterial.ts b/packages/frontend/components/map/engine/city3d/tsl/buildingMaterial.ts index 071726a3..5b7cbbf2 100644 --- a/packages/frontend/components/map/engine/city3d/tsl/buildingMaterial.ts +++ b/packages/frontend/components/map/engine/city3d/tsl/buildingMaterial.ts @@ -51,7 +51,7 @@ import { dithered, extrusionLit, mapBlending, - premultiplied, + opaque, terrainLift, type UniformRef, } from './common'; @@ -66,7 +66,6 @@ export interface BuildingUniformRefs { uLightPosition: UniformRef; uLightIntensity: UniformRef; uLightColor: UniformRef; - uOpacity: UniformRef; uGrounded: UniformRef; } @@ -84,7 +83,6 @@ export function createBuildingUniforms(refs: BuildingUniformRefs) { uLightPosition: bindVec3(refs.uLightPosition), uLightIntensity: bindFloat(refs.uLightIntensity), uLightColor: bindVec3(refs.uLightColor), - uOpacity: bindFloat(refs.uOpacity), uGrounded: bindFloat(refs.uGrounded), }; } @@ -166,7 +164,7 @@ function buildMaterial( const ground = float(AO.groundMin).add( float(1 - AO.groundMin).mul(smoothstep(0, AO.groundReachM, vHeightAboveGround)), ); - const color = premultiplied(extrusionLit(base, n, ground.mul(vCornerAo), u), u.uOpacity); + const color = opaque(extrusionLit(base, n, ground.mul(vCornerAo), u)); const material = mapBlending(new MeshBasicNodeMaterial()); // The lift is metres; stored z is 1/32 m, scaled back by the model matrix. diff --git a/packages/frontend/components/map/engine/city3d/tsl/cityTileView.ts b/packages/frontend/components/map/engine/city3d/tsl/cityTileView.ts index 42021fb0..7c9fa93d 100644 --- a/packages/frontend/components/map/engine/city3d/tsl/cityTileView.ts +++ b/packages/frontend/components/map/engine/city3d/tsl/cityTileView.ts @@ -140,7 +140,6 @@ export async function createCityTileView(options: CityTileViewOptions): Promise< uLightPosition: v(light.position), uLightIntensity: { value: light.intensity }, uLightColor: v(light.color), - uOpacity: { value: 1 }, uGrounded: { value: 0 }, }); // `cityMeshes.ts` → SIDE: the stored units mirror y once. In the map that is diff --git a/packages/frontend/components/map/engine/city3d/tsl/common.ts b/packages/frontend/components/map/engine/city3d/tsl/common.ts index 50452083..a9484285 100644 --- a/packages/frontend/components/map/engine/city3d/tsl/common.ts +++ b/packages/frontend/components/map/engine/city3d/tsl/common.ts @@ -76,12 +76,11 @@ export function perObject(key: string, read: (value: unknown) => number, fallbac }); } -/** The light the engine hands every material (`occlusion.ts` → `CityLight`), plus opacity and terrain. */ +/** The light the engine hands every material (`occlusion.ts` → `CityLight`), plus terrain. */ export interface LightUniforms { uLightPosition: ReturnType; uLightIntensity: ReturnType; uLightColor: ReturnType; - uOpacity: ReturnType; uGrounded: ReturnType; } @@ -107,9 +106,13 @@ export function extrusionLit(base: Vec3, n: Vec3, occlusion: Float, u: LightUnif ); } -/** Premultiplied, as MapLibre blends custom layers. */ -export const premultiplied = (color: Vec3, opacity: Float): Node<'vec4'> => - vec4(color.mul(opacity), opacity); +/** + * An opaque surface's output. The buildings and trees are never blended: the + * zoom fade (`BUILDINGS_3D_FADE`) is the dither's, as every other fade + * ({@link dithered}), so they draw as one solid layer with their depth, and + * nothing behind a wall shows through it. + */ +export const opaque = (color: Vec3): Node<'vec4'> => vec4(color, 1); /** Blend as MapLibre blends custom layers: premultiplied alpha, opaque pass. */ export function mapBlending(material: T): T { diff --git a/packages/frontend/components/map/engine/city3d/tsl/skyBuildingMaterial.ts b/packages/frontend/components/map/engine/city3d/tsl/skyBuildingMaterial.ts index 4a2a35b6..7f9a93d4 100644 --- a/packages/frontend/components/map/engine/city3d/tsl/skyBuildingMaterial.ts +++ b/packages/frontend/components/map/engine/city3d/tsl/skyBuildingMaterial.ts @@ -51,7 +51,7 @@ import { Z_UNITS_PER_METRE } from '../packing'; import type { CityTileFormat } from '../protocol'; import { cityTileSurface, rampAt, temperedTag } from './buildingSurface'; import type { BuildingUniforms } from './buildingMaterial'; -import { dithered, type Float, mapBlending, premultiplied, terrainLift, type Vec3 } from './common'; +import { dithered, type Float, mapBlending, opaque, terrainLift, type Vec3 } from './common'; import { rolloff } from './detailedMaterial'; import type { SkyNodes } from './skyNodes'; @@ -267,7 +267,7 @@ function buildMaterial( }; const lit = max(response(surface, light), vec3(0)).pow(1 / 2.2); // Aerial perspective, the same as the ground pass's. - const color = premultiplied(sky.aerial(lit, vWorld), u.uOpacity); + const color = opaque(sky.aerial(lit, vWorld)); const material = mapBlending(new MeshBasicNodeMaterial()); material.positionNode = local; diff --git a/packages/frontend/components/map/engine/city3d/tsl/treeMaterial.ts b/packages/frontend/components/map/engine/city3d/tsl/treeMaterial.ts index e8753e25..3d3b1ea5 100644 --- a/packages/frontend/components/map/engine/city3d/tsl/treeMaterial.ts +++ b/packages/frontend/components/map/engine/city3d/tsl/treeMaterial.ts @@ -45,7 +45,7 @@ import { extrusionLit, mapBlending, perObject, - premultiplied, + opaque, terrainLift, type UniformRef, } from './common'; @@ -75,7 +75,6 @@ export interface TreeUniformRefs { uLightPosition: UniformRef; uLightIntensity: UniformRef; uLightColor: UniformRef; - uOpacity: UniformRef; uGrounded: UniformRef; } @@ -103,7 +102,6 @@ export function createTreeMaterials(refs: TreeUniformRefs, side: Side): TreeMate uLightPosition: bindVec3(refs.uLightPosition), uLightIntensity: bindFloat(refs.uLightIntensity), uLightColor: bindVec3(refs.uLightColor), - uOpacity: bindFloat(refs.uOpacity), uGrounded: bindFloat(refs.uGrounded), }; const built = new Map(); @@ -135,7 +133,6 @@ function buildMaterial( uLightPosition: ReturnType; uLightIntensity: ReturnType; uLightColor: ReturnType; - uOpacity: ReturnType; uGrounded: ReturnType; }, side: Side, @@ -220,11 +217,9 @@ function buildMaterial( mix(float(AO.groundMin + 0.1), 1, smoothstep(-0.6, 0.55, n.z)).mul(mix(0.9, 1.05, vCrown)), float(AO.groundMin).add(smoothstep(0, 3, vHeight).mul(0.2)), ); - const lit = premultiplied(extrusionLit(surface, n, occlusion, u), u.uOpacity); + const lit = opaque(extrusionLit(surface, n, occlusion, u)); // The contact shadow: premultiplied black, fading to nothing at its rim. - const shadowAlpha = float(TREE_SHADOW_ALPHA) - .mul(float(1).sub(smoothstep(0.15, 1, vRadial))) - .mul(u.uOpacity); + const shadowAlpha = float(TREE_SHADOW_ALPHA).mul(float(1).sub(smoothstep(0.15, 1, vRadial))); const color = select(vPart.greaterThan(SHADOW - 0.5), vec4(vec3(0), shadowAlpha), lit); const material = mapBlending(new MeshBasicNodeMaterial()); diff --git a/packages/frontend/scripts/check-city3d-visible.ts b/packages/frontend/scripts/check-city3d-visible.ts index 673360ae..f97ce511 100644 --- a/packages/frontend/scripts/check-city3d-visible.ts +++ b/packages/frontend/scripts/check-city3d-visible.ts @@ -78,6 +78,7 @@ import { extname, join, resolve } from 'node:path'; import { type Browser, chromium, type Page, type Route } from 'playwright-core'; import { CITY_COVERAGE_PATH } from '../lib/map/cityTiles'; +import { BUILDINGS_3D_FADE } from '../lib/map/style/buildings3d'; import { type PublishedStats, type SettledWants, unsettled } from './city3dSettled'; import { cityTile, @@ -316,6 +317,11 @@ interface View { trees: number; triangles: number; streetTriangles: number; + /** + * With `backdrops`: the view again with only the 3D city drawn, over a + * black and then a white backdrop (every other layer hidden, no sky). + */ + backdrops: [Buffer, Buffer] | null; } /** The few members of the development build's MapLibre map the gate uses. */ @@ -408,6 +414,13 @@ async function view( tilt: 'ease' | 'jump' = 'ease', /** The page's clock: local noon, or the night view's midnight. */ time: Date = NOON, + /** Also shoot the city alone over black and white (see {@link View.backdrops}). */ + backdrops = false, + /** + * Once grounded, jump to this zoom and settle there (the city opens where + * it is known to settle on software GL, then shows another zoom). + */ + zoomTo: number | null = null, ): Promise { const started = Date.now(); // Per-view progress on stderr, so a CI log says WHICH view and WHICH step @@ -524,6 +537,19 @@ async function view( if (!lifted) throw new Error(`${name}: the terrain never lifted the map: the gate would test nothing`); await step('terrain lifted'); + if (zoomTo !== null) { + await page.evaluate( + (zoom) => + new Promise((done) => { + const map = reliefMap(); + if (!map) return done(); + map.once('moveend', () => done()); + (map as unknown as { jumpTo(options: { zoom: number }): void }).jumpTo({ zoom }); + }), + zoomTo, + ); + await step(`at z${zoomTo}`); + } const stats = city ? await settled(page, wants, name) : null; const landmarkFailed = landmark !== null && @@ -565,6 +591,12 @@ async function view( path: join(outDir, `${name}.png`), timeout: SETTLE_TIMEOUT_MS, }); + const overBackdrops = backdrops + ? ([ + await overBackdrop(page, '#000000', join(outDir, `${name}-over-black.png`)), + await overBackdrop(page, '#ffffff', join(outDir, `${name}-over-white.png`)), + ] as [Buffer, Buffer]) + : null; await context.close(); // A view that never settled was measured mid-load: whatever it shows, the // gate cannot vouch for it, and a timeout is a finding, not a pass. @@ -576,6 +608,7 @@ async function view( png, settled: !city || stats !== null, landmarkFailed, + backdrops: overBackdrops, trees: stats?.trees ?? 0, triangles: stats?.triangles ?? 0, streetTriangles: stats?.streetTriangles ?? 0, @@ -745,6 +778,95 @@ const SPECKLE_LEVELS = 24; */ const NIGHT_MAX_SPECKLE_PCT = 0.1; +/** + * The view with only the 3D city drawn over a flat `colour`: every other + * layer hidden, the sky off, the colour behind the map's canvas. Two of + * these give each pixel's coverage by the city ({@link layeredPct}). + */ +async function overBackdrop(page: Page, colour: string, path: string): Promise { + await page.evaluate(async (fill) => { + const map = reliefMap() as unknown as { + getStyle(): { layers: { id: string; type: string }[] }; + getLayoutProperty(id: string, name: string): unknown; + setLayoutProperty(id: string, name: string, value: unknown): void; + setSky(sky: unknown): void; + triggerRepaint(): void; + getCanvas(): HTMLCanvasElement; + once(event: string, listener: () => void): void; + }; + // Once: everything but the city hidden, so the map's own pixels are + // transparent wherever the city does not cover them. + const others = map + .getStyle() + .layers.filter((layer) => !(layer.type === 'custom' && layer.id.endsWith('city3d'))); + if (others.some((layer) => map.getLayoutProperty(layer.id, 'visibility') !== 'none')) { + for (const layer of others) map.setLayoutProperty(layer.id, 'visibility', 'none'); + map.setSky(undefined); + await new Promise((done) => { + map.once('idle', () => done()); + map.triggerRepaint(); + }); + } + // The backdrop is the canvas element's own: the page composites the + // map's frame over it, exactly as the city's coverage says. + map.getCanvas().style.background = fill; + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + }, colour); + await drainGpu(page); + return page.screenshot({ clip: CLIP, path, timeout: SETTLE_TIMEOUT_MS }); +} + +/** + * Of the pixels the city covers, the share (%) where it covers only PARTLY + * but more than {@link FADE_MID_COVERAGE} would: more than one surface of it + * blended over the pixel, so a wall or a street behind a building shows + * through it. From the city alone over black and over white: a pixel's + * coverage is 1 − (white − black) / 255. A solid layer covers each pixel + * fully or not at all (a dither), or by the fade's one share (an alpha). + */ +async function layeredPct(browser: Browser, black: Buffer, white: Buffer): Promise { + const page = await browser.newPage(); + const value = await page.evaluate( + async ([b, w, mid]) => { + const pixels = async (data: string) => { + const img = new Image(); + img.src = `data:image/png;base64,${data}`; + await img.decode(); + const canvas = new OffscreenCanvas(img.width, img.height); + const g = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D; + g.drawImage(img, 0, 0); + return g.getImageData(0, 0, img.width, img.height).data; + }; + const [x, y] = await Promise.all([pixels(b), pixels(w)]); + let covered = 0; + let layered = 0; + for (let i = 0; i < x.length; i += 4) { + const through = (y[i] - x[i] + (y[i + 1] - x[i + 1]) + (y[i + 2] - x[i + 2])) / (3 * 255); + const coverage = 1 - through; + if (coverage < 0.05) continue; + covered++; + if (coverage > mid + 0.1 && coverage < 0.95) layered++; + } + return covered === 0 ? 0 : (100 * layered) / covered; + }, + [black.toString('base64'), white.toString('base64'), FADE_MID_COVERAGE] as const, + ); + await page.close(); + return Number(value.toFixed(2)); +} + +/** + * Mid-fade, the most of the city's pixels that may show a surface through + * another, %. Measured on SwiftShader at z16: 37% on main @52dde03 (every + * surface blended at the fade's alpha: coverage 0.5, 0.8, 0.9 by the + * surfaces behind), 0.3% with the fade dithered (coverage 0 or 1). + */ +const FADE_MAX_LAYERED_PCT = 2; + +/** The zoom fade's share at the mid-fade view's zoom (`BUILDINGS_3D_FADE`). */ +const FADE_MID_COVERAGE = + (16 - BUILDINGS_3D_FADE.start) / (BUILDINGS_3D_FADE.end - BUILDINGS_3D_FADE.start); + /** * Over a junction of the synthetic grid (the one north-west of the central * block, `syntheticCity.ts` → `roadTile`), where the street detail fills the @@ -1102,6 +1224,44 @@ try { `night: ${speckled}% of the map is speckle (over ${NIGHT_MAX_SPECKLE_PCT}%): a surface value changes pixel by pixel`, ); } + if (!DEBUG && (skies.includes('nosky') || skies.includes('fade'))) { + // Mid-fade, at a resting zoom inside `BUILDINGS_3D_FADE`: the city must + // draw as ONE solid layer, never a wall or a street behind a building + // showing through it. Born of main @cd11022: every surface was blended at + // the fade's alpha, so walls and streets showed through the blocks. Without + // the sky, whose passes draw over the backdrop; trees off (their contact + // shadows are blended by design). + const { flags, choices } = SKIES.nosky; + // Street detail off: it starts above this zoom, and would never settle. + const mid = await view( + browser, + origin, + `${flags.replace('&cityStreets=1', '')}&city3dTrees=0`, + choices, + outDir, + 'fade-mid', + 'buildings', + { ...SYNTHETIC_CENTRE, zoom: 18 }, + // At z16 the high preset draws a city of tiles that two software-GL + // cores take minutes a frame over; what is under test is the blend. + 'low', + null, + 'jump', + NOON, + true, + 16, + ); + const [black, white] = mid.backdrops ?? [mid.png, mid.png]; + const layered = await layeredPct(browser, black, white); + results.fade = { triangles: mid.triangles, layeredPct: layered }; + console.error(`fade: ${layered}% of the city's pixels show a surface behind another`); + if (mid.triangles === 0) + failures.push('fade: the engine drew no buildings mid-fade (the fixture is not reaching it)'); + else if (layered > FADE_MAX_LAYERED_PCT) + failures.push( + `fade: ${layered}% of the city's pixels show a surface behind another (over ${FADE_MAX_LAYERED_PCT}%): walls and streets show through the buildings`, + ); + } if (!DEBUG && skies.includes('objects')) { // Models (`models.ts`, drawn by the TSL pass): the landmark on and off, // without the sky (models are drawn the same way under either).