Content/Skills/landscape/SKILL.md
Create and edit landscape terrain, heightmaps, sculpting, paint layers, terrain analysis, mesh projection, and procedural terrain features using LandscapeService
npx skillsauth add kevinpbuckley/vibeue landscapeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill is split into the lean index (you are reading it) plus sibling sub-docs. Load
a sub-doc with manage_skills(action="load", skill_name="landscape/<section>"):
| 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. |
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
Creating landscapes with many components is SLOW. Python execution has a 30-second timeout.
Safe configurations (under 30s):
ComponentCount=8x8 (505x505 resolution) — FASTComponentCount=16x16 with QuadsPerSection=63 — OKWill TIMEOUT (avoid):
ComponentCount=36x36 or larger — too many components, takes minutesComponentCount=72x72 — definitely exceeds timeoutimport_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.
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) | Bilinear resample a 16-bit heightmap to new dimensions |
| 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 |
BEST approach — request the heightmap at the exact resolution you need:
(8 × 63 × 2) + 1 = 1009terrain_data generate_heightmap with resolution=1009 and map_size=N
map_size controls how many real-world km are captured (default 17.28)map_size = more detail for a specific feature (e.g., 2-5 km for a single mountain)map_size = wider area with less per-pixel detail (e.g., 10-20 km for a region)If you already have a heightmap at a non-matching resolution:
get_heightmap_dimensions(file_path)find_landscape_config_for_resolution(width, height)resize_heightmap(source, target_w, target_h)(100, 100, 100) = 1 meter per unit⚠️ 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.
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:
set_height_in_region are world-space Z valuesget_height_in_region are world-space Z valuesGrassVariety (and its nested structs like PerPlatformFloat, FloatInterval, etc.) cannot be set via direct attribute assignment. You MUST use set_editor_property() for all properties.
# 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.
LandscapeGrassType assets are NOT created via LandscapeService. Use AssetToolsHelpers with LandscapeGrassTypeFactory:
# 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)
unreal.LandscapeMaterialService.create_landscape_material("M_Terrain", "/Game/Mats")unreal.MaterialService.create_material("M_Terrain", "/Game/Mats") → causes TypeError: Nativize: Cannot nativize 'MaterialCreateResult' as 'String'MaterialService is for generic materials. Landscape materials MUST use LandscapeMaterialService.
Every paint layer needs a ULandscapeLayerInfoObject asset:
LandscapeMaterialService.create_layer_info_object().asset_path from the result — never guess pathsLI_<LayerName> (e.g., LI_Grass, LI_Rock) — NOT Grass_LayerInfoLandscapeService.add_layer(landscape_label, info.asset_path)After creating/changing a landscape material, compile it before assigning to the landscape.
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:
compile_material() (this is slow)| 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 |
| 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 |
LandscapeLayerBlend nodes. Good for prototyping with 2-5 layers.When you need to create or modify the material applied to a landscape, load the appropriate material skill:
# For simple materials:
manage_skills(action="load", skill_name="landscape-materials")
# For production auto-materials:
manage_skills(action="load", skill_name="landscape-auto-material")
NOTE: For landscape material, texture, and layer blending operations, use
LandscapeMaterialService(load thelandscape-materialsorlandscape-auto-materialskill).
tools
Create and manage Niagara particle systems - system lifecycle, adding/copying emitters, user parameters, system script settings, scratch-pad authoring
development
Configure Niagara emitter internals - modules, renderers, rapid iteration parameters, graph positioning, and scratch-pad authoring (Custom HLSL + node graph)
tools
Create and modify Blueprint assets, variables, functions, and components
tools
Search, find, open, move, duplicate, save, delete, import, and export assets