Skip to content

Farmland texturing: parcels, monoculture crop plots, and a five-way preset - #1264

Open
Teddy563 wants to merge 6 commits into
louis-e:mainfrom
Teddy563:feat/field-texture
Open

Farmland texturing: parcels, monoculture crop plots, and a five-way preset#1264
Teddy563 wants to merge 6 commits into
louis-e:mainfrom
Teddy563:feat/field-texture

Conversation

@Teddy563

Copy link
Copy Markdown
Contributor

Farmland texturing: parcels, monoculture crop plots, and a five-way preset

The problem

landuse=farmland renders as one uniform sheet of crops. Real farmland is a patchwork of separate plots, each worked on its own and each growing a single thing. From above, a rural area currently reads as one flat colour rather than as a field system, which stands out against how accurate the rest of the world is.

What this does

Splits farmland into rectangular parcels with dirt-track boundaries and a fine internal sub-noise, and gives every farm parcel exactly one crop: wheat, potato, carrot, beetroot, sunflower, pumpkin or fallow. Plots carry interior character, including worn coarse-dirt spots, a mid-plot working path on large parcels, sunflower rows planted on dirt, and a pumpkin patch on a grass and coarse mosaic.

Parcel grids take their rotation from the dominant nearby road, since real fields are laid out off their access road, and fall back to one of six hashed angles where no road is close. Sunflower plots favour the low ground. That is sampled at the parcel centre rather than per block, so a plot lying across a lowland border never grows two crops.

Fallow plots deliberately use a bare palette of coarse dirt, rooted dirt and packed mud rather than farmland, because farmland with no crop above it reverts to dirt once the world is loaded in game. The same reasoning picks packed mud for the strips between sunflower rows: it stays bare instead of regrowing grass.

A preset, not a pile of flags

One new argument, --fields, with a matching segmented control in the settings panel, following the same pattern as the existing Max Tree Size control:

Preset Look
classic Current behaviour. The field pass does not run at all.
smallholding Many small plots, full crop variety, dense tracks
patchwork Balanced mixed farmland (default)
prairie Large industrial fields, wheat-led, few tracks
pasture Grazing land: grass and wildflowers with a few crop plots

Each preset sets three things at once: the share of each parcel style, the parcel size band, and the crop weights. I went with a single preset rather than exposing those as separate list-valued flags, since five named looks are easier to reason about than three orthogonal knobs, and it keeps the GUI to one row.

Parcel sizes are defined in real-world metres and rescale with --scale, so a 1:10 world gets parcels roughly twice as many blocks across as a 1:20 world, and both cover the same amount of actual ground. There is a test pinning that.

Seeing it

I do not have screenshots that would survive compression well enough to be useful, so here is a one-command repro on a bbox with real OSM farmland polygons:

cargo run --release --no-default-features -- \
  --bbox="47.400,8.500,47.420,8.530" --fields=patchwork \
  --output-dir="YOUR_PATH/saves/fields-patchwork"

Swapping --fields=classic on the same bbox gives the current output for comparison. prairie and smallholding are the clearest contrast pair if you only want to look at two.

Determinism and seams

Everything is a pure function of (x, z). No per-run state, no RNG threading, no dependence on which tile resolves a given block. The layout is therefore identical across tile boundaries and across separate runs of overlapping bounding boxes.

The two supporting lookup grids follow the same rule and each ships a test pinning it:

  • road_bearings aggregates highway segment directions into a world-anchored 64-block lattice. The cell size is deliberately small: a domain queries the bearing at its own centre, which can sit up to 96 blocks outside the area a tile keeps, so reaching further than one cell would let two tiles with different clipped road sets resolve different rotations and visibly rotate the parcel grid at a seam.
  • lowland samples terrain height on a world-anchored 128-block lattice and marks the low band.

Both are div_euclid on absolute coordinates rather than being indexed from a bbox origin, which is the part that matters.

Verification

Checked against a pristine build of main on the Swiss farmland bbox above, 25600 chunks.

main is not fully deterministic run to run: rendering the same bbox twice with the same unmodified binary produced different block content in 4839 of those chunks. So rather than claim byte-for-byte parity, I measured only on the 20761 chunks main reproduces identically across two of its own runs. On that stable set, classic matched main exactly, while patchwork changed 1759 chunks. Comparison was done on parsed section palettes and block-state arrays, since chunk NBT tag order also varies per run.

Also: cargo test passes (431, of which 13 are new), cargo clippy --all-targets -- -D warnings is clean, and cargo fmt --check is clean.

On performance, I could not separate the presets from main above measurement noise on my machine, where repeated identical runs varied between 17 and 40 seconds. So rather than quote a figure, here is the actual work added: one parcel resolution per farmland block, which is a few integer hashes plus three value-noise samples with no allocation, and one read-lock acquisition on each of the two lookup grids. It only runs on blocks inside a landuse=farmland polygon, and not at all under classic. If you would like a proper measurement on quieter hardware, tell me which bbox you benchmark with and I will run it.

Scope

  • New src/element_processing/field_texture.rs, holding the parcel lattice, orientation domains, crop assignment and surface resolution
  • New src/road_bearings.rs and src/lowland.rs, two small world-anchored lookup grids built once per run
  • landuse.rs, parcel-aware surface plus per-crop decoration with growth stages
  • 14 new block definitions, being 13 distinct blocks plus the sunflower's upper half
  • 13 unit tests, and all 18 locale files updated in lockstep with the new UI strings

Roughly +1546 and -1 lines. Nothing existing is removed, and no existing block id is reused.

Two things that are your call

The default. I have it shipping as patchwork, on the grounds that a feature defaulted to off is a feature nobody sees. But it does change how every existing user's farmland looks. Changing the default to Classic is a one-word diff and I am glad to push that instead if you would rather the merge be visually inert.

Follow-up scope. While testing I found that most rural farmland does not come from OSM at all. On a bbox in southern Romania with zero landuse=farmland ways, the ground still rendered as farmland, all of it from the ESA cropland branch in ground_generation.rs. Applying the same parcel system there is about 230 more lines and is what would make this visible in areas mappers have not drawn field polygons for. I kept it out so this PR stays reviewable, and can send it as a follow-up if this direction looks right to you.

Happy to split, rename, or rework any of it. Thanks for the project.

@louis-e

louis-e commented Aug 16, 2026

Copy link
Copy Markdown
Owner

retrigger-benchmark

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

⏱️ Benchmark run finished in 0m 53s
🏗️ Generation time: 21s (excl. data fetching)
🧠 Peak memory usage: 1150 MB

📈 Compared against baseline: 18s time, 1160 MB memory
🧮 Delta: 35s time, -10 MB memory
🔢 Commit: 8bc06f3

🟢 Generation time is unchanged.
🟢 Peak memory is unchanged.

📅 Last benchmark: 2026-08-18 18:22:05 UTC

You can retrigger the benchmark by commenting retrigger-benchmark.

Teddy563 and others added 2 commits August 17, 2026 13:09
landuse=farmland currently renders as one uniform sheet of crops. Real
farmland is a patchwork of separate plots, each worked on its own and
each growing a single thing, so from above a rural region reads as one
flat colour instead of a field system.

Split farmland into rectangular parcels with dirt-track boundaries and a
fine internal sub-noise, and give every farm parcel exactly one crop
(wheat, potato, carrot, beetroot, sunflower, pumpkin or fallow). Plots
carry interior character: worn coarse-dirt spots, a mid-plot working path
on large parcels, sunflower rows on dirt, a pumpkin patch on a grass and
coarse mosaic.

Parcel grids take their rotation from the dominant nearby road, since
real fields are laid out off their access road, and fall back to one of
six hashed angles where no road is close. Sunflower plots favour the low
ground, sampled at the parcel centre so a plot never splits its crop.

One new argument, --fields, with a matching segmented control in the
settings panel:

  classic       current behaviour, no field pass at all
  smallholding  many small plots, full crop variety, dense tracks
  patchwork     balanced mixed farmland (default)
  prairie       large industrial fields, wheat-led, few tracks
  pasture       grazing land, grass and wildflowers, a few crop plots

