DOCUMENTATION

The world

Common mistakes with levels in Unreal through MifBridge: landscape and grass, navigation, collision, ground traces, World Partition and Sequencer. Read it when a placement lands off the ground, a navmesh answer looks plausible and wrong, collision does not change, or a Sequencer track animates nothing.

Landscape

A layer is paintable only if the material declares it

Painting needs two things: a target layer registered on the landscape and bound to a LandscapeLayerInfoObject asset, and an assigned material that references that layer name. A material that references a layer name with no target layer reads its weight as always zero. The reverse is just as quiet: a registered layer the material never samples gives a paintable entry that paints nothing, with no error.

register_landscape_layer checks this and refuses the second case. On 5.8, registering six layer infos shipped with one master material accepted five and refused Sand: "'Sand' is not a layer this landscape can register: it is absent from the landscape material". That refusal is the useful behavior. Registering layers also does not create weightmaps: right after registering five, the landscape still reported componentsWithoutWeightmap: 64 of 64, because weightmaps are allocated per component when weight is first applied.

Grass renders nothing without a Grass Output node, and says nothing

Creating a LandscapeGrassType and pointing it at a mesh produces no grass and no error, because the grass system is driven from the landscape material. A MaterialExpressionLandscapeGrassOutput node holds the list of grass types, and the order is: material node first, grass type second, mesh third.

Everything is reachable through the generic calls create_asset, edit_container, set_property and add_material_expression:

create_asset   {class:"LandscapeGrassType", ...}
edit_container {objectPath:<type>, propertyPath:"GrassVarieties", operation:"add"}
set_property   {objectPath:<type>, propertyPath:"GrassVarieties[0].GrassMesh", value:<mesh>}
set_property   {objectPath:<type>, propertyPath:"GrassVarieties[0].GrassDensity.Default", value:250}
  • GrassVarieties starts empty, and set_property can only index an element that exists. Grow it with edit_container first.
  • Density is a per-platform float, so the path ends in .Default. The asset-level GrassDensity, StartCullDistance and EndCullDistance survive only as deprecated fields, and writing them does nothing.
  • A new Grass Output node is never empty. Its constructor already adds one entry named Grass. The sequence "add an element, then set element 0" fills that default entry and leaves your new element dangling and empty. Set GrassTypes[0].GrassType directly, or in one call:
add_material_expression {materialPath:<m>, expressionClass:"MaterialExpressionLandscapeGrassOutput",
                         properties:{GrassTypes:"((Name=\"Grass\",GrassType=\"<type path>\"))"}}

add_material_expression returns expressionName and expressionIndex but no object path. To address the node afterward, use the subobject form <Material>.<Material>:<expressionName>.

What else to know when you tune grass:

  • The density input is a plain float, so any expression drives it: a slope mask, a height band or noise, not only a painted layer. A landscape with no painted layers can still be clothed. A landscape material holds at most 32 grass types.
  • The density number is one tenth of what its tooltip says. The tooltip reads "Instances per 10 square meters", but the engine computes instances as Extent.X * Extent.Y * Density / 1e6 in centimeters. The default 400 gives 4 instances per square meter, 40 per 10 m^2, not 400. Budget from the formula.
  • StartCullDistance and EndCullDistance both default to 10000. They feed a fade node, and equal values leave nothing to fade across, so grass pops at 100 m. bCastDynamicShadow defaults to true: at default density that is roughly 126,000 shadow-casting instances inside the 100 m cull radius.
  • Grass components step, they do not ramp. For 126-quad landscape components at draw scale 100 the threshold is density 412.8, and the default is 400. Raising it to 450 for a slightly thicker look takes the component count from 1 to 4 per landscape component, and the cvar's own help warns that more components can be hitchy as they come into range.

Writing the merged heightmap is lost at the next composite

On a landscape with sculpt edit layers, the merged heightmap and weightmaps are regenerated from the layers every time the landscape recomposites, and anything can ask for that: its first update after creation, a spline, an edit in Landscape mode. A write straight to the merged buffer is thrown away then, and a read-back immediately after the write passes, because the composite has not run yet. In one test, an import right after creating a landscape never reached the surface, and one apply_spline_to_landscape far away flattened a later import and took a sculpt from 4245 to 0. Write through a named layer. A persistence check has to cause the thing that would destroy the write, not wait: a merged write survived ten seconds on 5.7 because nothing asked for a composite.

A one-vertex read at the last row or column returns 0

