Engine · guide
Painting a terrain
The four paint tools, the layer weights that sum to one, the reusable ground a layer names, and the undo record that has to hold all of them.
Edit this page on GitHubDocuments
What it is#
TerrainPaint is the four tools an artist paints ground with: paint, smooth, flatten and noise, each
over one target layer. TerrainLayerDescription is what a .vxlayer holds — the textures, the tiling
in metres, the blend mode, the physics material. TerrainWeightStroke is the undo record a drag
builds, and TerrainWeightmap reads and writes one layer's coverage as a grayscale image.
What it is for#
Deciding where the grass stops and the rock starts. Everything here writes
TerrainWeights, so the sum-to-one invariant is maintained by construction rather than by each tool
remembering to.
You do not want these for a ground that never changes — one material on the whole terrain needs no layers at all, and the generated splat material compiles a loop of one.
Using it#
var stroke = new TerrainWeightStroke(terrain);stroke.Record(brush, stamp);TerrainPaint.Paint(terrain, layer: 1, brush, stamp, amount: 64);⚠ Every kernel here goes through TerrainWeights.Paint rather than writing a channel. That method
is where the invariant lives — raise one layer and the rest come down in proportion, by largest
remainder — and a kernel that wrote the byte itself would be a second implementation of the rule,
disagreeing with the first by a unit or two per sample. Which is exactly the drift Verify reports
and nobody can explain.
⚠ **The undo record holds *every* layer at every sample, not the one that was painted. Painting one lowers all the others, so restoring a single channel leaves the rest holding what the redistribution gave them — the sum comes out above 255 at every touched sample, and the drift surfaces three operations later.
⚠ And restoring it needs TerrainWeights.Restore, not six SetWeight calls.** Setting six layers
back one at a time redistributes six times, so the first five are moved again by the sixth and the
undo lands *near* where the stroke started rather than on it.
⚠ Smooth reads a snapshot. Worse here than for heights: a paint write moves every layer at the sample, so smoothing in place would average against weights the redistribution had already changed twice.
The layer asset#
⚠ Textures are named, not handled. The kernel has no device and no asset database, and a
.vxlayer is read by a world that has not run yet — the same choice TerrainComponent.Terrain makes.
⚠ The tiling is in metres of world, not repeats per terrain. Repeats-per-terrain is the spelling that makes a layer stop being reusable, and getting it wrong is the mistake in Unreal's own quick-start guide's troubleshooting section.
⚠ "Blend mode" means two different things. TerrainBlend is a *storage* question — whether the
layer takes part in the sum-to-one budget, which is the snow case — and TerrainLayerBlend is a
*shading* one: weight, height or alpha. A layer is routinely weight-blended in storage and
height-blended in shading.
⚠ A height blend with no surface texture is refused rather than degraded. There is nowhere to read the height from, so it would silently become a weight blend — the class of failure reported as "the height blending does not work".
Import and export#
TerrainWeightmap.Import(terrain, layer: 2, mask, width: 1024, height: 1024);⚠ An import restores the invariant rather than trusting the file. A mask painted elsewhere has no idea the other layers exist, so it goes through the same redistribution painting it by hand would have — and it resamples edge to edge, because a terrain of four 128-sample tiles is 509 across and image editors make 512s.
Examples#
The whole of a paint drag, which is what the editor does:
var stroke = new TerrainWeightStroke(terrain);var path = new BrushStroke(brush);var stamps = new List<BrushStamp>();path.MoveTo(where, stamps);foreach (var stamp in stamps) { stroke.Record(brush, stamp); TerrainPaint.Smooth(terrain, layer, brush, stamp);}A layer that lies over the others rather than taking from them:
var snow = terrain.Weights.AddLayer( TerrainLayerDescription.Of("Snow") with { Albedo = "Textures/snow", TilingMetres = 6f }, TerrainBlend.NonWeight);⚠ A non-weight-blended layer is excluded from the sum by design, which is what lets snow cover whatever is underneath instead of replacing it — and it is why the material normalises by the total it actually accumulated rather than dividing by one.
Asking what ground a place is, which is what a footstep does:
var ground = terrain.Weights.GroundAt(x, z);See also#
- The terrain heightfield — where the weights live, and the invariant itself.
- Terrain brushes — the stamp and the falloff all four tools share.
- Drawing a terrain — the generated material these feed.
- docs/plan/31 § D5 —
- why the weights sum to one, and why the layer that broke it is named.