Ecosystems and Grass¶
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.
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:
heightsA
PNMImage(the grey level timesz_scaleplusz_offset; use a 16-bit image for smooth slopes), aTexture(its RAM image) or a flat float buffer withx_sizeandy_size(metres).moisture0 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.
exclusion1 where nothing grows: roads, yards, a runway. Partial values thin the plants and the grass out.
paint_road()paints a polyline into it,widthmetres of road fading over averge.water1 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 |
|---|---|
|
the height (m) it reaches on an ideal site |
|
crown radius over height |
|
the share of its remaining height gained a year at full vigour |
|
the age (years) by which senescence takes it |
|
the age at which it starts to seed |
|
0 needs full sun (pioneers) .. 1 thrives in deep shade |
|
the moisture (0 dry .. 1 wet) it does best at |
|
how far from that it still does well |
|
the steepest ground (degrees) it establishes on |
|
the altitudes (m) it establishes between |
|
how far (m) its seeds fall |
|
seedlings a healthy mature plant sends out a year |
|
seed rain from outside: establishments per hectare a year on a good site |
|
how much light its crown stops |
|
0 even seed rain .. 1 strongly patchy stands |
|
the size (m) of those patches |
|
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 (disturbanceevents 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.
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.
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 footpath and a pair of wheel tracks stamped into the grass.¶
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.
--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.