Ecosystems and Grass

This page describes part of the Python framework that comes in the acron wheel. The terrain, the ecosystem simulator and the grass layout are native and available to C++ as well: see Ecosystems and Ground Cover.

acron.ecosystem decides where the plants of a world stand and covers the ground between them with grass. An ecosystem simulator grows woods over a terrain for a century or so – plants competing for light, seeding, dying – and the plants it ends with go straight into an acron.trees.Forest. The grass is drawn blade by blade, thinned under the trees’ crowns, gone from the roads, and lit, shadowed and blown about by acron.sky.

A meadow of tall grass in front of the edge of a mixed wood of broadleaf trees and conifers.

samples/ecosystem_demo.py --view meadow: a clearing the simulation kept open, a wood’s edge 60 m away, grass lit by the low afternoon sun.

The simulator, the terrain and the grass layout are the engine’s native acron.vegetation (Ecosystems and Ground Cover); this package holds the ecology presets, the terrain helpers, the planting and the grass renderer. It uses no numpy. Without the native module it still imports – the presets and shader assembly work – and anything native raises an ImportError that names the module. The grass needs the sky’s core profile (OpenGL 4.5 on Windows and Linux, 4.1 on macOS for development; see Sky, Weather and Forests).

A wooded landscape

from acron.sky import Environment, SunShadows
from acron.trees import Forest
from acron.ecosystem import Ecosystem, Grass, Terrain

env = Environment(base)
shadows = SunShadows(env, distance=220)

ground = Terrain.procedural(size=1024, relief=70, seed=4)
ground.set_altitude(200.0, 12.0)            # z 0..70 m -> 200..1040 m
ground.paint_road([(0, 330), (380, 420), (1024, 560)], width=5)
ground.set_water_level(4.0)

eco = Ecosystem(ground, ["birch", "pine", "oak", "maple", "fir",
                         "spruce", "willow", "bush"], seed=7)
eco.simulate_area((0, 0), (1024, 1024))     # every core, about 0.5 s

forest = Forest(env, shadows=shadows)
eco.plant(forest, min_height=1.5)
grass = Grass(env, ground, ecosystem=eco, ground=ground.color_image(),
              shadows=shadows)

That is the whole of samples/ecosystem_demo.py minus the camera.

The terrain

A Terrain is a heightfield over a rectangle of the world – its south-west corner origin and its size in metres – with optional masks over the same rectangle:

heights

A PNMImage (the grey level times z_scale plus z_offset; use a 16-bit image for smooth slopes), a Texture (its RAM image) or a flat float buffer with x_size and y_size (metres).

moisture

0 dry to 1 wet. Without a mask, moisture comes from the relief: hollows and valley floors (lower than the ground 40 m round them) are wetter, ridges and steep slopes drier, the banks of open water wettest.

exclusion

1 where nothing grows: roads, yards, a runway. Partial values thin the plants and the grass out. paint_road() paints a polyline into it, width metres of road fading over a verge.

water

1 on open water. set_water_level() makes it from the heights.

Images map onto the rectangle with their first row at the north edge (+y, the top of the picture as seen from above), and samples are bilinear. World units are metres, z up.

Species’ altitude limits are written in metres above sea level, and set_altitude() maps the world’s heights onto that range (altitude = z * scale + offset) without changing the ground: a game whose hills are 70 m high can still have oaks in the valleys and spruce on the tops.

Terrain.procedural() makes rolling hills without numpy (octaves of the engine’s Perlin noise in PNMImage arithmetic), optionally with a valley down the middle. ground_color() and color_image() give the ground’s albedo (lush or dry grass, earth and rock on slopes, the roads, a lake’s bed) and make_mesh() a vertex-coloured mesh for lit_basic; a game with its own terrain uses its own.

Species ecology

What the simulator knows of a species is an Ecology: pure data, read from acron/ecosystem/presets/ecology.json, which has an entry for every shipped tree species (oak, maple, birch, aspen, ash, pine, fir, spruce, willow, palm and bush), loosely after European silvics. The values are a starting point: a game tunes them or writes its own.

Key

