Skip to content

fix(city3d): fade the city in as one solid layer, dithered, never blended - #205

Merged
NateIsern merged 3 commits into
mainfrom
fix/city3d-solid-zoom-fade
Oct 13, 2026
Merged

NateIsern merged 3 commits into
mainfrom
fix/city3d-solid-zoom-fade

Conversation

@NateIsern

Copy link
Copy Markdown
Member

Problem

At a resting zoom between 15.5 and 16.5 (BUILDINGS_3D_FADE), every building and tree surface was blended at the fade's alpha. A pixel mixed the front surface with whatever the city drew behind it, so walls and streets showed through the blocks. The report came from Madrid at z16, night.

Fix: the zoom fade is a dither

The zoom fade now uses the same screen door as a level's crossfade (dithered in tsl/common.ts):

  • Each drawn entry keeps its share of the transition's pixels, scaled by the zoom's: setEntryFade(entry, from × opacity, to × opacity).
  • Complementary level crossfades still split exactly the zoom's share of the pixels between them.
  • The buildings and trees are opaque again and keep their depth writes, so they composite as one solid layer.
  • The noise is in screen space, so the pattern stays still while panning.
  • The opacity uniform is gone from every material. Web and native share one path; cityTileView included.
  • The fade window is still BUILDINGS_3D_FADE.

What it does and doesn't change: a fade still shows the map. At z16, half the screen's pixels draw the city and half draw the map. What can no longer happen is a pixel blending the city's front surface with a surface behind it (inner walls, or streets behind a block).

Why a dither and not an offscreen layer composited at one alpha

The offscreen option costs, on a phone:

  • a colour + depth target the size of the screen, about 20 MB at 1080×2400;
  • an extra full-screen pass every frame in the window, which matters for bandwidth on tile-based GPUs;
  • handing depth back to MapLibre so its later 3D layers still occlude correctly, on web and again on WebGPU.

The dither costs none of these: its variants already exist and are warmed.

Measured

bench:map (production builds, pixel6a profile, real GPU RTX 5090). The machine was shared and loaded, so treat CPU numbers as noise; the GPU process crashed on a third pass.

Scene (in the fade window) Draws, triangles GPU p50, main → this PR
eixample z16.3 138, 3.02 M (identical) 4.6–4.8 → 1.6 ms
manhattan z16.2 119–120, 1.43 M (identical) 1.5–2.3 → 1.2 ms

There is no blending, and half the fragments are discarded.

Regression check (fails on main)

check-city3d-visible's nosky leg adds a mid-fade view at z16 (or --skies=fade alone):

  • It shoots the city alone, every other layer hidden, over a black and then a white canvas backdrop.
  • Each pixel's coverage is 1 − (white − black)/255.
  • Under 2% of the city's pixels may be covered more than the fade's share and less than fully, which means a surface behind another shows through.
Coverage per pixel (share of the view) Pixels showing a surface behind another
main @52dde03 0: 17%, 0.5: 49%, 0.8: 20%, 0.9: 11%, 1: 3% 37%
This PR 0: 58%, 1: 42% 0.3%

Checks

  • bun run lint and tsc pass.
  • The frontend tests pass (1710).

🤖 Generated with Claude Code

NateIsern and others added 2 commits October 13, 2026 03:51
…nded

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 <noreply@anthropic.com>
Resolves the gate's view() parameters: main's relief, then this branch's
backdrops and zoomTo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NateIsern
NateIsern force-pushed the fix/city3d-solid-zoom-fade branch from b52be82 to 97dc7b3 Compare October 13, 2026 01:17
Resolves the gate script with main's behaviour kept: main's camera-
clearance import and its view() inspect hook (run first, on the untouched
page), then this branch's backdrops and zoomTo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NateIsern
NateIsern merged commit 5476313 into main Oct 13, 2026
13 checks passed
@NateIsern
NateIsern deleted the fix/city3d-solid-zoom-fade branch October 13, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant