Terrain Scatter

showcase/terrain-scatter/

A Geometry Nodes hill tile with an embedded scatter of glacier-worn boulders of varied size through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

Rendered headless by the showcase piece itself. Select it to enlarge.

witnesses Recomputed: 2986 tris, two materials with 654 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.800x1.800x0.609 m, grounded zmin, 9 stones of >=54 faces each sealed into the ground in 8/8 sectors, footprint ratio 9.8, rim tilt step 33.5 deg, no stone pairs overlapping, LOD ratios in band, convex collider 54 tris, hygiene zero, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --poke-rock 17; --float-rocks 18; --box-rocks 19; --pile-rocks 20; --perch-rocks 21; --uniform-rocks 22; --sharp-rim 23.

category Terrain

tags mesh export showcase

blender --background --python showcase/terrain-scatter/terrain_scatter.py --

A showcase piece, not an example. Geometry Nodes sine-hill Mesh Grid with an Index-jittered Instance-on-Points scatter, realized cubes replaced by closed-form, glacier-worn boulders seated on sampled dirt Z, then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

GN still builds the hill, with two closed-form micro-relief terms on top of the sine, and the instance grid. After realize+slabify, whose rim is bevelled into the walls, cube islands are deleted and each centroid is reseated as a sized stone, sunk until the ground closes around it on every side, then clamped above the slab floor.

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

Composes skills geometry-nodes-python, mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package). Hygiene combinatorics match examples/mesh-hygiene-audit (copied, not imported).

Intended size: 1.80 m square hill tile, ~0.16 m sine amplitude, nine embedded stones from cobble to boulder; outer AABB 1.800 × 1.800 × 0.609 m.

Hero. The render path dresses the tile as a hand-made meadow diorama. None of it is part of the asset, its budgets or its export; the dressing is drawn with a fixed seed, parented to the tile, and the asset sheet judges TerrainLow alone.

Why the stones are worn boulders now#

The third pass found the stones read as placeholders at 100%: faceted grey icosphere blobs, one with a pure-black flat face. Glacial erratics, the boulders strewn across upland meadows, are sub-rounded to sub-angular blocks of granite or gneiss, rounded by transport in the ice with a few flat fracture and abrasion faces, lichen on their tops and moss where they meet the turf.

Budgets#

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

AxisDeclaredMeasured (4.5.11 / 5.1.2 / 5.2.1)
Base triangles2000–38002986 / 2986 / 2986
LOD1 ratio0.32–0.62 of base0.4997 / 0.4997 / 0.4997
LOD2 ratio0.10–0.35 of base0.2197 / 0.2197 / 0.2197
Materialsexactly 2 distinct, ≥24 stone faces2 slots, 654 stone
UVsin 0..1, AABB overlap ≤ 1e-5in range, overlap 0
Outer AABB(1.800, 1.800, 0.609) m ± 0.015(1.8000, 1.8008, 0.6085), zmin 0
Collider tris≤ 12054
Exportwritten, size > 0218868 / 218868 / 218860 bytes (4.5.11 / 5.1.2 / 5.2.1)

Base triangles rose from 1194 to 1758 in the first quality pass: 9-vert hill became a 21-vert grid, and bevelled cubes became subdiv-2 icospheres. Outer Z dropped from 0.655 m (crate corners swinging through the slab) to 0.552 m of seated stone on the hill.

The second pass took them from 1758 to 2398: the four-segment rim bevel adds 640. The outer Z moved from 0.584 m to 0.570 m, because the stones now sink into the ground rather than perch on it, and the declared size was re-fitted to match. The collider fell from 72 to 36 triangles because it now hulls a coarse, unbevelled 9-vertex tile (COLLIDER_GRID): the chamfer and micro-relief are visual, and on the full-resolution tile they pushed the hull to 178, over its ceiling.

The third pass took them from 2398 to 2986, and the declared band was re-fitted from 1400–2800 to 2000–3800 around the new count, the same relative width. The 80-triangle icosphere stones became quad cube-spheres: 192 triangles for each of the four boulders, 108 for each of the five cobbles (1308 for the stones, against 720). At 80 triangles a 0.4 m boulder's silhouette showed its facets, and smooth shading cannot hide a polygonal outline. The outer Z rose from 0.570 m to 0.609 m: the blocky superellipsoid keeps more of its height than the cleaved ellipsoid did, and the declared size was re-centred on the measured 0.6085 m (the ± 0.015 tolerance is unchanged). The collider rose from 36 to 54 triangles, still under its ceiling of 120, because the rounder stones put a few more vertices on the hull.

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh matched on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG: jitter, cleave planes and relaxation are all closed form or fixed iteration. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.