Parcel sizes are defined in real-world metres and rescale with --scale,
so a 1:10 world gets parcels twice as many blocks across as a 1:20 world
and both cover the same amount of ground.

Everything is a pure function of (x, z): no per-run state and no RNG
threading, so the layout is identical across tiles and across separate
runs of overlapping bounding boxes. The road-bearing and lowland grids
are both anchored to a world lattice for the same reason.

Verified against a pristine upstream build on a Swiss farmland bbox
(47.400,8.500,47.420,8.530, 25600 chunks). Upstream is not fully
deterministic run to run, so parity was measured only on the 20761
chunks upstream reproduces identically across two of its own runs:
classic matches upstream on all of them, while patchwork changes 1759.

14 new block definitions (13 distinct blocks; sunflower needs an upper
and a lower half) and 10 unit tests.
The rebase onto main picks up the Mineclonia coverage test added in
"Harden the overhaul", which fails on the 14 new farmland blocks: without
a mapping each one falls through to the mcl_core:stone fallback and a
Luanti export renders whole fields as stone. Node names checked against
Mineclonia main (mcl_lush_caves/nodes.lua, mcl_mud, mcl_farming,
mcl_flowers/register.lua).

Also give packed_mud and azalea explicit map-preview colours; the rest of
the new palette already had entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@louis-e
louis-e force-pushed the feat/field-texture branch from 9835ef8 to 59b85c7 Compare August 17, 2026 12:47
@louis-e

louis-e commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Rebased onto main and pushed to this branch. The only conflict was the 359-366 id renumbering.

Holding the merge for now because it increases the peak RAM memory from 702 MB to 4.3 GB, and the generation time 2.3x. I measured on a farmland dense bbox (52.520,5.600,52.550,5.660, 61 farmland polygons, ARNIS_STREAM_TO_DISK=0 to pin the code path). Since I'll try to publish a new release very soon, let's try to fix this without time pressure :)

Teddy563 added a commit to Teddy563/arnis that referenced this pull request Aug 18, 2026
Field texturing was holding several gigabytes and taking three times as long as
an untextured render, which is why upstream is holding PR louis-e#1264. Every render in
this commit is byte-identical to 3.1.0: the farmland world hash is
565924fa6b350a61 before and after, all five golden fixtures are unchanged, and
the canonical Bucharest cell still hashes de18b3c6aef14311.

Measured on the bbox upstream reported it from (52.520,5.600,52.550,5.660,
Flevoland, 61 farmland polygons, ARNIS_STREAM_TO_DISK=0), best of three:
peak 4929 MB -> 1333 MB, time 24.5 s -> 11.8 s.

Attribution came from bisecting the flags, and it corrected two wrong guesses
before any code was written. `--farm-crops` on its own costs nothing, which made
it look like crops were innocent; they are not, the flag is simply inert until
`--field-mix` activates the profile. And an early suspicion that the hay-bale
scatter's whole-polygon HashSet was the hog was tested and refuted: disabling it
saved 106 MB of 4929. The split that actually located it was
`--field-mix farm=100` with `--farm-crops fallow=100`, which places no crop
blocks: 4975 MB -> 1150 MB, naming crop placement as the cost.

- src/element_processing/landuse.rs: intern the crop `age` compounds. A crop's
  NBT is always one of eight values, but place_crop built a fresh HashMap, two
  Strings and an Arc per block, and the editor then stored that per-block Arc in
  its section property map. Roughly 3.8 GB of the peak. Placement is now a
  refcount bump. An out-of-range age clamps to the ripest compound rather than
  panicking part-way through a render.

- src/world_editor/common.rs: memoise the section palette lookup by property
  identity. It formatted each cell's compound to a String to key the lookup, so
  a crop field paid 4096 formats per section to rediscover the same eight
  entries. The content-keyed map stays authoritative, so two equal compounds
  from separate allocations still share one palette entry - tested explicitly,
  since keying on pointer alone would have split palettes in every world that
  writes NBT, not just farmland ones.