The engine's fast height read for a single vertex on the landscape's last vertex row or column reads nothing and leaves the buffer at 0, so a landscape read reported -25600 cm there while the exported heightmap held the real height. sample_landscape reads the 2 x 2 block ending at that vertex. A region read that starts inside is fine.

Nanite on a landscape: the flag builds nothing

bEnableNanite = true runs the landscape's change handler, which invalidates the Nanite representation and switches the rendering method. It builds nothing. A component renders as Nanite only when all five hold:

  1. landscape.RenderNanite is 1.
  2. The proxy has Nanite components.
  3. Nanite is usable on the platform. 5.3 checks the maximum RHI feature level and 5.4 and later check the world's.
  4. The Nanite mesh is up to date.
  5. The materials pass Nanite's audit.

Condition 3 bites on test machines: an editor running Direct3D 12 at feature level SM5 has no Nanite at all, so a fully built landscape stays non-Nanite. Set the project's targeted RHIs to SM6 instead of forcing the editor with -sm6, which timed out a GPU on a project that did not target SM6. With the SM6 targets set, all 256 of 256 landscape components rendered with Nanite, built in 37 s.

A bigger search extent can return a worse answer

Projecting a point onto the navmesh takes an extent, a search box. When a projection fails the instinct is to widen it, and widening can move the answer to a different floor while still reporting success. Projecting one fixed point at growing extents:

extent (cm) onNavMesh projected Z movedBy
200 true 1220 43 cm
250 true 1220 43 cm
300 true 1545 285 cm
400 to 1200 true 1545 285 cm

The jump happens at 300 and never comes back, and the answer got 6.6 times further away because the box got bigger. The engine finds the nearest polygon by comparing in the horizontal plane and ignoring height, then checks the height difference against the box on all three axes. Once the box is tall enough to admit a polygon that is nearer in plan but much higher, that polygon wins.

Start with the smallest extent that works, and treat a sudden jump in movedBy in nav_project_point as a sign you crossed onto another surface. If a point genuinely needs a large vertical search, use a tight height and a generous horizontal range instead of a big cube.

Two related notes. The height you pass is not the height used: the navmesh adds a per-navmesh VerticalDeviationFromGroundCompensation to each search height, so read it off the RecastNavMesh actor before trusting a vertical tolerance. And an interaction point authored at waist height is not on the navmesh. Four stations at z=120 over a navmesh at z=10 needed an extent of at least 250 and then moved about 110 cm.

nav_status tile counts do not prove a build

tiles is the size of the tile pool, above zero as soon as a navmesh exists, generated or not. A volume that covers nothing still reports tiles. Wait on tilesWithPolygons instead.

The navigation switch is the World Settings config

bEnableNavigationSystem is deprecated and is read only once, to give an old map a config. A World Settings made in the editor keeps the flag true whatever the config says. To turn navigation off, the navigation system config must name no navigation system (a null config).

Building a brush moves the volume

The engine's brush builder snaps the actor to the grid when it ends a brush. A navigation volume fitted to a center at z 104.5 sat at z 100. Every volume the bridge places is put back after the build.

Collision

set_collision works, and the field to pass is componentPath

Setting a collision profile by writing the property never propagates. Writing CollisionProfileName sets a name and nothing else. The engine's setter also calls a loader that copies the object type, collision enabled state and responses out of the named profile, and a property write never reaches it. A read-back of the name agrees with what was written while the body behaves as before. set_collision calls the setters, so use it.

An older hint told callers to pass a component's templatePath as the objectPath. That field belongs to Blueprint class templates, and a native component of a placed level actor reports "templatePath": null. The field that works is componentPath:

