🧠 Brains complement: IF an
unreal-engine-skills-managertool (external MCP) exists in this session, call it with{action: "load", skill: "landscape-and-foliage"}for UE domain knowledge on this topic — correct APIs, architecture, best practices — and treat it as the rubric for any review / "best practices" question. If no such tool is available (e.g. running under Claude Code or Codex without that MCP), skip this line entirely and proceed with this skill alone — do NOT attempt the call.
Landscape Terrain Skill
Sub-docs available
This skill is split into the lean index (you are reading it) plus sibling sub-docs. Skills
load via the engine's AgentSkillToolset (ListSkills / GetSkills) — request
landscape/<section> through GetSkills, or read the sibling *.md file (it sits next to
this SKILL.md on disk) directly:
| Sub-doc | When to load |
|---|---|
return-types.md | Exact property names of LandscapeCreateResult, LandscapeInfo_Custom, LandscapeLayerInfo_Custom, LandscapeSplinePointInfo, LandscapeSplineSegmentInfo, LandscapeSplineMeshEntryInfo, plus the create_landscape signature. |
workflows-creation.md | Creating landscapes, getting info, importing/exporting heightmaps, recreating splines, copying terrain/paint layers between landscapes, calculating side-by-side offsets, reading height data in a region. |
workflows-editing.md | Sculpting, direct region editing, procedural noise, painting layers, setting material, querying terrain, LandscapeGrassType creation/cloning, full landscape cloning, existence checks, heightmap sizing utilities. |
intelligent-sculpting.md | v3 semantic features (mountain/valley/ridge/plateau/crater/terraces/erosion/blend), terrain analysis (slope/normal/flat areas), mesh projection, batch line traces, complete v3 workflow example. |
Critical Rules
Valid Quad Sizes
QuadsPerSection must be one of: 7, 15, 31, 63, 127, 255 SectionsPerComponent must be 1 or 2
Common setup: QuadsPerSection=63, SectionsPerComponent=1, ComponentCount=8x8
⚠️ Performance: Landscape Creation Timeout
Creating landscapes with many components is SLOW. Python execution has a 30-second timeout.
Safe configurations (under 30s):
ComponentCount=4x4(253x253 resolution) — safe for throwaway/test terrainsComponentCount=8x8(505x505 resolution) — can exceed 30s in a populated level (measured 67s in a level that already had another landscape); safe only in near-empty levels
Will TIMEOUT (avoid):
ComponentCount=16x16or larger in a populated levelComponentCount=36x36or larger — too many components, takes minutesComponentCount=72x72— definitely exceeds timeout
⚠️ A timeout does NOT cancel the operation. PYTHON_EXECUTION_TIMEOUT means the tool stopped waiting — the create keeps running on the game thread and usually completes. Never blindly retry: you'd stack a second landscape with the same label (then delete_landscape removes only the first match and landscape_exists stays true, which looks like a delete failure). After a timeout, check landscape_exists(label) first. create_landscape also refuses to create a landscape whose label already exists.
⚠️ Heightmap Import: Resolution MUST Exactly Match
import_heightmap() requires the heightmap file resolution to exactly match the landscape resolution. There is NO automatic scaling. A size mismatch will fail or produce flat/corrupt terrain.
If the target landscape label does not exist, import_heightmap() now auto-creates a matching landscape at world origin with default scale (100,100,100) before importing. For real-world terrain workflows, explicitly creating the landscape first is still recommended so you can control XY/Z scale.
Landscape resolution formula: Resolution = (ComponentCount × QuadsPerSection × SectionsPerComponent) + 1
Safe performant configs (common resolutions):
| Components | Quads | Sections | Resolution | km at scale=100 | Notes |
|---|---|---|---|---|---|
| 8×8 | 63 | 1 | 505×505 | ~0.5 km | Fast, good for prototypes |
| 8×8 | 63 | 2 | 1009×1009 | ~1.0 km | Good balance of detail/speed |
| 16×16 | 63 | 1 | 1009×1009 | ~1.0 km | Same resolution, more LOD components |
| 8×8 | 127 | 1 | 1017×1017 | ~1.0 km | Larger quads, fewer components |
| 16×16 | 63 | 2 | 2017×2017 | ~2.0 km | Epic recommended for medium landscapes |
| 32×32 | 63 | 1 | 2017×2017 | ~2.0 km | Same resolution, more LOD components |
| 8×8 | 127 | 2 | 2033×2033 | ~2.0 km | High detail, fewer components |
| 16×16 | 127 | 1 | 2033×2033 | ~2.0 km | High detail, more components |
| 32×32 | 63 | 2 | 4033×4033 | ~4.0 km | Large open world |
| 32×32 | 127 | 2 | 8129×8129 | ~8.1 km | Max single tile |
⚠️ 1081×1081 is NOT a valid performant config! The only config producing 1081 is 36×36 components, 15 quads, 2 sections — which has 1296 components and will timeout. Never request or assume 1081.
Heightmap ↔ Landscape Sizing Utilities
Four Python utility functions help match heightmaps to landscapes:
| Function | Purpose |
|---|---|
get_heightmap_dimensions(file_path) | Read width/height/bitdepth of a PNG or RAW heightmap file |
resize_heightmap(source, width, height, output, interpolation) | Resample a 16-bit heightmap to new dimensions. Default interpolation="bicubic" (Catmull-Rom, smooth normals); pass "bilinear" only if you need the legacy behavior — bilinear upsampling of a low-res DEM produces blocky planar facets on flat plains |
calculate_landscape_resolution(cx, cy, quads, sections) | Compute resolution from landscape config parameters |
find_landscape_config_for_resolution(width, height) | Find the best landscape config that matches a given resolution |
✅ Recommended Workflow: Request Matching Resolution
BEST approach — request the heightmap at the exact resolution you need:
- Decide landscape config (e.g., 8×8 components, 63 quads, 2 sections)
- Calculate resolution:
(8 × 63 × 2) + 1 = 1009 - Use
terrain_data generate_heightmapwithresolution=1009andmap_size=Nmap_sizecontrols how many real-world km are captured (default 17.28)- Smaller
map_size= more detail for a specific feature (e.g., 2-5 km for a single mountain) - Larger
map_size= wider area with less per-pixel detail (e.g., 10-20 km for a region)
- Create landscape with matching config
- Import heightmap — sizes match perfectly
✅ Fallback Workflow: Resize After Download
If you already have a heightmap at a non-matching resolution:
- Check dimensions:
get_heightmap_dimensions(file_path) - Either:
- Find a landscape config that matches:
find_landscape_config_for_resolution(width, height) - Or resize the heightmap to match your landscape:
resize_heightmap(source, target_w, target_h)
- Find a landscape config that matches:
- Create landscape / import resized heightmap
World Partition landscapes (streaming proxies)
In a World Partition level, pass world_partition_grid_size (components per proxy grid cell,
e.g. 2) to create_landscape to split the landscape into ALandscapeStreamingProxy actors —
the same thing the editor's New Landscape tool does in a WP world:
pythonresult = unreal.LandscapeService.create_landscape( unreal.Vector(0, 0, 0), unreal.Rotator(0, 0, 0), unreal.Vector(100, 100, 100), sections_per_component=1, quads_per_section=63, component_count_x=8, component_count_y=8, landscape_label="WPLandscape", world_partition_grid_size=2)
- The default
world_partition_grid_size=0keeps the single monolithicALandscape(old behavior). - The call fails with an error if the level is not World Partition — the engine's grid split is a silent no-op there, so VibeUE refuses instead of pretending.
import_heightmap, sculpting, painting, and region edits are proxy-transparent: they resolve the parentALandscapeand fan updates out to every proxy sharing its landscape GUID. Nothing else in your workflow changes.
🚨 Save semantics: the dirty list lies — audit with get_last_landscape_write() + git status
On a World Partition landscape the engine's dirtying mode (LandscapeSettings.LandscapeDirtyingMode,
default In Landscape Mode And User Triggered Changes) does not mark an edit dirty when it is made
outside Landscape mode — and every VibeUE/Python edit is outside Landscape mode. The engine
tracks the touched packages in an internal modified-package list instead of the normal dirty flag. The
practical consequences, all measured:
import_heightmapdirties the proxies but does not save them. You must save explicitly.- A brush / region / semantic op (
flatten_at_location,set_height_in_region, sculpt/smooth/…) can write the proxy.uassetfiles to disk during the edit-layer resolve while the editor's dirty list shows nothing. Oneflatten_at_locationleft 49 proxy files modified on disk with an empty dirty list. So the dirty list is not a reliable audit of a WP landscape after a brush op. git statusis the only fully honest audit (the modified files live underContent/__ExternalActors__/...; restore an unwanted change with the editor closed viagit checkout -- Content/__ExternalActors__/...).
Two tools make this auditable from Python:
pythonimport unreal svc = unreal.LandscapeService svc.flatten_at_location("Landscape", 0.0, 0.0, 0.0, 250.0) # any height writer # What did that write actually touch? rep = svc.get_last_landscape_write() print(rep.summary) # e.g. "49 proxies dirtied; 49 written to disk by the edit-layer flush (mtime changed)" print(rep.num_proxies_dirtied, rep.num_written_to_disk, rep.b_any_written_to_disk) for p in rep.proxies: print(p.package_name, "dirty:", p.b_dirty_after, "written:", p.b_written_to_disk, p.file_size_bytes)
-
get_last_landscape_write()reports, per proxy package the last write touched:b_dirty_before/b_dirty_after(the unreliable flag) andb_written_to_disk— an on-disk mtime comparison bracketing the edit-layer resolve, which is the signal you can trust.num_written_to_disk/b_any_written_to_disksummarise it.- Caveat: the mtime probe only sees writes that complete during the MCP call (around the
synchronous
ForceUpdateLayersContentresolve). If the engine ever defers the on-disk save to a later editor tick,b_written_to_diskreadsfalseeven though the file changes shortly after — sogit statusremains the final word.
- Caveat: the mtime probe only sees writes that complete during the MCP call (around the
synchronous
-
save_landscape(label)is the deterministic, explicit save: it collects the parentALandscapepackage plus every proxy sharing the landscape GUID and saves them all throughUEditorLoadingAndSavingUtils::SavePackages(..., only_dirty=False)— i.e. regardless of the dirty flag. Use it afterimport_heightmap(which never saves), or any time you want a clean, audited flush.
pythonres = svc.save_landscape("Landscape") if res.b_success: print("saved", res.num_requested, "packages:", list(res.saved_packages)) else: print("save failed:", res.error_message) # e.g. a read-only / source-controlled file
Blocky / faceted flat areas (plains)
Low-res source DEMs upsampled to landscape resolution show planar facets on flat terrain. Fixes, in order of preference:
resize_heightmap(..., interpolation="bicubic")— now the default; re-run the resize+import if the landscape was built with an older (bilinear) resize.- Increase
blur_passes(20–40) onterrain_data generate_heightmapfor plains-heavy regions. - Material-level smoothing without touching heights: derive a world-space smoothed normal in the
landscape material (see
landscape-materialsskill) when height data must stay pixel-exact.
Landscape Scale
- Default scale
(100, 100, 100)= 1 meter per unit - Larger Z scale = taller terrain range (e.g., Z=200 doubles height range)
⚠️ When importing real-world terrain from terrain_data, ALWAYS calculate Z scale:
z_scale = 20000 / height_scale
Do NOT hardcode Z scale. The height_scale from preview_elevation determines the correct Z scale.
Height Limits (uint16 Saturation)
The heightmap is uint16 (0–65535, midpoint 32768). Maximum world height depends on Z scale:
| Z Scale | Max Height (world units) | Min Height (world units) | Total Range |
|---|---|---|---|
| 100 | ~25,599 | ~-25,600 | ~51,200 |
| 200 | ~51,198 | ~-51,200 | ~102,400 |
| 50 | ~12,800 | ~-12,800 | ~25,600 |
Formula: MaxHeight = (65535 - 32768) × (1/128) × ZScale
When sculpting pushes vertices to 0 or 65535, a saturation warning is logged. To increase range:
- Increase landscape Z scale (halves vertical resolution)
- Offset landscape Z location downward to use the full ±range
World Coordinates vs Landscape Coordinates
- Most methods accept world coordinates (WorldX, WorldY)
- get_height_in_region and set_height_in_region use landscape-local vertex indices
- Heights in
set_height_in_regionare world-space Z values - Heights returned by
get_height_in_regionare world-space Z values
⚠️ GrassVariety Struct Properties Are Read-Only via Direct Assignment
GrassVariety (and its nested structs like PerPlatformFloat, FloatInterval, etc.) cannot be set via direct attribute assignment. You MUST use set_editor_property() for all properties.
python# WRONG - raises "Property is read-only and cannot be set" nv = unreal.GrassVariety() nv.affect_distance_field_lighting = True nv.grass_density.default = 50.0 # CORRECT - use set_editor_property and construct new struct instances nv = unreal.GrassVariety() nv.set_editor_property('affect_distance_field_lighting', True) nv.set_editor_property('grass_density', unreal.PerPlatformFloat(default=50.0)) nv.set_editor_property('scale_x', unreal.FloatInterval(min=1.0, max=3.0))
This applies to ALL nested structs: PerPlatformFloat, PerPlatformInt, PerQualityLevelFloat, PerQualityLevelInt, FloatInterval, LightingChannels. Always construct a new struct instance with keyword args.
⚠️ Creating LandscapeGrassType Assets Requires AssetTools + Factory
LandscapeGrassType assets are NOT created via LandscapeService. Use AssetToolsHelpers with LandscapeGrassTypeFactory:
python# WRONG - no such method exists unreal.LandscapeService.create_grass_type("LGT_MyGrass", "/Game/Grass") # CORRECT - use AssetTools + Factory asset_tools = unreal.AssetToolsHelpers.get_asset_tools() factory = unreal.LandscapeGrassTypeFactory() new_asset = asset_tools.create_asset("LGT_MyGrass", "/Game/Grass", unreal.LandscapeGrassType, factory)
⚠️ Use LandscapeMaterialService for Landscape Materials - NOT MaterialService
- CORRECT:
unreal.LandscapeMaterialService.create_landscape_material("M_Terrain", "/Game/Mats") - WRONG:
unreal.MaterialService.create_material("M_Terrain", "/Game/Mats")→ causesTypeError: Nativize: Cannot nativize 'MaterialCreateResult' as 'String'
MaterialService is for generic materials. Landscape materials MUST use LandscapeMaterialService.
Layer Info Objects Required
Every paint layer needs a ULandscapeLayerInfoObject asset:
- Create with
LandscapeMaterialService.create_layer_info_object() - ALWAYS store and use
.asset_pathfrom the result — never guess paths - Naming convention is
LI_<LayerName>(e.g.,LI_Grass,LI_Rock) — NOTGrass_LayerInfo - Then add to landscape with
LandscapeService.add_layer(landscape_label, info.asset_path)
Material Must Be Compiled First
After creating/changing a landscape material, compile it before assigning to the landscape.
⚠️ Split Material Operations Into Separate Code Blocks
compile_material() can take minutes for complex landscape materials. NEVER combine material creation + compile + layer info creation in one code block — it WILL timeout.
Do this in separate steps:
- Block 1: Create material, add blend nodes, add layers, connect, save
- Block 2:
compile_material()(this is slow) - Block 3: Create layer info objects, assign to landscape
⚠️ Common Mistakes to Avoid
| WRONG | CORRECT |
|---|---|
create_landscape(..., actor_label="X") | create_landscape(..., landscape_label="X") |
result.actor_path | result.actor_label (no actor_path exists) |
info.success | Check info.actor_label (no success field on info) |
info.num_sections | info.num_subsections |
info.quads_per_section | info.subsection_size_quads |
MaterialService.create_material() for landscapes | LandscapeMaterialService.create_landscape_material() |
Guessing layer info path Grass_LayerInfo | Use create_layer_info_object().asset_path → LI_Grass |
segment.start_control_point_index | segment.start_point_index (spline segment) |
segment.end_control_point_index | segment.end_point_index (spline segment) |
layer.name on list_layers result | layer.layer_name (LandscapeLayerInfo_Custom property) |
LandscapeService.create_spline_control_point(...) | LandscapeService.create_spline_point(...) (no "control" in name) |
LandscapeMaterialService.add_material_layer(...) | LandscapeMaterialService.add_layer_to_blend_node(...) |
mat_path = create_landscape_material(...) as string | Returns LandscapeMaterialCreateResult — use result.asset_path |
connect_spline_points(..., tangent_length=25.0) ignoring negative source | Pass tangent_length=seg.start_tangent_length, end_tangent_length=seg.end_tangent_length — both tangents are needed for exact shape |
Only passing tangent_length to connect_spline_points | Must also pass end_tangent_length=seg.end_tangent_length — end tangent is typically negative (UE convention). Omitting it defaults to -start_tangent which is usually correct for NEW splines, but when copying, always pass both explicitly |
create_spline_point("L", location=vec) | Parameter is world_location, NOT location: create_spline_point("L", world_location=vec) |
modify_spline_point("L", 0, location=vec) | Parameter is world_location, NOT location: modify_spline_point("L", 0, world_location=vec) |
modify_spline_point(...) with wrong rotation | Use auto_calc_rotation=False, rotation=pt.rotation to set exact rotation from get_spline_info |
Setting rotation BEFORE connect_spline_points | connect_spline_points triggers auto-calc rotation — set rotations AFTER all segments are connected |
pt.paint_layer_name on LandscapeSplinePointInfo | Property is pt.layer_name (NOT paint_layer_name) — paint_layer_name is the parameter name in create_spline_point |
| Reading weights right after painting and getting 0.0 | Weights are written to edit layer — reads use same path |
spline_info.points on LandscapeSplineInfo | Property is spline_info.control_points (NOT points) |
| Not setting spline segment meshes after connecting | Use set_spline_segment_meshes() to assign mesh entries (e.g. SM_River) — splines are green without meshes |
| Forgetting to set control point meshes | Use set_spline_point_mesh() — control points can have their own static mesh |
layer.asset_path on LandscapeLayerInfo_Custom | Property is layer.layer_info_path (NOT asset_path) |
| Guessing random offset like 110000 for side-by-side | Calculate: offset = (resolution - 1) * scale.x e.g. (1009-1)*100 = 100800 |
Using FoliageService.clear_all_foliage() thinking it only clears one landscape | clear_all_foliage() removes ALL foliage from the ENTIRE level — use remove_foliage_in_radius() or remove_all_foliage_of_type() to target specific areas |
| Manually scattering foliage to replicate a landscape that uses LandscapeGrassType | If foliage is procedural (from LandscapeGrassType on the material), just copy paint layers — foliage auto-generates from weights. Check if list_foliage_types() returns 0; if so, foliage is procedural. |
Using terrain_data without resolution and getting 1081×1081 | Always pass resolution=N matching your landscape. Common values: 505, 1009, 1017, 2033 |
| Assuming 1081×1081 heightmap will fit any landscape | 1081 requires 36×36 components which WILL timeout. Use resolution=1009 (8×8, 63q, 2s) instead |
| Importing a heightmap without checking dimensions | Always call get_heightmap_dimensions() first, then resize_heightmap() if sizes don't match |
| Creating landscape then downloading heightmap (wrong order) | Decide config → calculate resolution → download at that resolution → create landscape → import |
info.scale_x on LandscapeInfo_Custom | info.scale.x — scale, location, rotation are Vector/Rotator structs, not flattened floats |
| Assuming a landscape is centered on its location | It spans +X/+Y from its location (corner-anchored): bounds = location to location + (resolution-1)*scale. A landscape created at (0,0) with 505 res @ scale 100 covers (0,0)–(50400,50400); its center is (25200,25200) |
create_layer_info_object("Grass", "/Game/X", "LI_Grass") | Third param is a bool: create_layer_info_object(layer_name, destination_path, is_weight_blended=True) — passing a string asset name causes TypeError: Cannot nativize 'str' as 'bool' |
AssetTools.create_asset(..., unreal.LandscapeLayerInfoObject, None) to make a layer info | Yields a broken asset with LayerName=None, and layer_name is read-only from Python so it can't be fixed afterwards. Use LandscapeMaterialService.create_layer_info_object(layer_name, path) — or EditorAssetLibrary.duplicate_asset an existing LayerInfo, which keeps its baked LayerName |
Retrying create_landscape after PYTHON_EXECUTION_TIMEOUT | The first call usually still completes in the background — check landscape_exists(label) before retrying (duplicate labels now return an error) |
result.location on LandscapeCreateResult | Result has ONLY success, actor_label, error_message — get location via get_landscape_info(label).location |
result.resolution_x / .resolution_y on HeightmapImportResult | The field is result.resolution — a string like "1009x1009", not separate ints. Split it if you need numbers. |
Treating get_height_at_location() as a float | It returns a LandscapeHeightSample struct — use .height (float) and .valid (bool); float(sample) / f"{sample}" raise TypeError |
| Clearing a World-Partition landscape (parent + streaming proxies) | delete_landscape(label) now removes the streaming proxies too by default (include_proxies=True): it unregisters each LandscapeStreamingProxy sharing the landscape's GUID from the LandscapeInfo, destroys the proxies, then the parent. Pass include_proxies=False for the parent actor only. Do NOT destroy_actor the proxies yourself — destroying a proxy while the LandscapeInfo still references it crashes the editor (access violation in the Landscape module). With the editor closed, deleting the proxy package files under __ExternalActors__ is the other safe route. |
info.component_count_x / info.component_count_y | info.num_components is the TOTAL count (e.g. 64 for 8×8) — there are no per-axis fields |
info.rotation.x (Rotator) | Rotator fields are .roll / .pitch / .yaw — there is no .x/.y/.z on Rotator (only Vector has those) |
Related Skills
| Task | Skills to Load |
|---|---|
| Sculpt terrain only | landscape |
| Simple painted material (LayerBlend) | landscape + landscape-materials |
| Production auto-material (material functions, RVT, instances) | landscape + landscape-auto-material |
| Material instance / biome configuration | landscape-auto-material |
- landscape-materials: Simple landscape materials with
LandscapeLayerBlendnodes. Good for prototyping with 2-5 layers. - landscape-auto-material: Production-quality auto-materials using material functions, Runtime Virtual Textures, and material instances (Real_Landscape paradigm). Good for shipping quality.
When you need to create or modify the material applied to a landscape, load the appropriate
material skill through GetSkills (AgentSkillToolset):
- For simple materials: load
landscape-materials - For production auto-materials: load
landscape-auto-material
NOTE: For landscape material, texture, and layer blending operations, use
LandscapeMaterialService(load thelandscape-materialsorlandscape-auto-materialskill).
Sample scripts (run via execute_python_code)
scripts/sculpt_terrain.txt— create a landscape and sculpt procedural mountain/valley features.
Additional gotchas
- On a World Partition landscape, verify every sculpt and every reader with a
line_trace_singlegrid; the only safe write path isexport_heightmap-> stamp in Python ->import_heightmap. Never synthesise a flat baseline — a flat-plus-pit import erases carved features. - Pixel mapping: origin is the proxy min corner,
world = min + px * scale,worldZ = (H - 32768) * 100/128, no row flip; check against a known trace height before writing. import_heightmapdoes not write proxies to disk — callsave_landscape(orsave_packagesthe Landscape and every proxy). Useget_last_landscape_writeto see what changed, since the dirty list is unreliable right after landscape calls.flatten_at_locationandset_height_in_regionread and write the same edit layer now, so a region write stays inside its brush on World Partition;apply_erosionwas always safe.load_levelfrom Python leaves all WP cells unloaded for the rest of the session, andcreate_landscaperun headless produces a broken landscape (zero heights, no collision).- Delete a landscape together with its streaming proxies via
delete_landscape(include_proxies=True); destroying proxy actors by hand crashes the editor. - The edit-layer merge can re-surface old garbage after a later merge (a brush spawn,
apply_splines_to_landscape); re-verify landmarks by trace after any landscape-touching op. - Spawning any WaterBody auto-spawns a
WaterBrushManager(affects_landscapedefaults True) that adds a "Water" edit layer and poisons the terrain; flip that CDO default first and delete any spawned manager.WaterZone.zone_extentis on the actor; the river/lake seam material islake_transition_material;target_wave_mask_depthis the still-water knob.


