Water surface z-fights with the land when changing altitude #13
Labels
No labels
agent
agent:ci
agent:done
agent:failed
agent:needs-input
agent:refined
agent:refining
agent:running
agent:shipped
agent:skip
autonomous
autopilot
driven
local
plan
proposal
qa
qa-gap
research
retro
ship
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jeroen/cartopolis#13
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
Geometric separation.
SurfaceGroup::y_offset(
crates/viberfox/src/systems/vector_tiles.rs:494) packs every ground sheetinto 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).
StandardMaterial::depth_bias(vector_tiles.rs:518), a ladder ofintegers: Built 1, Grass 2, Forest/Sand 3, Water 4, Roads 5–9, Canopy/Trunk
10. Applied at
crates/viberfox/src/systems/map_geometry.rs:393and, forwater,
crates/viberfox/src/systems/water.rs:159.The doc comment at
vector_tiles.rs:515claims the bias "biases the comparisonitself, so the ordering holds at any distance". That is half right. In Bevy 0.19
StandardMaterial::specializedoes 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), andExtendedMaterialchains to the base'sspecialize(
bevy_pbr-0.19.0/src/extended_material.rs:106), so water gets it too. Butbias.constantis denominated in depth-buffer ULPs, not world units, andadjacent 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), sofor a float depth buffer the bias unit is
r = 2^(e−23) ≈ D·1.19e−7whereDisthe 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:
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_scaleis left at 0 (mesh.rs:3647), so nothing compensates for thevery 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.
vector_tiles.rsand expressdepth_bias()asrung * STEP(STEP = 16 → adjacent rungs 16 ULPs apart, ~5× the noise).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.vector_tiles.rs:509-517— it is what made the1-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 at10 m. It cannot punch through buildings, which are separated by metres of honest
depth.
Acceptance criteria
surface > the map tile, roads > water, canopy > all surfaces, route line >
every surface, driver arrow > route line.
cargo test -p viberfox --bin viberfoxpasses, including the existingy_offset/depth_biaswindow assertions atvector_tiles.rs:1940-1956.Verification
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, butStandardMaterialonly exposesconstant; setting it would mean wrappingevery surface group in an
ExtendedMaterialpurely to reachMaterialExtension::specialize. Revisit only if widening the ladder provesinsufficient.
y_offsetwindow. It cannot be widened without lowering the maptile, which would sink every vector surface's edge into a visible lip and put
the buildings on a pedestal.
systems/globe_ocean.rs), which is a BRDF split on thetile imagery and shares nothing with this stack.
Branch:
fix/13-water-surface-z-fightingBoth fixed in-session.
1. The z-fight (
3d8c346). Diagnosis confirmed by the reporter: the landsheets beneath punch up through the water as triangles that flip in and out
while the camera moves.
depth_biasdoes reach the hardwareDepthStencilState::bias.constant, so the original spec's framing holds — thedefect 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 onthe 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 (
#AFBDC5against#36495C). Thepaint moved to meet the geometry, the same contract
palette::FOREST_CANOPIEDhas with the trees.
The non-obvious part is a constraint pulling the other way: on a raster basemap
the globe classifies ocean by
blue − redand 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.
#3E5A70keeps it at 50 —exactly where
#A8C4DAhad it — andtile_loader::the_basemaps_water_paint_reads_as_fully_waternow 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 wouldneed every surface group wrapped in an
ExtendedMaterialto reachMaterialExtension::specialize. Worth revisiting only if the flicker survives atlow grazing angles.
Resolved — confirmed gone by the reporter.
Four commits:
3d8c346— space the ladder (DEPTH_BIAS_STEP), correct the doc comment thatmade 1-unit rungs look sufficient
71ca4b1— match the basemap's water paint to the shaded vector water6bc8476— give water the widest gap in the window, ladder 16 → 642633e7f— markbase_color's dead water armThe 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_scaleis not being done, and the Out-of-scope note above should be readas closed rather than pending. Assessed once the defect was gone and it loses
on all three axes:
bias is proportional to the depth gradient, which is unbounded at grazing
(
clampis 0), so the angles it helps at are where it starts detachingsurfaces from what they sit on. Acne traded for peter-panning.
StandardMaterialis bindless(
#[bindless(index_table(range(0..31)))]) andExtendedMaterialonly staysbindless if the extension is too, which
WaterExtensionis not. Wrapping theground sheets would drop the most numerous, highest-triangle geometry in the
scene out of bindless batching, with nothing recovered elsewhere:
depth_biasalready sits in
StandardMaterialKeyat bit 32, so every rung specializes itsown pipeline today.
bind_group_datakey and
Assets<SurfaceMaterial>threaded throughmap_geometryand theroad-texture path, to reach one field on a pipeline descriptor.
The comment at
DEPTH_BIAS_STEPkeeps it recorded as the escalation if theflicker 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 thatnearly 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.