Plant-Layer Viewport Culling

Layer-based lazy loading

The plant layer (PlantsLayer.tsx) lazy loads elements as they enter the visible map area.

It calculates the currently visible bounds of the canvas and checks which plantings intersect with those bounds.

The intersecting plantings are saved in a Set, and only those plantings are rendered.

The shared utility used to calculate the visible bounds can be found in lazy-load-util.ts.

This reduces rendering and memory costs because plantings outside the viewport do not need to be rendered.

Linear viewport selection

Before spatial indexing was added, the plant layer iterated over every planting to determine which plantings were visible.

Lazy loading therefore reduced the number of rendered plantings, but visible-planting lookup could still become expensive on larger maps.

The cost of this linear lookup increases with the total number of plantings, even when only a small number of them are visible.

Spatial indexing

An RBush spatial index was implemented for the plant layer.

The index stores the bounding box of each planting. When the viewport changes, the visible viewport bounds are used to query the index.

This allows plantings outside the viewport to be skipped without checking every planting individually.

Spatial indexing also introduces overhead:

  • the index must be constructed when the plantings change;
  • each viewport lookup requires an index search;
  • the index requires additional memory.

For small maps, this overhead can outweigh the work saved by the spatial query.

Benchmark results

The benchmark results summarize five runs for each scenario.

ScenarioLinear medianRBush medianRBush change
50 plants, all visible1574.4 ms1679.4 ms6.7% slower
50 plants, half visible1591.6 ms1461.1 ms8.2% faster
100 plants2420.0 ms2118.8 ms12.4% faster

At 50 plants, the better implementation depends on how many plantings are visible.

When all plantings are visible, RBush adds query overhead without skipping enough plantings to compensate for that overhead.

When approximately half of the plantings are visible, RBush performs better because it avoids processing the plantings outside the viewport.

At 100 plants, spatial indexing provided a clear performance improvement.

Heuristic

The plant layer uses the following heuristic:

  • use linear viewport filtering for fewer than 100 plantings;
  • use RBush spatial indexing for 100 plantings or more.

A threshold of 50 plantings was considered.

However, the benchmark results at 50 plantings depended strongly on the percentage of visible plantings. This percentage depends on planting density, viewport size, and the user's zoom level.

The threshold of 100 plantings was selected as a conservative value because the benchmark showed a clear improvement at that size.

The threshold is a guideline rather than a universally optimal value. It can be adjusted later if further profiling shows that users generally work while sufficiently zoomed in for an earlier switch to be beneficial.

Memory usage

Memory usage was checked on a map containing 100 plantings.

ImplementationRetained size
Linear38,485 kB
RBush40,145 kB

The measured difference was 1,660 kB.

These measurements should be interpreted cautiously because garbage collection had a noticeable effect on the reported retained size.

The observed difference was considered small enough that memory usage was not used as an additional condition in the heuristic.

The implementation was not tested on an older or memory-constrained computer because no such test device was available.

Invisible layers

When the plant layer is invisible, neither linear viewport filtering nor spatial indexing is performed.

The RBush index is not constructed, and the viewport event listeners used for plant culling are removed.

When the layer becomes visible again, the lookup strategy is selected based on the number of plantings:

  • linear viewport filtering is used for fewer than 100 plantings;
  • RBush spatial indexing is used for 100 plantings or more.

The visible plantings are recalculated immediately when the layer becomes visible again.

Scope

The heuristic is currently limited to the plant layer.

It is considered unlikely that users will place enough shade or drawing elements for spatial indexing to provide the same benefit in those layers.

Spatial indexing should only be added to another layer if profiling identifies its viewport lookup as a meaningful bottleneck.

For the architectural decision, see Frontend spatial indexing.