UE 5.6 Python / MCP Material API Reference¶
Summary: A field guide to the non-obvious quirks of scripting Unreal Engine 5.6 materials from Python and the
unreal-engineMCP. Covers material creation, material instances, expression editing, Custom HLSL nodes, redirector-following deletion hazards, and pin/sampler naming conventions. All items were hardened while building real assets (RVT blend,M_Aura_Decal, texture-ref repairs) — verify against current engine behavior before relying on any single claim.Engine-version caveat (2026-08-06): every quirk below was verified on UE 5.6. The project now runs on UE 5.8, and none of these have been re-verified there. Missing enum values, absent
FCustomInputproperties, silently-failing factories, and unsaved-asset behavior are exactly the kind of thing an engine upgrade fixes or changes — treat each claim as unverified on 5.8 and confirm before building on it. Engine source line references cite a 5.6 checkout.
Table of Contents¶
- When to Use This
- Material Creation & Settings
- Material Instances
- Inspecting & Editing Expressions
- Pin Name Strings
- Wiring Material Outputs (Main pins)
- Custom HLSL Nodes
- Static Switches & Parameter Defaults
- Redirector-Following Deletion Hazard
- Asset Save / Delete Gotchas
- Related Systems
- Recent Changes
When to Use This¶
Read this before authoring or repairing materials via Python or the manage_material_authoring MCP action. The overarching workflow rule (from the MCP + Python Automation Guide): run edits in the live editor via MCP py, then verify with get_material_info and by tailing Saved/Logs/ProjectEternal.log. The -run=pythonscript commandlet returns 0 expressions / no editor-only data for materials, so it is not a substitute for the live editor.
General principle: most material-graph APIs fail silently — connect_material_expressions returns False on a wrong pin name, add_scalar_parameter bakes 0, and setting a non-existent property no-ops or corrupts serialization. Always check return values and re-inspect the graph.
Material Creation & Settings¶
| Setting | Call |
|---|---|
| Blend mode | material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_MASKED) |
| Shading model | material.set_editor_property("shading_model", unreal.MaterialShadingModel.MSM_DEFAULT_LIT) |
| World-space normal output | material.set_editor_property("tangent_space_normal", False) — lets the Normal pin take a world-space vector directly |
Factory classes for asset creation are often not exposed as direct constructors (e.g. RuntimeVirtualTextureFactoryNew). Load the factory class by script path instead:
factory_class = unreal.load_class(None, "/Script/VirtualTexturingEditor.RuntimeVirtualTextureFactoryNew")
factory = unreal.new_object(factory_class) if factory_class else None
asset = AssetTools.create_asset(name, pkg, unreal.RuntimeVirtualTexture, factory)
Position constants (LWC): MaterialExpressionWorldPosition works fine and outputs absolute world position even under Large World Coordinates.
Struct construction: use unreal.LayerBlendInput() (NOT LandscapeLayerBlendInput); set its fields via set_editor_property("layer_name", ...) — direct attribute assignment (entry.layer_name = name) fails.
Material Instances¶
| Task | Reliable path |
|---|---|
| Create the MI | MCP create_material_instance (py factory MaterialInstanceConstantFactoryNew silently fails in 5.6 — see MI creation: prefer MCP) |
| Set parent | Always force it via MaterialEditingLibrary.set_material_instance_parent(mi, parent) after creation — the factory's InitialParent may not stick |
| Scalar param on instance | py MaterialEditingLibrary.set_material_instance_scalar_parameter_value (MCP set_scalar_parameter_value echoed value:0 and did not apply) |
| Texture param on instance | py set_material_instance_texture_parameter_value |
Inspecting & Editing Expressions¶
You cannot enumerate a Material's expression list in 5.6 Python:
- Material.get_expressions() → AttributeError (no such method).
- mat.get_editor_property("expressions") / "expression_collection" → "protected and cannot be read".
- mat.get_editor_property("editor_only_data") returns MaterialEditorOnlyData but exposes neither list.
- MaterialEditingLibrary has get_num_material_expressions but no list getter.
Workflow to inspect and edit an existing node:
- Get node ids + connections via MCP
manage_material_authoringactionget_material_info— returnsexpressions[]withnodeId(e.g.MaterialExpressionTextureSample_0), type, position, andconnections[]mapping nodes to Main inputs. Identify which sample is which by its connections, not by sampler type. - Load the individual node as a named subobject (the array is protected, but each object is not):
UE assigns numeric suffixes (
expr = unreal.find_object(mat, nodeId) # or load_object(mat, nodeId) expr.set_editor_property("texture", tex) unreal.MaterialEditingLibrary.recompile_material(mat) unreal.EditorAssetLibrary.save_asset(mat_path)<ExpressionTypeName>_<idx>); iterate a range to enumerate. - Find what feeds a Main pin:
MaterialEditingLibrary.get_material_property_input_node(material, MaterialProperty.MP_*).
Protected input structs. The .A / .B / .Alpha ExpressionInput structs on math / lerp / switch nodes cannot be read or written. To re-route such an input, just call connect_material_expressions again with the new source — it overwrites the existing wire silently. Also applies to nodes hidden in "dead branches" (e.g. NamedRerouteDeclaration, referenced by GUID not by input pins) — reach them only via find_object enumeration.
Diagnostics. MaterialEditingLibrary.get_used_textures(mat) returning [] means every sample resolved to None (broken texture refs) — confirms the bug and, after the fix, the repair.
Pin Name Strings¶
connect_material_expressions(from_node, from_output_name, to_node, to_input_name) requires the pin's exact display name. A wrong string silently returns False and the material falls back to Default. Probe unknown pins on a throwaway material by looping candidate strings and logging the bool.
| Node | Pin | Correct string |
|---|---|---|
TextureSampleParameter2D |
UV coords | "UVs" (NOT "Coordinates") |
TextureSampleParameter2D |
RGB output | "RGB" (NOT "") |
StaticSwitchParameter |
True / False branches | "True" / "False" (NOT "A" / "B") |
LinearInterpolate |
inputs | "A", "B", "Alpha" |
Multiply / Add / Subtract / Divide / Max / Min |
inputs | "A", "B" |
Saturate / Sine / Cosine (single-input unary) |
input | "" (empty) — "Input" returns False! |
ComponentMask |
input | "" (empty) |
Noise |
position | "World Position" (with space, NOT "Position") |
RuntimeVirtualTextureOutput |
normal in | "Normal" (NOT "WorldSpaceNormal") |
RuntimeVirtualTextureSample |
named outputs | "BaseColor", "Normal", "Roughness", "WorldHeight" |
VectorParameter / Constant3Vector output is float3 RGB, not float4. The default "" output is masked-RGB (already float3); connect the separate "A" output for alpha. ComponentMask-ing "" for a 4th component fails: Not enough components in (... float3) for component mask 0001. Use "R"/"G"/"B"/"A" outputs for single channels.
Unary-input workaround. If the "" Saturate pin is unreliable in a chain, replace saturate(x) with Min(Max(x, Const(0)), Const(1)) — Max/Min use the proven "A"/"B" pins and sidestep the quirk.
Wiring Material Outputs (Main pins)¶
Material OUTPUT pins (EmissiveColor, Opacity, etc.) are not addressable via MCP connect_nodes — the resolver returns NODE_NOT_FOUND because the root node's id is Main. Use py:
unreal.MaterialEditingLibrary.connect_material_property(expr, "", unreal.MaterialProperty.MP_EMISSIVE_COLOR)
Missing enum values (5.6 binding). unreal.MaterialProperty does not expose indices 3, 4, 13, 14, 16, 17, 20+. Critically, MP_PIXEL_DEPTH_OFFSET (16 in C++) is NOT exposed — PDO cannot be wired from Python; drag it manually in-editor, or route through use_material_attributes=True + MakeMaterialAttributes (untested).
Exposed values: MP_EMISSIVE_COLOR=0, MP_OPACITY=1, MP_OPACITY_MASK=2, MP_BASE_COLOR=5, MP_METALLIC=6, MP_SPECULAR=7, MP_ROUGHNESS=8, MP_ANISOTROPY=9, MP_NORMAL=10, MP_TANGENT=11, MP_WORLD_POSITION_OFFSET=12, MP_SUBSURFACE_COLOR=15, MP_AMBIENT_OCCLUSION=18, MP_REFRACTION=19, MP_FRONT_MATERIAL=30.
Custom HLSL Nodes¶
To feed a Texture2D into a MaterialExpressionCustom from Python:
- Create a
MaterialExpressionTextureObjectParameter(orTextureObjectfor a constant) and set itstextureproperty to a validTexture2D. - Create a
unreal.CustomInputwithinput_name = "BrushTex". Do NOT setinput_type— that property does not exist onFCustomInputin 5.6;set_editor_propertywill silently no-op or corrupt serialization, producing a black material on next compile. - Connect the texture node's
""output to the Custom node'sBrushTexinput. - In HLSL, sample with
Texture2DSample(BrushTex, BrushTexSampler, uv). The sampler is auto-generated as<InputName>Sampler— hardcoded by the translator, do not rename.
n_brush = mlib.create_material_expression(mat, unreal.MaterialExpressionTextureObjectParameter, x, y)
n_brush.set_editor_property("parameter_name", "BrushTex")
n_brush.set_editor_property("texture", default_tex)
ci = unreal.CustomInput()
ci.set_editor_property("input_name", "BrushTex") # NO input_type!
inputs.append(ci)
mlib.connect_material_expressions(n_brush, "", n_custom, "BrushTex")
Why: FCustomInput in 5.6 has only InputName (FName) and Input (FExpressionInput). The type is inferred at compile time from the connected expression. ECustomMaterialOutputType (CMOT_Float1..4) exists for outputs only — there is no CMIT_TEXTURE_2D or input_type. The translator emits Texture2D <Name>, SamplerState <Name>Sampler when the input resolves to MCT_Texture2D.
add_custom_expressionmakes only 1 input. For N named inputs, py-setnode.set_editor_property('inputs', [unreal.CustomInput(input_name=...), ...]), then wire each via MCPconnect_nodes(targetPin = the input name).- Appending inputs preserves existing wiring:
ins = node.get_editor_property('inputs'); ins.append(unreal.CustomInput(input_name=..)); node.set_editor_property('inputs', ins)— only the new inputs need wiring. (CustomInput.inputitself is protected/unreadable.)
Engine source refs (verified at Q:\Unreal\UE_5.6): MaterialExpressionCustom.h (FCustomInput, lines 26-36); MaterialExpressions.cpp:3971-4145 (TextureObject / TextureObjectParameter); HLSLMaterialTranslator.cpp:15585-15744 (input-type dispatch + sampler naming at 15695-15701).
Static Switches & Parameter Defaults¶
add_scalar_parameterignoresdefaultValue→ the default bakes to 0 (get_material_infoshows"Param (0) 'Name'"). Instances that don't override the param inherit 0 (e.g. RingRadius 0 → a dot instead of a ring). Fix: after creating, pynode.set_editor_property('default_value', x), then recompile.- Static-switch inputs wire ONLY via MCP
connect_nodes(targetPinA/B). pyconnect_material_expressions(..., 'A')returnsFalse, andswitch.get/set_editor_property('a')raises "protected". The MCP resolver handles A/B where py cannot. decal_blend_modeis deprecated/protected in 5.6 ("No longer used"). A Translucent DeferredDecal emits emissive glow without setting it.- Gradient textures: multi-stop
colorStopsoncreate_gradient_textureerrors (TEXTURE_ERROR) for both object- and array-color forms; only 2-stopstartColor/endColorworks.
Redirector-Following Deletion Hazard¶
unreal.load_object(None, "<pkg>.<Name>") and load_asset silently follow redirectors — they return the redirector's TARGET, not the redirector. So EditorAssetLibrary.delete_loaded_asset(obj) on that handle deletes the target asset (real-world bite: deleted a live montage instead of the redirector; recovered via git checkout HEAD -- <path>).
Always guard asset-deletion / rename Python with a class-name check that aborts on mismatch, rather than trusting the loaded handle:
expr = unreal.load_object(None, path)
if expr.get_class().get_name() != "AnimSequence":
raise RuntimeError("refusing to delete: not the expected class")
Removing a redirector that shares a package with a still-referenced asset (asset name ≠ package name, from a messy rename): neither ProjectCleaner nor ResavePackages -fixupredirects can delete it, because both delete at package granularity and the package must survive for the live asset. Fix by renaming the LIVE asset into its own package, leaving the old package as pure redirector(s):
at = unreal.AssetToolsHelpers.get_asset_tools()
anim = unreal.load_object(None, "<oldpkg>.<RealAssetName>") # explicit obj path; verify class!
rd = unreal.AssetRenameData(asset=anim, new_package_path="<dir>", new_name="<RealAssetName>")
at.rename_assets([rd]) # repoints referencers, leaves redirector behind
Then "Fix Up Redirectors in Folder" (GUI) or the ResavePackages commandlet deletes the now-orphan redirector. Run such commandlets with the editor closed.
Asset Save / Delete Gotchas¶
manage_asset importwithsave:truedoes NOT write the.uassetto disk (5.6) — the asset loads in-memory (renders fine) but is not on disk, so git can't see it and refs break on reload. Force it:EditorAssetLibrary.save_loaded_asset(load_asset(path), False)(thesave_asset(path, only_if_is_dirty=False)form was unreliable). Verify the.uassetexists on disk beforegit add. (MCPcreate_noise_texture/create_gradient_textureDO save.)EditorAssetLibrary.delete_assetmay hang on a modal dialog if the asset has GC referencers, and the file may stay locked while the editor holds a package handle. To force-restore via git: close the asset's editor tab, GC, possibly switch level, thengit checkout(may still fail until a full editor restart).- Safer for live materials:
MaterialEditingLibrary.delete_all_material_expressions(material)then rebuild — keeps the asset path and MI references stable.
StaticMesh collision API (5.6): EditorStaticMeshLibrary is gone. Use StaticMeshEditorSubsystem; method names are plural:
ss = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
ss.bulk_set_convex_decomposition_collisions(meshes, hull_count=4, max_hull_verts=16, hull_precision=100000)
Related Systems¶
- Unreal MCP + Python Automation Guide — canonical guide to driving the editor headless; read first
- Foliage Pivot Baking — a concrete
find_objectenumeration use case (NamedRerouteDeclaration discovery) - RVT Landscape Blend — a material pattern built entirely through these APIs
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-07-03 | Consolidated five session-memory references into this doc | Single lookup for 5.6 material-scripting quirks |