Water surface z-fights with the land when changing altitude #13

Closed
opened 2026-07-30 14:02:22 +00:00 by jeroen · 2 comments
Owner

Problem

Water surfaces shimmer/jitter against the land around them while the camera
changes altitude. It is z-fighting in the coplanar ground stack, and the cause is
that the ladder separating those sheets is one depth-buffer ULP wide — the
smallest non-zero margin possible, and smaller than the rasteriser's own rounding
noise.

Two mechanisms are supposed to order the stack, and both are too weak at range:

  1. Geometric separation. SurfaceGroup::y_offset
    (crates/viberfox/src/systems/vector_tiles.rs:494) packs every ground sheet
    into the 10 mm window between the map tile's top face (−0.01, from the 0.04 m
    cuboid centred at −0.03 in crates/viberfox/src/systems/map_stream.rs:438)
    and the buildings' base at y = 0. Water sits at −0.004, just 2 mm above
    Forest/Sand (−0.006) and 1 mm below Road (−0.003).

  2. StandardMaterial::depth_bias (vector_tiles.rs:518), a ladder of
    integers: Built 1, Grass 2, Forest/Sand 3, Water 4, Roads 5–9, Canopy/Trunk
    10. Applied at crates/viberfox/src/systems/map_geometry.rs:393 and, for
    water, crates/viberfox/src/systems/water.rs:159.

The doc comment at vector_tiles.rs:515 claims the bias "biases the comparison
itself, so the ordering holds at any distance". That is half right. In Bevy 0.19
StandardMaterial::specialize does write it to the hardware depth bias
(bevy_pbr-0.19.0/src/pbr_material.rs:1558:
depth_stencil.bias.constant = key.bind_group_data.bits() >> 32), and
ExtendedMaterial chains to the base's specialize
(bevy_pbr-0.19.0/src/extended_material.rs:106), so water gets it too. But
bias.constant is denominated in depth-buffer ULPs, not world units, and
adjacent rungs differ by exactly 1.

Why altitude is the trigger. The depth buffer is reversed-z Depth32Float
(bevy_pbr-0.19.0/src/render/mesh.rs:3640, depth_compare: GreaterEqual), so
for a float depth buffer the bias unit is r = 2^(e−23) ≈ D·1.19e−7 where D is
the primitive's depth value. Expressing the 2 mm geometric gap in those same
units gives Δy / (1.19e−7 · z) — i.e. the geometric margin, measured in ULPs,
falls off as 1/z
:

slant range geometric margin bias margin total
5 km ~3.4 ULP 1 ULP ~4.4 ULP
15 km ~1.1 ULP 1 ULP ~2.1 ULP

Rounding noise in the interpolated depth is itself a few ULPs, and it re-rolls
every frame as the view matrix changes. Climbing collapses the geometric term
until only the 1-ULP bias differential is left, which loses — so the surface
flickers while the camera moves in height and freezes when it stops. Water is
where it is most visible because it is dark against pale land, and because lakes
are large contiguous areas usually bordered by Forest, its nearest rung (2 mm).

bias.slope_scale is left at 0 (mesh.rs:3647), so nothing compensates for the
very large depth gradient of a ground plane viewed obliquely at range — the exact
case in the report.

Approach

Keep both mechanisms, but widen the bias ladder so its spacing dominates the
noise instead of sitting inside it. A uniform multiplier preserves every existing
ordering relationship.

  • Add a shared step constant in vector_tiles.rs and express depth_bias() as
    rung * STEP (STEP = 16 → adjacent rungs 16 ULPs apart, ~5× the noise).
  • Scale the other users of the same budget by the same factor so the global order
    is unchanged: the route line materials (18/20/22) at
    crates/viberfox/src/systems/navigation.rs:678-681, and the driver arrow (24)
    at crates/viberfox/src/systems/driver.rs:748.
  • Correct the doc comment at vector_tiles.rs:509-517 — it is what made the
    1-unit spacing look sufficient. Record that the unit is a depth ULP and that
    the geometric term decays as 1/z.