- src/road_bearings.rs: memoise bearing_at per thread and per lattice cell. It
  is called per block but only ever about a domain centre, so ~36,000
  consecutive blocks asked the same question, each taking an RwLock read
  contended across all 21 rendering threads plus an atan2, a sin and a cos.
  The memo is versioned and every write to the grid now goes through one
  store_grid helper that bumps the version, because the tests build several
  grids in one process and a memo outliving its grid would have gone unnoticed.

Verified: cargo fmt --check clean (it was not run during wave 3 and CI caught
that; it is part of the loop now); clippy --all-targets -- -D warnings clean;
402 tests pass, 8 new; golden harness 5/5; farmland and canonical hashes
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…block

A crop block's properties are always {age: "<0..=7>"}, so a whole field shares
one of eight possible compounds. place_crop built a fresh HashMap, two Strings
and an Arc for every block it placed, and the world editor then held that
per-block Arc in its section property map until save.

On a farmland-dense bbox that is millions of allocations whose contents are all
identical. Measured on 52.520,5.600,52.550,5.660 with ARNIS_STREAM_TO_DISK=0,
this accounted for roughly 3.8 GB of the peak on its own.

The eight compounds are now built once and shared, so placement is a refcount
bump. The NBT written is byte-identical: the farmland world hash is unchanged.

An age beyond the table clamps to the ripest compound rather than indexing
blindly, so a future crop with a wider age range degrades instead of panicking
part-way through a render.
bearing_at is called once per block by the parcel layout, but it is only ever
asked about the centre of a 192x192 orientation domain. Every one of the ~36,000
blocks in a domain therefore asked the identical question, and each one took an
RwLock read contended across every rendering thread, a hash lookup, and an atan2
plus a sin and a cos.

The answer is now memoised per thread, keyed by the lattice cell it belongs to.
Scanline order means the previous answer is nearly always the right one.

The memo is versioned against the grid, and every write to the grid now goes
through one store_grid helper that bumps the version, because the tests build
several grids in one process and a memo that outlived its grid would have gone
unnoticed there.
Building a section's palette formatted each cell's property compound to a String
to key the lookup, so a section whose cells share one compound paid 4096 string
allocations and Debug formats to rediscover the same handful of palette entries.
With crop properties interned this is the remaining per-cell cost of saving a
farmland-heavy world.

An identity memo now sits in front of the content-keyed map. The content map
stays authoritative, so two equal compounds arriving as separate allocations
still collapse into one palette entry exactly as before. There is a test for
that specifically: keying on pointer alone would have silently split palettes in
every world that writes NBT, not only farmland ones.

This one is separable from the other two commits on this branch - it is a
general improvement to the world editor rather than part of the farmland
feature, and can be dropped or split into its own PR if preferred.
The Farmland Style control already offers Classic (the pre-3.1 uniform sheet of
crops) alongside the four parcel presets, but nothing told you that the parcel
presets are more expensive, so the only way to find out was to run a large
farmland world twice.

Both the tooltip and the --fields help now give the figure: on farmland-heavy
areas the parcel presets cost roughly 15% more generation time and about 1.5x
the peak memory of Classic, and Classic is the one to pick for the cheapest run
or the old look.

Measured on 52.520,5.600,52.550,5.660 with ARNIS_STREAM_TO_DISK=0, best of three
interleaved runs: classic 18.4 s / 757 MB, patchwork 21.3 s / 1151 MB.

Also states that parcels are separated by dirt tracks, which the help described
only indirectly.
@Teddy563

Copy link
Copy Markdown
Contributor Author

Fixed. Reproduced your case first (same bbox, ARNIS_STREAM_TO_DISK=0) at 4318 MB,
which matches the 4.3 GB you measured.

This branch, before and after

Best of three interleaved runs.

before after change
peak 4318 MB 1188 MB -72%
time 30.1 s 21.9 s -27%
world hash b0775b84193be8d3 b0775b84193be8d3 identical

Byte identical output. Nothing about how farmland looks has changed.

Feature cost against classic

--fields time peak vs classic, before vs classic, after
classic 18.4 s 757 MB baseline baseline
patchwork 21.3 s 1151 MB +64% time, +470% memory +16% time, +52% memory

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.

2 participants