Meaning

max_height

the height (m) it reaches on an ideal site

crown_ratio

crown radius over height

growth_rate

the share of its remaining height gained a year at full vigour

max_age

the age (years) by which senescence takes it

maturity_age

the age at which it starts to seed

shade_tolerance

0 needs full sun (pioneers) .. 1 thrives in deep shade

moisture_pref

the moisture (0 dry .. 1 wet) it does best at

moisture_width

how far from that it still does well

slope_max

the steepest ground (degrees) it establishes on

altitude_min/max

the altitudes (m) it establishes between

seed_radius

how far (m) its seeds fall

seed_rate

seedlings a healthy mature plant sends out a year

density

seed rain from outside: establishments per hectare a year on a good site

crown_opacity

how much light its crown stops

patchiness

0 even seed rain .. 1 strongly patchy stands

patch_scale

the size (m) of those patches

understory

a shrub-layer plant (default: under 5 m tall)

from acron.ecosystem import Ecology

birch = Ecology.load("birch")
hardy = birch.with_values(altitude_max=2100, shade_tolerance=0.2)
print(hardy.describe())
alder = Ecology.from_dict({"name": "alder", "max_height": 20,
                           "moisture_pref": 0.85, "moisture_width": 0.12})

Values are checked when they load (Ecology.schema() lists every key with its default, range and meaning). The name is what Ecosystem.plant() maps to a tree species.

How the woods grow

The simulator follows Deussen et al. (1998) – plants competing for room and light, succession, seeding – and Makowski et al. (2019) – plants shading their neighbours by their crowns, shade tolerance, self-thinning, seed dispersal. The world is a lattice of 4 m cells, each holding at most one tree and one understory plant, jittered inside the cell. Every five years, for a hundred years by default, every cell at once:

  • Light. Each plant gets the open sky’s light minus what the crowns of the plants around it stop: a crown covers the cells within its radius of its stem, by its opacity; a clearly taller crown shades fully, one of the same height crowds a little, a shorter one not at all. A closed canopy leaves a few per cent of the light on the forest floor.

  • Vigour and growth. The light, through the species’ shade response (a pioneer starves below about 70 % of the open sky, a tolerant fir grows on slowly at 5 %), times the site’s suitability for the species: moisture against its preference, slope, altitude, the masks. Health follows vigour; height grows towards the size the site allows at a rate set by vigour.

  • Mortality. A small background rate, stress (a suppressed plant dies within a few steps, so dense stands thin themselves), senescence towards max_age, and windthrow: patches of tall trees knocked over (disturbance events per km2 a year) that open gaps.

  • Seeding. Mature plants seed the empty cells within their seed radius; seed rain from outside lands everywhere, patchy per species; a seed takes if the site and the light on the ground suit its species.

The land starts as a mosaic: initial_cover of the cells hold a plant of any age, in stands about stand_size metres across that mostly share a species (0 starts from bare land, and the years tell a succession from pioneers on). clearings keeps a share of the land open in patches – grazing, mowing, a game’s meadows: trees do not establish there, shrubs hardly, and the old trees left standing die out into meadows with lone trees and shrubby edges.

Out of that come woods with clusters and clearings, pioneers giving way to shade-tolerant species under them, brighter edges where the side light lets the shrubs in, and species sorted by altitude and moisture: willows by the water, oak and ash in the valleys, maple on the slopes, fir and spruce on the hills, pine on the dry ridges.

A map of a square kilometre seen from above: crowns as coloured discs, patches of single colours, open green clearings, two lakes.

The sample’s square kilometre after 120 years, from above (crowns at their radius; birch pale yellow, aspen dark green, pine olive, oak purple, ash blue, maple orange, fir light green, spruce red, willow lavender, shrubs dark dots; the ground darker where less light reaches it). Spruce and fir hold the high ground at the bottom right.

The parameters are the constructor’s: years (100), step (5), cell_size (4 m), tile_size (256 m), disturbance (1.5), clearings (0.1 of the land, in 90 m patches), initial_cover (0.3), stand_size (40 m) and min_height (0.5 m: smaller seedlings are not reported).

