Skip to content

Foliage Pivot Baking

Summary: How per-blade wind/bend data is encoded into foliage meshes so M_Grass can animate each blade around its own base. The convention: UV2 = per-blade local-space pivot XY, VertexColor.R = root→tip ramp. Data is baked with Tools/Python/bake_grass_pivots.py. Critical footgun: static-mesh lightmap-UV regeneration silently overwrites the baked UV2 channel on every build — disable it after the bake.

Table of Contents


Encoding Convention

Each blade of a grass mesh carries per-blade animation data in its vertex channels:

Channel Meaning
UV2.xy Local-space pivot point of the blade — one shared (X, Y) per blade; every vertex of the same blade carries identical UV2. Z is appended as a constant at runtime.
UV1 Unread by the bend chain — leave it free, or mirror UV2 as a safety placeholder.
VertexColor.R Root→tip mask (z - z_min_blade) / (z_max_blade - z_min_blade) per blade. Drives the Power(R, n) * BendWPO path and the wiggler root-tip weight.
VertexColor.G Wind weight constant (≈ 0.5 in the reference asset).
VertexColor.B Mirrors R.
VertexColor.A 1 in the reference asset.

How M_Grass Reads It

M_Grass (/Game/MaterialLibrary/Grass/M_Grass) drives per-blade wind/bend through the Prismatiscape_FoliageBender + Prismatiscape_FoliageWiggler material functions. Both take a PivotPoint input fed by a single MaterialExpressionNamedRerouteDeclaration labeled PivotPointPerBladeOfGrass, which evaluates to:

TransformPosition_Instance_to_World( Append( TextureCoordinate(idx=2), Constant(z=0) ) )

i.e. UV2.xy → local pivot, Z appended, transformed to world space.

Discovery note: this reroute lives in a "dead branch" of the graph that Python's MaterialEditingLibrary output-walk cannot reach (a NamedRerouteDeclaration is referenced by GUID, not through input pins). It was found via unreal.find_object(mat, "MaterialExpressionNamedRerouteDeclaration_N") enumeration — see UE Python Material API Reference.


Baking Workflow

Tools/Python/bake_grass_pivots.py runs inside the editor (via the unreal-engine MCP system_control execute_command: py "..."). Algorithm:

  1. Copy LOD0 into a DynamicMesh via GeometryScript_AssetUtils.copy_mesh_from_static_mesh.
  2. BFS connected components on triangle adjacency to identify individual blades.
  3. Take the centroid of each blade's bottom-25% Z-vertices as its pivot.
  4. Write UV1 + UV2 + vertex color per the convention above.
  5. copy_mesh_to_static_mesh back onto the asset.
  6. Disable lightmap UV regen (see next section) — the copy resets build settings, so this must happen last.

Reference asset /Game/Art/Levels/Shared/Foliage/Grass/SM_Grass matches this convention; SM_Grass_01b_Pivot was authored from SM_Grass_01b with this script.


The Lightmap UV Footgun

Every LOD's FMeshBuildSettings carries bGenerateLightmapUVs = true plus SrcLightmapIndex / DstLightmapIndex. On every static-mesh build, the engine auto-unwraps from src and writes to dst — silently clobbering whatever custom data you stored in that channel. Because copy_mesh_to_static_mesh itself resets build settings to defaults, disabling regen must happen after the copy, not before.

Insidious symptom: the source-asset MeshDescription still shows your baked data via DynamicMesh extraction (so verification scripts pass), while the actual rendered render-data uses the regenerated lightmap. On M_Grass this meant UV2 was both the pivot channel and the auto-lightmap dst index — the whole grass mesh tilted as one rigid body around a meaningless world-space pivot instead of bending per-blade.

Fix (Python), immediately after copy_mesh_to_static_mesh:

unreal.StaticMeshUtilitiesLibrary.set_generate_lightmap_uv(mesh, False)   # covers all LODs
mesh.set_editor_property("light_map_coordinate_index", 0)                  # point nothing at the reused channel
mesh.modify()
unreal.EditorAssetLibrary.save_asset(path)

This applies to any bake into UV1/UV2/UV3 of an existing static mesh — pivot painter, wind data, custom masks, AO bakes, etc.


Applying to a New Foliage Mesh

To bake pivots into another mesh that should accept M_Grass: 1. Write per-blade local-space pivot XY into UV2, and the root→tip ramp into VertexColor.R. UV1 is optional. 2. Disable the static mesh's lightmap UV regen (above) — do this after copy_mesh_to_static_mesh, and reset light_map_coordinate_index off channel 2.


Verification

Spawn the asset in a level and view UV2 with a debug material (TextureCoordinate(2) → BaseColor). Correct data shows clustered flat-color zones (one color per blade), not a unique-per-vertex unwrap. A rigid whole-mesh tilt in wind = the lightmap regen clobbered UV2.



Recent Changes

Date Change Impact
2026-07-03 Documented the pivot convention + lightmap-UV footgun together Single reference for foliage pivot bakes