perf(map): stop the vector surfaces casting shadows they can't make #20

Open
jeroen wants to merge 2 commits from perf/render-budget into main
Owner

lod22 and the basemap's relief meshes both give up distant shadow
casting, and both note the same reason: geometry re-rasterised into up
to eight cascades (sun + moon x 4) is the dominant frame cost.
map_geometry never got that treatment — every surface it spawned cast
unconditionally, at every distance.

Measured on the Groningen z14 fixture, tessellating one cell the way the
worker does (surfaces + bridges + roads + scatter + furniture at
tile_m = 1500):

Canopy 38,624 Road(Path) 2,786 Built 521
Furniture 13,080 Road(Minor) 2,537 Water 331
Trunk 9,248 Grass 1,695 others 815

70,450 triangles per cell, of which 60,952 — 87% — are the three groups
with actual volume. Across the CELL_RADIUS=1 ring that is ~550k
triangles going into every cascade, more shadow geometry than the
terrain-relief regression that measured ktris_shadow 1.07M -> 2.96M and
26 fps -> 10.

Two different fixes, because the groups fail differently:

The flat ground stack (water, landuse, roads) gets a permanent
NotShadowCaster. Those sheets sit 1-10 mm inside the ground they are
drawn on, so their only possible shadow is self-shadow acne, and no
camera position changes that. ~9.5k triangles per cell, ~85k across the
ring, for nothing. SurfaceGroup::in_ground_stack names that property
rather than reusing y_offset() < 0.0 directly: the offsets were chosen
to stop z-fighting, and a future group joining the stack for depth
reasons should not silently inherit a shadow verdict. A test holds the
two matches together.

Trees and furniture get a distance LOD (SURFACE_SHADOW_RADIUS = 0), the
same shape as lod22's. A radius and not an off-switch because tree
shadows at street level are most of the reason to draw trees; it is the
ones two cells out, whose shadows fall on ground seen from a kilometre
up, that pay nothing back. Bridge decks keep casting unconditionally —
a deck spans a gap, and its shadow on the water underneath is what
reads it as a structure.

The cell rides on the entity (ShadowLodCell) rather than in a per-cell
entity list on MapSurfaces, which is the shape lod22 uses. With a list
the toggle would remove NotShadowCaster from every mesh in the cell
the moment the camera walked into it — silently handing the ground
stack back its acne-only casting, with nothing to show for it but a
frame-time regression nobody could attribute. There is a test for that
specific direction.

It is a separate system because update_map_surfaces is already at
Bevy's 16-parameter ceiling, which is also why the camera cell is
published on MapSurfaces instead of recomputed: the two must agree on
which cell is "here" or the shadow boundary sits somewhere other than
the streaming ring. Current state is read with Has
rather than mirrored in a set, so there is no bookkeeping to drift and a
stationary camera issues no commands.

Note this layer escaped the policy quietly: log_render_cost buckets
buildings, lod22, tiles and globe, but has no MapSurface line, so the
largest remaining shadow contributor was the one thing its ktris_shadow
column could not attribute.

Unverified: this container has no GPU, so the frame-time win is
argued from triangle counts, not measured. Two things want eyes on a
workstation — the size of the win (--csv, watching frame_ms over the
Groningen anchor at street level), and whether tree shadows ending at a
~1.5 km cell boundary reads badly when the camera stands near one.
Raising SURFACE_SHADOW_RADIUS to 1 is the whole fix if it does.

`lod22` and the basemap's relief meshes both give up distant shadow casting, and both note the same reason: geometry re-rasterised into up to eight cascades (sun + moon x 4) is the dominant frame cost. `map_geometry` never got that treatment — every surface it spawned cast unconditionally, at every distance. Measured on the Groningen z14 fixture, tessellating one cell the way the worker does (surfaces + bridges + roads + scatter + furniture at tile_m = 1500): Canopy 38,624 Road(Path) 2,786 Built 521 Furniture 13,080 Road(Minor) 2,537 Water 331 Trunk 9,248 Grass 1,695 others 815 70,450 triangles per cell, of which 60,952 — 87% — are the three groups with actual volume. Across the CELL_RADIUS=1 ring that is ~550k triangles going into every cascade, more shadow geometry than the terrain-relief regression that measured ktris_shadow 1.07M -> 2.96M and 26 fps -> 10. Two different fixes, because the groups fail differently: The flat ground stack (water, landuse, roads) gets a permanent NotShadowCaster. Those sheets sit 1-10 mm *inside* the ground they are drawn on, so their only possible shadow is self-shadow acne, and no camera position changes that. ~9.5k triangles per cell, ~85k across the ring, for nothing. `SurfaceGroup::in_ground_stack` names that property rather than reusing `y_offset() < 0.0` directly: the offsets were chosen to stop z-fighting, and a future group joining the stack for depth reasons should not silently inherit a shadow verdict. A test holds the two matches together. Trees and furniture get a distance LOD (SURFACE_SHADOW_RADIUS = 0), the same shape as lod22's. A radius and not an off-switch because tree shadows at street level are most of the reason to draw trees; it is the ones two cells out, whose shadows fall on ground seen from a kilometre up, that pay nothing back. Bridge decks keep casting unconditionally — a deck spans a gap, and its shadow on the water underneath is what reads it as a structure. The cell rides on the entity (ShadowLodCell) rather than in a per-cell entity list on MapSurfaces, which is the shape lod22 uses. With a list the toggle would remove NotShadowCaster from *every* mesh in the cell the moment the camera walked into it — silently handing the ground stack back its acne-only casting, with nothing to show for it but a frame-time regression nobody could attribute. There is a test for that specific direction. It is a separate system because `update_map_surfaces` is already at Bevy's 16-parameter ceiling, which is also why the camera cell is published on MapSurfaces instead of recomputed: the two must agree on which cell is "here" or the shadow boundary sits somewhere other than the streaming ring. Current state is read with Has<NotShadowCaster> rather than mirrored in a set, so there is no bookkeeping to drift and a stationary camera issues no commands. Note this layer escaped the policy *quietly*: `log_render_cost` buckets buildings, lod22, tiles and globe, but has no MapSurface line, so the largest remaining shadow contributor was the one thing its ktris_shadow column could not attribute. Unverified: this container has no GPU, so the frame-time win is argued from triangle counts, not measured. Two things want eyes on a workstation — the size of the win (`--csv`, watching frame_ms over the Groningen anchor at street level), and whether tree shadows ending at a ~1.5 km cell boundary reads badly when the camera stands near one. Raising SURFACE_SHADOW_RADIUS to 1 is the whole fix if it does.
perf(map): stop the vector surfaces casting shadows they can't make
Some checks failed
CI / cargo check (pull_request) Has been cancelled
CI / build & test viberfox (pull_request) Has been cancelled
CI / build & test viberfox (push) Has been cancelled
CI / cargo check (push) Has been cancelled
5fb7a8bb65
`lod22` and the basemap's relief meshes both give up distant shadow
casting, and both note the same reason: geometry re-rasterised into up
to eight cascades (sun + moon x 4) is the dominant frame cost.
`map_geometry` never got that treatment — every surface it spawned cast
unconditionally, at every distance.