Tiles and streaming

The simulation runs in square tiles, and a tile comes out byte-identical whichever other tiles are simulated, in any order, on any thread – so a streaming world simulates the tiles round the player as it needs them, and the tiles meet without a seam. Every random draw is a hash of world cell indices, the step and the seed, and a cell’s next state reads only the cells within a few cells of it (the widest crown or seed radius, reach). A tile is simulated over itself plus a halo of (steps + 1) x reach cells that shrinks by the reach each step, so its own cells come out exactly as in a simulation of the whole world. The halo costs time (about 250 m round a 256 m tile with the defaults); bigger tiles or fewer steps make it cheaper.

eco.simulate_tiles([(0, 0), (1, 0)], threads=0)   # every core
tile = eco.tile(2, 0)                              # simulated if new
eco.forget(0, 0)                                   # the player moved on
eco.ground_light(130.0, 40.0)                      # under the crowns

The kept tiles’ plants and ground light stay with the ecosystem: the grass reads the light, plants() and plant() the plants. eco.native is the engine’s acron.vegetation.Ecosystem.

Measured on an Apple M4 Pro, ten species, 100 years: a square kilometre in 16 tiles takes 0.5 s on every core; one 256 m tile 0.2 s on one core; 2.4 km2 0.55 s on every core (4.5 s on one).

Planting a forest

Ecosystem.plant() puts the kept tiles’ plants into an acron.trees.Forest: each species under its own name, or under species_map[name] (a Species or a preset name; None leaves it out), at its simulated height times height_scale, tinted by its health (the leaves of a struggling plant yellow). min_height leaves out the seedlings and bounds (x0, y0, x1, y1) plants one area.

plants() gives the same data as flat array('f') per species name – positions (x, y, z: the ground under each stem), heights, ages, healths and tints – for a game that places something else there: rocks under old trees, a mushroom where it is dark, a renderer of its own.

Grass

A Grass covers the terrain with blades round the camera, out to 90 m by default:

grass = Grass(env, ground, ecosystem=eco, density=64,
              height=0.5, ground=ground.color_image())
grass.set_season(dry=0.3)                 # late summer
grass.set_wind_response(1.2)              # an exposed meadow

Where the blades stand is native (GrassField): a world-anchored jittered grid of density blades per square metre, each kept with the probability the masks give at its spot – the roads and the water clear it, slopes over 28 degrees thin it to nothing at 48, dry ground thins it, an optional density_image scales it, and the light under the ecosystem’s crowns suppresses it (canopy_response). Blades gather in clumps of a shared height, facing and tone, as real grass does. A blade depends only on its spot and the seed, so the field is seamless and the same every time. Building the blades of a cell takes about 0.12 s per million blades.

Drawing is hardware instancing on the core profile (4.1 and 4.5): each 16 m cell is one node with one Geom, a strip of 15, 7 or 3 vertices by level of detail, drawn as many times as the cell has blades to show; the blades themselves are a texture the vertex shader reads with gl_InstanceID. Each blade is a Bezier curve (Jahrmann and Wimmer 2017) whose tip the wind pushes over, with gust waves rolling across the field along the wind (FRAME_WIND); it tapers to a point, turns towards the camera when seen edge on, has rounded normals and dark roots (after Ghost of Tsushima’s grass). Far cells draw fewer blades, the shader shrinks the rest away and widens the survivors, and the last metres fade into the ground’s colour (ground: an RGB triple or an image over the terrain, such as Terrain.color_image()), so the edge of the grass does not show.

Light is the sky’s, as for every lit surface: the key light through the air and its shadows (the trees’ shadows lie on the grass), the sky’s irradiance or the environment probe’s, light through the blades when the sun is behind them, a little sheen, aerial perspective and fog. Under acron.pipeline the grass writes its normals and motion vectors (the blade a frame ago is the same blade at the previous frame’s time and wind) and takes the ambient occlusion.