"componentPath": "/Game/Maps/Main:PersistentLevel.StaticMeshActor_191.StaticMeshComponent0"
"templatePath":  null
"origin":        "native"
  • list_components needs includeNative: true to show a static mesh actor's StaticMeshComponent0. Without it the actor appears to have zero components.
  • get_collision takes a static mesh asset path and reports that mesh's collision geometry. It is not the reader for a component's collision settings. Read those with get_property on BodyInstance.
  • A StaticMeshActor ignores a collision profile written into its component's BodyInstance: it turns bUseDefaultCollision on, so the mesh's body setup decides. set_property on BodyInstance.CollisionProfileName changes a field nothing reads (the field read OverlapAll while the component's profile was BlockAll). set_collision goes through the component's setter, which turns the default off.

Ground traces and splines

set_spline_points with snapToGround traces 10000 uu above and below each point's own height. trace_ground starts at z = 100000. A spline authored flat at z: 0 over terrain higher than 10000 uu keeps z: 0, and trace_ground still sees the ground there, because the snap trace begins below the surface it is looking for. Two readers disagree about whether there is ground, and both are right. Send a point's height near the terrain, or use trace_ground first.

World Partition

  • A far actor never plays. On a World Partition map an actor placed past the player's loading range is never streamed into play, so list_pie_actors finds nothing and a test reads "no pawn". Keep an actor you want to play within about 250 m of the origin, or make it not spatially loaded.
  • Simulate has no copy of an unloaded cell's actors. A play or Simulate session streams in only the cells around its streaming source. An actor 88,000 cm out had no play-world copy on 5.8.2, and keep_simulation_changes refuses it ("has no copy in the play world"). Keep what is near the view or load the region first. While a session runs, get_actor_bounds resolves an actor in the play world and does not find an editor-world path. Read an editor actor's component by object path with get_property instead.
  • Placed actors have private external packages. In a World Partition level each placed actor lives in its own external package, and the engine refuses a hard reference to it from call_function's argument block ("PRIVATE to package"). merge_static_mesh_actors calls the merge directly for that reason, and set_pcg_parameter_override writes PCG overrides directly. More generally, an object path that points at a loaded actor can import as null while the call reports success. The same rule makes set_property refuse an actor reference written into an object in another package, which is correct, since a saved hard pointer into another level cannot be stored. Use a soft reference, or set it from an object in the same level. call_function passing an actor as an argument does work for a full path.
  • A merged mesh may get an SM_ prefix. From 5.4 the engine prefixes SM_ to a merged mesh name that lacks it. The endpoint answers the path it made and warns when that differs from outputPath.

Sequencer

Several Sequencer calls report success and animate nothing until a second step is taken.

  • A property track drives nothing until it names its property. A new track binds no property, so its keys animate nothing. add_sequence_track takes propertyPath and propertyName and refuses a property track without them. An empty name would end the editor, so it is validated first. A transform track binds Transform itself. An actor's visibility track binds bHidden and a component's bHiddenInGame, and its channel is the inverse of hidden: a visibility track keys VISIBLE.
  • A component material track needs a material type. From 5.4 the track's material type defaults to empty, and an empty type drives no material. add_sequence_track takes materialType and materialSlot and warns on an empty type. DecalMaterial is for a decal and IndexedMaterial plus a slot is for a mesh. UE 5.3 has no material types: the track drives a primitive component's slot, so a decal's material cannot be animated by a material track there. A new material parameter section has no channels until a parameter is keyed.
  • An enum property's track needs its enum. A byte or enum track does not find its enum on its own, so keys show as numbers in the Curve Editor. add_sequence_track sets it from the resolved property, and set_sequence_keys falls back to the track's enum.
  • Re-keying an exact time makes a second key. The curve channels' linear, cubic and constant key adds insert after any key at that tick, so a re-written key left two and a stale hold key kept winning. set_sequence_keys removes the old key first.
  • Times are in ticks. A cubic key's stored tangent is value per tick: at 24000 ticks per second a slope of 15 units per second is stored as 0.000625. get_sequence_keys gives both.
  • A play-rate change resizes a skeletal animation section. A reflected write to the play rate fires the engine's behavior of resizing the section so the clip still plays through: one set_property took a section from 7.0 to 11.65 s to 7.0 to 22.5 s. set_property reports sectionRange and takes keepSectionRange.
  • A muted row still reads as playing in some readers. A muted row is RowsDisabled on the track and a muted track is bIsEvalDisabled. A reader that asks only whether a section is active reports a muted row as playing. evaluate_sequence evaluates channels directly and reports exact only where one absolute section is active at full weight.
  • The camera cut track is separate. It is in neither a binding's tracks nor the movie scene's track list. A reader that walks only those never sees which camera is live. Removing a camera's binding leaves its cuts pointing at nothing.
  • Piloting is a two-way lock. A viewport left piloting a camera actor moves the camera whenever the viewport is flown. pilot_viewport with eject: true keeps the view where it is. The editor's own Eject moves the view back to where piloting began.
  • Pose reads trail the playhead. set_sequencer_time moves the playhead and get_sequencer_state reads the new frame, but the component keeps its last computed pose until the editor ticks it. Use read_component_pose with sequence and frame. See Common mistakes with animation.
  • A decal is not a primitive. The engine's actor bounds union primitive components only. A decal actor's box was its editor-only arrow and sprite, which ignore scale and rotation (x 1472 to 1728 for a decal painting 1392.5 to 1807.5). get_actor_bounds uses the decal's own size box.