Hygiene#

Recomputed from the generated mesh, not asserted about the script.

AxisDeclaredMeasured (all three)
Non-manifold edges00
Loose verts / edges0 / 00 / 0
Doubles merged at 1e-500
Zero-area faces00
N-gons00
Coplanar disjoint face pairs00
Grounded: zminwithin 1e-4 of 00.0000
Stone shells99
Per-stone faces≥ 4054 (cobbles) / 96 (boulders)
Stone floor zmin≥ 0.012 m0.08305
Seat offset (zmin − dirt Z)≤ 0.02 m−0.03775
Interpenetrating stone pairs (BVH overlap)00
Sealed sectors per stone (buried vertex in each of 8)8 / 88 / 8
Stone footprint ratio (largest / smallest)≥ 3.09.757
Rim tilt step between adjacent dirt faces≤ 40°33.52°

Why the stones are cleaved, relaxed and faceted#

The committed stones were smooth-shaded, near-white ellipsoids with a gentle sine bump: eggs or marshmallows on a pale clay slab. Two pairs also interpenetrated, because the GN scatter jitters by up to 0.22 m on a 0.575 m grid, and no budget compared stone to stone.

Why the stones are sunk, sized, and the rim is rounded#

The second pass inspected the tile in clay, wire, six orthos and ground-contact close-ups, and found the tile read as a slice of cake with pebbles dropped on it.

stone_overlap_audit BVH-tests every stone pair. --pile-rocks skips the relaxation and draws the scatter in to 40% so neighbours collide: 8 pairs interpenetrate (7 with the second pass's stones), and the piece exits 20. Skipping the relaxation alone proved nothing in the second pass, because the cleaved stones missed each other unrelaxed.

Falsifiers#

Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.

FlagBudget violatedExit
--skip-decimateLOD1 ratio band9
--stray-vertloose vertex count is 015
--lift-zbounding box zmin is 016
--poke-rockstone floor zmin17
--float-rocksseat offset vs sampled dirt Z18
--box-rocksper-stone face floor19
--pile-rocksstone-to-stone interpenetration is 020
--perch-rocksevery stone sealed in 8/8 sectors (measures 4/8)21
--uniform-rocksfootprint ratio ≥ 3.0 (measures 2.317)22
--sharp-rimrim tilt step ≤ 40° (measures 88.50°)23

Run#

blender --background --python terrain_scatter.py --
blender --background --python terrain_scatter.py -- --skip-decimate
blender --background --python terrain_scatter.py -- --stray-vert
blender --background --python terrain_scatter.py -- --lift-z
blender --background --python terrain_scatter.py -- --poke-rock
blender --background --python terrain_scatter.py -- --float-rocks
blender --background --python terrain_scatter.py -- --box-rocks
blender --background --python terrain_scatter.py -- --pile-rocks
blender --background --python terrain_scatter.py -- --perch-rocks
blender --background --python terrain_scatter.py -- --uniform-rocks
blender --background --python terrain_scatter.py -- --sharp-rim
blender --background --python terrain_scatter.py -- --output terrain.png

Smoke passes no flags.

Exit codes#

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Mesh did not build / no UV layer
4Base triangle count outside range
5Material count ≠ 2 distinct slots, or stone faces missing
6UVs outside 0..1
7UV AABB overlap above tolerance
8World AABB off declared outer size
9LOD ratio band (--skip-decimate lands here)
10Framing gate (render path only)
11Collider triangle count above ceiling
12Bake did not finish or image has no data
13Export file missing or empty
14--output produced no file
15Hygiene (--stray-vert lands here)
16Grounded zmin (--lift-z)
17Stone floor poke (--poke-rock)
18Float above host (--float-rocks)
19Stone shell faces (--box-rocks)
20Stone pairs interpenetrate (--pile-rocks)
21A stone not sealed in every sector (--perch-rocks)
22Stone footprint ratio below floor (--uniform-rocks)
23Rim tilt step above ceiling (--sharp-rim)
26Gallery asset-quality violation (render path; the floors' 11 is remapped here)

Source

showcase/terrain-scatter/terrain_scatter.py View the script on GitHub →
A square meadow hillside tile: lichen-blotched granite boulders in dense grass and wildflowers, a pebbled footpath, three shrubs, and a cut side showing turf, soil and bedrock layers.