Scaling is safe at every range because a ULP-denominated bias is proportional to
the primitive's own depth: the apparent world-space shift is ~1.19e−7 · rung · STEP · z, i.e. water at 64 ULPs moves ~3.8 cm forward at 5 km and ~40 µm at
10 m. It cannot punch through buildings, which are separated by metres of honest
depth.

Acceptance criteria

  • Adjacent surface rungs are ≥ 16 depth-bias units apart.
  • Relative ordering is unchanged and unit-tested: water > grass, every
    surface > the map tile, roads > water, canopy > all surfaces, route line >
    every surface, driver arrow > route line.
  • An altitude sweep over water shows no flicker on the water/land boundary.
  • cargo test -p viberfox --bin viberfox passes, including the existing
    y_offset/depth_bias window assertions at vector_tiles.rs:1940-1956.

Verification

cargo check -p viberfox
cargo test -p viberfox --bin viberfox
# altitude sweep over the Paterswoldsemeer lakes south of Groningen
cargo run -p viberfox --profile dev-bevy -- --shot /tmp/water.png \
    --at 53.175,6.545,1500 --at 53.175,6.545,12000 --frames 8 --look=-35,0

The flicker is motion-dependent, so a still frame cannot prove it fixed; compare
the water/land boundary across sweep frames, which is where the ULP margin
changes.

Out of scope

  • bias.slope_scale. It is the textbook remedy for the oblique-angle case, but
    StandardMaterial only exposes constant; setting it would mean wrapping
    every surface group in an ExtendedMaterial purely to reach
    MaterialExtension::specialize. Revisit only if widening the ladder proves
    insufficient.
  • Re-cutting the y_offset window. It cannot be widened without lowering the map
    tile, which would sink every vector surface's edge into a visible lip and put
    the buildings on a pedestal.
  • The globe tier's ocean (systems/globe_ocean.rs), which is a BRDF split on the
    tile imagery and shares nothing with this stack.

Branch: fix/13-water-surface-z-fighting