Shadows. Given the sun’s cascades (shadows=), the nearest level of detail – the blades within lod[0] (10 m) – casts into them: blades shade the ground between them and each other at the roots, in long stripes when the sun is low. The cascades draw them with a depth-only program of their own (grass.vert with GRASS_SHADOW: the same blades, wind, thinning and trampling), keyed on a tag state the forest’s casters use too. shadow_lods sets how many levels cast: 1 by default, 0 none, 2 adds the second level (to 28 m: about five times the vertices for little on the screen – farther blades are thinner than a cascade texel and only add noise). On a 1080p frame the default costs the cascades a few hundred thousand vertices at a grass density of 64 per square metre.

Seasons are a frame value: set_season() takes dry (green to straw) and snow (white upper blades, pressed lower). Changing them, or the wind, touches no render state.

Six views of the same wood's edge: spring, summer, a dry summer, autumn, winter with frosted grass and bare trees, and a summer evening.

Spring, summer, a dry summer, autumn, winter and a summer evening at the same edge (--season, --hour): Forest.set_season for the trees, Grass.set_season for the grass.

Trampling. follow_trample() keeps a trample map centred on a node (the player, a vehicle) and presses the grass flat round it as it moves; stamp() presses a disc anywhere (a wheel, an explosion). Trodden grass lies along the way it was pushed, shows its paler side, and stands up again over recovery seconds. The map (native GrassTrample) follows its centre in whole texels, so what was stamped stays where it was in the world.

A grass field with a winding footpath and two straight wheel tracks pressed into it.

A footpath and a pair of wheel tracks stamped into the grass.

A dirt road through a wood, with grass up to its verges.

The road painted into the exclusion mask: no trees and no grass on it.

stats() reports the cells and blades drawn (per level), the nodes and the time spent laying out and uploading cells.

Performance

On an Apple M4 Pro under OpenGL 4.1 at 1280 x 720 with 4x MSAA, the grass costs about 2.4 ms per million blades drawn (mostly the far level’s three-vertex blades), measured as frame time with and without it. The defaults draw about half a million blades from eye height. The Windows and Linux targets have not been measured yet.

The sample

samples/ecosystem_demo.py builds a square kilometre: a procedural terrain with a valley, a lake and a road, 120 years of ten species, the forest planted from them, grass, the sky and its shadows. --view picks meadow, edge, forest, road or aerial (spots searched in the simulated woods), --season summer, spring, dry, autumn or winter; --screenshot renders a still for scripts. In the window, 1-5 change the season, W the wind, T tramples a path where the camera walks, [ and ] move the clock.

An aerial view over a hilly wood with open clearings and lone trees in them.

--view aerial: the woods, their clearings and their edges.

Known limitations

  • One tree and one understory plant per 4 m cell: no thickets of saplings finer than that, and no herb layer besides the grass (ferns, flowers).

  • Seeds travel at most a few cells a step; long-distance dispersal is the seed rain from outside.

  • Terrain and masks are one rectangle; a world larger than one heightfield supplies a terrain per region (with a margin as wide as the halo).

  • The transcendental functions (exp, pow) come from the platform’s maths library, so a tile is byte-identical on one platform, run to run and tile to tile; across platforms the woods may differ in detail.

  • Only the near grass casts shadows (shadow_lods), and the grass has no levels finer than the cell: a cell switches its blades’ vertex count at once (the shape barely changes).

  • The terrain mesh does not take snow; only the grass does.

References

  • Oliver Deussen, Pat Hanrahan, Bernd Lintermann, Radomír Měch, Matt Pharr and Przemyslaw Prusinkiewicz, “Realistic Modeling and Rendering of Plant Ecosystems”, SIGGRAPH 1998.

  • Miłosz Makowski, Torsten Hädrich, Jan Scheffczyk, Dominik L. Michels, Sören Pirk and Wojtek Pałubicki, “Synthetic Silviculture: Multi-scale Modeling of Plant Ecosystems”, SIGGRAPH 2019.

  • Klemens Jahrmann and Michael Wimmer, “Responsive Real-Time Grass Rendering for General 3D Scenes”, I3D 2017.

  • Eric Wohllaib, “Procedural Grass in Ghost of Tsushima”, GDC 2021.