Measured on the Groningen z14 fixture, tessellating one cell the way the
worker does (surfaces + bridges + roads + scatter + furniture at
tile_m = 1500):

  Canopy      38,624      Road(Path)   2,786    Built    521
  Furniture   13,080      Road(Minor)  2,537    Water    331
  Trunk        9,248      Grass        1,695    others   815

70,450 triangles per cell, of which 60,952 — 87% — are the three groups
with actual volume. Across the CELL_RADIUS=1 ring that is ~550k
triangles going into every cascade, more shadow geometry than the
terrain-relief regression that measured ktris_shadow 1.07M -> 2.96M and
26 fps -> 10.

Two different fixes, because the groups fail differently:

The flat ground stack (water, landuse, roads) gets a permanent
NotShadowCaster. Those sheets sit 1-10 mm *inside* the ground they are
drawn on, so their only possible shadow is self-shadow acne, and no
camera position changes that. ~9.5k triangles per cell, ~85k across the
ring, for nothing. `SurfaceGroup::in_ground_stack` names that property
rather than reusing `y_offset() < 0.0` directly: the offsets were chosen
to stop z-fighting, and a future group joining the stack for depth
reasons should not silently inherit a shadow verdict. A test holds the
two matches together.

Trees and furniture get a distance LOD (SURFACE_SHADOW_RADIUS = 0), the
same shape as lod22's. A radius and not an off-switch because tree
shadows at street level are most of the reason to draw trees; it is the
ones two cells out, whose shadows fall on ground seen from a kilometre
up, that pay nothing back. Bridge decks keep casting unconditionally —
a deck spans a gap, and its shadow on the water underneath is what
reads it as a structure.

The cell rides on the entity (ShadowLodCell) rather than in a per-cell
entity list on MapSurfaces, which is the shape lod22 uses. With a list
the toggle would remove NotShadowCaster from *every* mesh in the cell
the moment the camera walked into it — silently handing the ground
stack back its acne-only casting, with nothing to show for it but a
frame-time regression nobody could attribute. There is a test for that
specific direction.

It is a separate system because `update_map_surfaces` is already at
Bevy's 16-parameter ceiling, which is also why the camera cell is
published on MapSurfaces instead of recomputed: the two must agree on
which cell is "here" or the shadow boundary sits somewhere other than
the streaming ring. Current state is read with Has<NotShadowCaster>
rather than mirrored in a set, so there is no bookkeeping to drift and a
stationary camera issues no commands.

Note this layer escaped the policy *quietly*: `log_render_cost` buckets
buildings, lod22, tiles and globe, but has no MapSurface line, so the
largest remaining shadow contributor was the one thing its ktris_shadow
column could not attribute.

Unverified: this container has no GPU, so the frame-time win is
argued from triangle counts, not measured. Two things want eyes on a
workstation — the size of the win (`--csv`, watching frame_ms over the
Groningen anchor at street level), and whether tree shadows ending at a
~1.5 km cell boundary reads badly when the camera stands near one.
Raising SURFACE_SHADOW_RADIUS to 1 is the whole fix if it does.
Merge branch 'main' into perf/render-budget
Some checks failed
CI / cargo check (push) Failing after 2m15s
CI / cargo check (pull_request) Failing after 1m0s
CI / build & test viberfox (push) Successful in 12m55s
CI / build & test viberfox (pull_request) Successful in 10m6s
0d8916b598
Some checks failed
CI / cargo check (push) Failing after 2m15s
CI / cargo check (pull_request) Failing after 1m0s
CI / build & test viberfox (push) Successful in 12m55s
CI / build & test viberfox (pull_request) Successful in 10m6s
This pull request has changes conflicting with the target branch.
  • crates/cartopolis/src/systems/map/map_geometry.rs
  • crates/viberfox/src/lib.rs
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin perf/render-budget:perf/render-budget
git switch perf/render-budget
Sign in to join this conversation.
No description provided.