Skip to content

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-engine MCP. 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 FCustomInput properties, 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

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 silentlyconnect_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:

  1. Get node ids + connections via MCP manage_material_authoring action get_material_info — returns expressions[] with nodeId (e.g. MaterialExpressionTextureSample_0), type, position, and connections[] mapping nodes to Main inputs. Identify which sample is which by its connections, not by sampler type.
  2. Load the individual node as a named subobject (the array is protected, but each object is not):
    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)
    
    UE assigns numeric suffixes (<ExpressionTypeName>_<idx>); iterate a range to enumerate.
  3. 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:

  1. Create a MaterialExpressionTextureObjectParameter (or TextureObject for a constant) and set its texture property to a valid Texture2D.
  2. Create a unreal.CustomInput with input_name = "BrushTex". Do NOT set input_type — that property does not exist on FCustomInput in 5.6; set_editor_property will silently no-op or corrupt serialization, producing a black material on next compile.
  3. Connect the texture node's "" output to the Custom node's BrushTex input.
  4. 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_expression makes only 1 input. For N named inputs, py-set node.set_editor_property('inputs', [unreal.CustomInput(input_name=...), ...]), then wire each via MCP connect_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.input itself 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_parameter ignores defaultValue → the default bakes to 0 (get_material_info shows "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, py node.set_editor_property('default_value', x), then recompile.
  • Static-switch inputs wire ONLY via MCP connect_nodes (targetPin A/B). py connect_material_expressions(..., 'A') returns False, and switch.get/set_editor_property('a') raises "protected". The MCP resolver handles A/B where py cannot.
  • decal_blend_mode is deprecated/protected in 5.6 ("No longer used"). A Translucent DeferredDecal emits emissive glow without setting it.
  • Gradient textures: multi-stop colorStops on create_gradient_texture errors (TEXTURE_ERROR) for both object- and array-color forms; only 2-stop startColor/endColor works.

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 import with save:true does NOT write the .uasset to 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) (the save_asset(path, only_if_is_dirty=False) form was unreliable). Verify the .uasset exists on disk before git add. (MCP create_noise_texture / create_gradient_texture DO save.)
  • EditorAssetLibrary.delete_asset may 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, then git 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)


Recent Changes

Date Change Impact
2026-07-03 Consolidated five session-memory references into this doc Single lookup for 5.6 material-scripting quirks