## Problem Water surfaces shimmer/jitter against the land around them while the camera changes altitude. It is z-fighting in the coplanar ground stack, and the cause is that the ladder separating those sheets is **one depth-buffer ULP wide** — the smallest non-zero margin possible, and smaller than the rasteriser's own rounding noise. Two mechanisms are supposed to order the stack, and both are too weak at range: 1. **Geometric separation.** `SurfaceGroup::y_offset` (`crates/viberfox/src/systems/vector_tiles.rs:494`) packs every ground sheet into the 10 mm window between the map tile's top face (−0.01, from the 0.04 m cuboid centred at −0.03 in `crates/viberfox/src/systems/map_stream.rs:438`) and the buildings' base at y = 0. Water sits at −0.004, just 2 mm above Forest/Sand (−0.006) and 1 mm below Road (−0.003). 2. **`StandardMaterial::depth_bias`** (`vector_tiles.rs:518`), a ladder of integers: Built 1, Grass 2, Forest/Sand 3, Water 4, Roads 5–9, Canopy/Trunk 10. Applied at `crates/viberfox/src/systems/map_geometry.rs:393` and, for water, `crates/viberfox/src/systems/water.rs:159`. The doc comment at `vector_tiles.rs:515` claims the bias "biases the comparison itself, so the ordering holds at any distance". That is half right. In Bevy 0.19 `StandardMaterial::specialize` does write it to the hardware depth bias (`bevy_pbr-0.19.0/src/pbr_material.rs:1558`: `depth_stencil.bias.constant = key.bind_group_data.bits() >> 32`), and `ExtendedMaterial` chains to the base's `specialize` (`bevy_pbr-0.19.0/src/extended_material.rs:106`), so water gets it too. But `bias.constant` is denominated in **depth-buffer ULPs**, not world units, and adjacent rungs differ by exactly **1**. Why altitude is the trigger. The depth buffer is reversed-z `Depth32Float` (`bevy_pbr-0.19.0/src/render/mesh.rs:3640`, `depth_compare: GreaterEqual`), so for a float depth buffer the bias unit is `r = 2^(e−23) ≈ D·1.19e−7` where `D` is the primitive's depth value. Expressing the 2 mm geometric gap in those same units gives `Δy / (1.19e−7 · z)` — i.e. **the geometric margin, measured in ULPs, falls off as 1/z**: | slant range | geometric margin | bias margin | total | |---|---|---|---| | 5 km | ~3.4 ULP | 1 ULP | ~4.4 ULP | | 15 km | ~1.1 ULP | 1 ULP | ~2.1 ULP | Rounding noise in the interpolated depth is itself a few ULPs, and it re-rolls every frame as the view matrix changes. Climbing collapses the geometric term until only the 1-ULP bias differential is left, which loses — so the surface flickers while the camera moves in height and freezes when it stops. Water is where it is most visible because it is dark against pale land, and because lakes are large contiguous areas usually bordered by Forest, its nearest rung (2 mm). `bias.slope_scale` is left at 0 (`mesh.rs:3647`), so nothing compensates for the very large depth gradient of a ground plane viewed obliquely at range — the exact case in the report. ## Approach Keep both mechanisms, but widen the bias ladder so its spacing dominates the noise instead of sitting inside it. A uniform multiplier preserves every existing ordering relationship. - Add a shared step constant in `vector_tiles.rs` and express `depth_bias()` as `rung * STEP` (STEP = 16 → adjacent rungs 16 ULPs apart, ~5× the noise). - Scale the other users of the same budget by the same factor so the global order is unchanged: the route line materials (18/20/22) at `crates/viberfox/src/systems/navigation.rs:678-681`, and the driver arrow (24) at `crates/viberfox/src/systems/driver.rs:748`. - Correct the doc comment at `vector_tiles.rs:509-517` — it is what made the 1-unit spacing look sufficient. Record that the unit is a depth ULP and that the geometric term decays as 1/z. Scaling is safe at every range because a ULP-denominated bias is proportional to the primitive's own depth: the apparent world-space shift is `~1.19e−7 · rung · STEP · z`, i.e. water at 64 ULPs moves ~3.8 cm forward at 5 km and ~40 µm at 10 m. It cannot punch through buildings, which are separated by metres of honest depth. ## Acceptance criteria - [ ] Adjacent surface rungs are ≥ 16 depth-bias units apart. - [ ] Relative ordering is unchanged and unit-tested: water > grass, every surface > the map tile, roads > water, canopy > all surfaces, route line > every surface, driver arrow > route line. - [ ] An altitude sweep over water shows no flicker on the water/land boundary. - [ ] `cargo test -p viberfox --bin viberfox` passes, including the existing `y_offset`/`depth_bias` window assertions at `vector_tiles.rs:1940-1956`. ## Verification ```bash cargo check -p viberfox cargo test -p viberfox --bin viberfox # altitude sweep over the Paterswoldsemeer lakes south of Groningen cargo run -p viberfox --profile dev-bevy -- --shot /tmp/water.png \ --at 53.175,6.545,1500 --at 53.175,6.545,12000 --frames 8 --look=-35,0 ``` The flicker is motion-dependent, so a still frame cannot prove it fixed; compare the water/land boundary across sweep frames, which is where the ULP margin changes. ## Out of scope - `bias.slope_scale`. It is the textbook remedy for the oblique-angle case, but `StandardMaterial` only exposes `constant`; setting it would mean wrapping every surface group in an `ExtendedMaterial` purely to reach `MaterialExtension::specialize`. Revisit only if widening the ladder proves insufficient. - Re-cutting the `y_offset` window. It cannot be widened without lowering the map tile, which would sink every vector surface's edge into a visible lip and put the buildings on a pedestal. - The globe tier's ocean (`systems/globe_ocean.rs`), which is a BRDF split on the tile imagery and shares nothing with this stack. --- Branch: `fix/13-water-surface-z-fighting`
Author
Owner

Both fixed in-session.

1. The z-fight (3d8c346). Diagnosis confirmed by the reporter: the land
sheets beneath punch up through the water as triangles that flip in and out
while the camera moves. depth_bias does reach the hardware
DepthStencilState::bias.constant, so the original spec's framing holds — the
defect is that the ladder ran in steps of 1.0, and that field is denominated in
depth-buffer ULPs. One unit is the smallest non-zero margin there is. Every rung
now rides DEPTH_BIAS_STEP (16); the route line and driver arrow are rungs on
the same ladder and scale with it.

2. The ring seam (71ca4b1), raised after filing and not in the spec above.
The vector surfaces only stream in a 3×3 cell ring, so there is always a hard
edge where the shaded water stops and the basemap's paint takes over — and the
two disagreed by nearly the whole value range (#AFBDC5 against #36495C). The
paint moved to meet the geometry, the same contract palette::FOREST_CANOPIED
has with the trees.

The non-obvious part is a constraint pulling the other way: on a raster basemap
the globe classifies ocean by blue − red and needs 40 to read as fully water.
Closing the last of the hue gap would have taken that to 23 and faded the
planet's oceans out, with nothing to report it. #3E5A70 keeps it at 50 —
exactly where #A8C4DA had it — and tile_loader::the_basemaps_water_paint_reads_as_fully_water
now pins it.

Acceptance criteria from the spec: rung spacing, ordering and the test suite are
all met (164 pass). The visual criterion was checked for the seam, which a still
can show; the flicker one could not be — it is motion-dependent, as the spec
predicted, and was confirmed by eye instead.

Still open (unchanged from Out of scope): bias.slope_scale, which would
need every surface group wrapped in an ExtendedMaterial to reach
MaterialExtension::specialize. Worth revisiting only if the flicker survives at
low grazing angles.

Both fixed in-session. **1. The z-fight** (`3d8c346`). Diagnosis confirmed by the reporter: the land sheets beneath punch up through the water as triangles that flip in and out while the camera moves. `depth_bias` *does* reach the hardware `DepthStencilState::bias.constant`, so the original spec's framing holds — the defect is that the ladder ran in steps of 1.0, and that field is denominated in depth-buffer ULPs. One unit is the smallest non-zero margin there is. Every rung now rides `DEPTH_BIAS_STEP` (16); the route line and driver arrow are rungs on the same ladder and scale with it. **2. The ring seam** (`71ca4b1`), raised after filing and not in the spec above. The vector surfaces only stream in a 3×3 cell ring, so there is always a hard edge where the shaded water stops and the basemap's paint takes over — and the two disagreed by nearly the whole value range (`#AFBDC5` against `#36495C`). The paint moved to meet the geometry, the same contract `palette::FOREST_CANOPIED` has with the trees. The non-obvious part is a constraint pulling the other way: on a raster basemap the globe classifies ocean by `blue − red` and needs 40 to read as fully water. Closing the last of the hue gap would have taken that to 23 and faded the planet's oceans out, with nothing to report it. `#3E5A70` keeps it at 50 — exactly where `#A8C4DA` had it — and `tile_loader::the_basemaps_water_paint_reads_as_fully_water` now pins it. Acceptance criteria from the spec: rung spacing, ordering and the test suite are all met (164 pass). The visual criterion was checked for the seam, which a still *can* show; the flicker one could not be — it is motion-dependent, as the spec predicted, and was confirmed by eye instead. Still open (unchanged from **Out of scope**): `bias.slope_scale`, which would need every surface group wrapped in an `ExtendedMaterial` to reach `MaterialExtension::specialize`. Worth revisiting only if the flicker survives at low grazing angles.
Author
Owner

Resolved — confirmed gone by the reporter.

Four commits:

  • 3d8c346 — space the ladder (DEPTH_BIAS_STEP), correct the doc comment that
    made 1-unit rungs look sufficient
  • 71ca4b1 — match the basemap's water paint to the shaded vector water
  • 6bc8476 — give water the widest gap in the window, ladder 16 → 64
  • 2633e7f — mark base_color's dead water arm

The first pass at 16 was not enough on its own: lakes still took the occasional
triangle at the oblique angles a low camera looks at the middle distance with.
What closed it was reallocating the window rather than only widening the ladder.
The millimetres were spread evenly and the fights inside them are not equally
visible — land against land is pale-on-pale and can be lost unseen, land against
water is dark against pale and is the one that shows. Land packs to 1 mm apart,
water takes 4 mm beneath and 2 mm above.

slope_scale is not being done, and the Out-of-scope note above should be read
as closed rather than pending.
Assessed once the defect was gone and it loses
on all three axes:

  • Visuals — nothing left to fix, and it is not free insurance. Slope-scaled
    bias is proportional to the depth gradient, which is unbounded at grazing
    (clamp is 0), so the angles it helps at are where it starts detaching
    surfaces from what they sit on. Acne traded for peter-panning.
  • Performance — strictly worse. StandardMaterial is bindless
    (#[bindless(index_table(range(0..31)))]) and ExtendedMaterial only stays
    bindless if the extension is too, which WaterExtension is not. Wrapping the
    ground sheets would drop the most numerous, highest-triangle geometry in the
    scene out of bindless batching, with nothing recovered elsewhere: depth_bias
    already sits in StandardMaterialKey at bit 32, so every rung specializes its
    own pipeline today.
  • Code quality — a material type, a plugin registration, a bind_group_data
    key and Assets<SurfaceMaterial> threaded through map_geometry and the
    road-texture path, to reach one field on a pipeline descriptor.

The comment at DEPTH_BIAS_STEP keeps it recorded as the escalation if the
flicker ever returns on other hardware or after a near-plane change — worth
reaching for then, not speculatively.

Regressions are pinned by tests rather than by the refactor (169 pass): rung
separation, water's widest-gap allocation, the stack ordering, and
the_basemaps_water_paint_reads_as_fully_water, which guards the constraint that
nearly bit during the seam work — closing the last of the hue gap would have
taken the globe's raster ocean mask from 50 to 23 against a threshold of 40 and
faded the planet's oceans out with nothing to report it.

**Resolved — confirmed gone by the reporter.** Four commits: - `3d8c346` — space the ladder (`DEPTH_BIAS_STEP`), correct the doc comment that made 1-unit rungs look sufficient - `71ca4b1` — match the basemap's water paint to the shaded vector water - `6bc8476` — give water the widest gap in the window, ladder 16 → 64 - `2633e7f` — mark `base_color`'s dead water arm The first pass at 16 was not enough on its own: lakes still took the occasional triangle at the oblique angles a low camera looks at the middle distance with. What closed it was reallocating the window rather than only widening the ladder. The millimetres were spread evenly and the fights inside them are not equally visible — land against land is pale-on-pale and can be lost unseen, land against water is dark against pale and is the one that shows. Land packs to 1 mm apart, water takes 4 mm beneath and 2 mm above. **`slope_scale` is not being done, and the Out-of-scope note above should be read as closed rather than pending.** Assessed once the defect was gone and it loses on all three axes: - *Visuals* — nothing left to fix, and it is not free insurance. Slope-scaled bias is proportional to the depth gradient, which is unbounded at grazing (`clamp` is 0), so the angles it helps at are where it starts detaching surfaces from what they sit on. Acne traded for peter-panning. - *Performance* — strictly worse. `StandardMaterial` is bindless (`#[bindless(index_table(range(0..31)))]`) and `ExtendedMaterial` only stays bindless if the extension is too, which `WaterExtension` is not. Wrapping the ground sheets would drop the most numerous, highest-triangle geometry in the scene out of bindless batching, with nothing recovered elsewhere: `depth_bias` already sits in `StandardMaterialKey` at bit 32, so every rung specializes its own pipeline today. - *Code quality* — a material type, a plugin registration, a `bind_group_data` key and `Assets<SurfaceMaterial>` threaded through `map_geometry` and the road-texture path, to reach one field on a pipeline descriptor. The comment at `DEPTH_BIAS_STEP` keeps it recorded as the escalation if the flicker ever returns on other hardware or after a near-plane change — worth reaching for then, not speculatively. Regressions are pinned by tests rather than by the refactor (169 pass): rung separation, water's widest-gap allocation, the stack ordering, and `the_basemaps_water_paint_reads_as_fully_water`, which guards the constraint that nearly bit during the seam work — closing the last of the hue gap would have taken the globe's raster ocean mask from 50 to 23 against a threshold of 40 and faded the planet's oceans out with nothing to report it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jeroen/cartopolis#13
No description provided.