agentleFS
dima-rdnvdima-rdnv/claude-skills/unreal-materials-python/SKILL.md

unreal-materials-python

Skill2 starsChanged 5 days agoMITView on GitHub

Building and editing Unreal Engine 5.8 materials from Python (MaterialEditingLibrary) — materials, material functions with inputs/outputs, Material Parameter Collections, Custom HLSL nodes (helper functions, texture sampling), per-instance pivots, injecting a function between a material's existing inputs and its outputs (incl. Substrate surfaces, decals and Single Layer Water), finding every master a level really uses, importing data textures, and finding compile errors. Includes verified traps (struct constructors, ComponentMask defaults, delete_all skipping nodes, MPC reordering). Load with unreal-mcp.

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read an existing material's wiring: MEL.get_material_property_input_node(mat, prop) and …

    line 29
  2. Very specific:

    Don't name an input Time (use e.g.

    line 85
  3. Very specific:

    Call it from a material: MaterialExpressionMaterialFunctionCall + set_editor_property("material_function", mf).

    line 113
  4. Very specific:

    Don't trust a list from the material assets: survey what the level actually renders — every MeshComponent (instance counts for …

    line 139
  5. Distinctive:

    After tonemapping the input is display-encoded: a target sRGB value / 255 lands exactly ((0.914, 0.165, 0.110) rendered (233, 42, 28)).

    line 164
  6. Very specific:

    Write a 16-bit grayscale PNG with numpy + zlib (no PIL needed), import with AssetImportTask (replace_existing=True, automated=True), then …

    line 192

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Unreal materials from Python

Verified on UE 5.8.4 with Substrate enabled in the project (legacy material inputs still work and are converted). Load unreal-mcp first for how to run code in the editor.

Basics

MEL = unreal.MaterialEditingLibrary
AT = unreal.AssetToolsHelpers.get_asset_tools()
mat = AT.create_asset("M_Foo", "/Game/Dir", unreal.Material, unreal.MaterialFactoryNew())
mf  = AT.create_asset("MF_Foo", "/Game/Dir", unreal.MaterialFunction, unreal.MaterialFunctionFactoryNew())
mpc = AT.create_asset("MPC_Foo", "/Game/Dir", unreal.MaterialParameterCollection,
                      unreal.MaterialParameterCollectionFactoryNew())
n = MEL.create_material_expression(mat, unreal.MaterialExpressionMultiply, x, y)
n = MEL.create_material_expression_in_function(mf, unreal.MaterialExpressionMultiply, x, y)
MEL.connect_material_expressions(src, "", dst, "A")     # returns False on a bad pin name
MEL.connect_material_property(src, "", unreal.MaterialProperty.MP_BASE_COLOR)
MEL.update_material_function(mf); MEL.recompile_material(mat)
unreal.EditorAssetLibrary.save_loaded_asset(asset)
  • Output/input pin names: "" = first/only pin. Lerp: A, B, Alpha; Multiply/Add: A, B; FunctionOutput / ComponentMask / TransformPosition input: ""; Custom node inputs: their names.
  • Wrap connect_material_expressions and raise when it returns False — silent failures otherwise.
  • Read an existing material's wiring: MEL.get_material_property_input_node(mat, prop) and MEL.get_material_property_input_node_output_name(mat, prop) (works, 5.8).
  • With Substrate on, connect_material_property to MP_BASE_COLOR etc. still works on a new material (MP_FRONT_MATERIAL stays unconnected; legacy inputs are auto-converted).

Traps (all hit in practice)

  • Struct constructors take no kwargs: unreal.CustomInput() then set_editor_property("input_name", n); same idea for CollectionScalarParameter / CollectionVectorParameter (parameter_name, default_value).
  • Vector4f has no Python fields (FunctionInput preview_value): pv = node.get_editor_property("preview_value"); pv.import_text("(X=0.5,Y=0.5,Z=0.5,W=0)"); node.set_editor_property("preview_value", pv).
  • ComponentMask defaults are not all off. Setting only r=True gave a float2 and a "float3 * float2" compile error. Always set all four: for c in "rgba": m.set_editor_property(c, c in "r").
  • delete_all_material_expressions_in_function removes only every other node per call. Leftover FunctionOutputs then break compiles ("Missing function output connection"). Loop until MEL.get_num_material_expressions_in_function(mf) == 0.
  • Never reorder an MPC's parameter arrays. Append new parameters at the END. Inserting in the middle shifts the collection's layout; already-compiled materials read the wrong slots. MEL.recompile_material on each user afterwards.
  • Rebuilding a material function in place (delete all nodes, recreate) silently disconnects every MaterialFunctionCall that uses it: the new FunctionInput/Output nodes get new ids, the calling material keeps compiling without errors, but its property pins (BaseColor, Emissive...) are now empty → black surfaces. After a structural rebuild, re-create or re-inject the calling materials; check with MEL.get_material_property_input_node(mat, MP_BASE_COLOR) (None = broken).
  • MaterialExpressionCollectionParameter: setting collection then parameter_name via set_editor_property resolves the parameter id (compiles, values verified in the viewport).
  • MaterialExpressionTextureObject: setting texture auto-picks the sampler type.
  • Changing MPC defaults resets the editor world's live MPC values on the next tick. Setting a preview value (MaterialLibrary.set_scalar_parameter_value(editor_world, ...)) in the same Python call as a default change gets wiped — set previews in a separate call.
  • Deleting a material that is loaded and assigned fails ("Failed to unload all packages" ensure) and create_asset then returns None — clear its nodes instead (loop MEL.delete_all_material_expressions(mat) until get_num_material_expressions == 0) and rebuild.

Compile errors

MEL has no compile-error getter. Read the log: after a recompile, lines like LogMaterial: Warning: [AssetLog] /Game/..: Failed to compile Material for platform PCD3D_SM6 are followed by tab-indented lines with the real error, e.g. (Function MF_X) (Node Multiply) Arithmetic between types float3 and float2 are undefined. A transient "Custom material ... missing input" can appear while a graph is still being wired. The MCP MaterialTools.get_expressions lists every node in a material/function (handy to spot leftovers).

Custom HLSL node

c = MEL.create_material_expression_in_function(mf, unreal.MaterialExpressionCustom, x, y)
c.set_editor_property("output_type", unreal.CustomMaterialOutputType.CMOT_FLOAT2)
c.set_editor_property("code", HLSL)
c.set_editor_property("inputs", custom_inputs(["Tex", "P", "Pivot", "Now"]))
  • Helper functions inside a Custom node: wrap them in a struct and instantiate it (struct N { float h(float3 p){...} }; N n; ... n.h(p)) — compiles in 5.8.
  • A TextureObject wired to input Tex is sampled in code as Texture2DSampleLevel(Tex, TexSampler, uv, 0).
  • Don't name an input Time (use e.g. Now) to stay clear of engine names.
  • World position: MaterialExpressionWorldPosition (absolute) into a float3 input.
  • Per-object / per-instance pivot: MaterialExpressionTransformPosition with transform_source_type = TRANSFORMPOSSOURCE_INSTANCE, transform_type = TRANSFORMPOSSOURCE_WORLD, input = Constant3Vector(0). Verified visually: StaticMeshActors AND each instance of an InstancedStaticMeshComponent switch on their own. (ObjectPositionWS exposes no Python properties.)
  • Per-instance object SIZE: MaterialExpressionBounds (default type "Instance Local" = the instance's mesh bounds; output 0 = half extent, local units) → MaterialExpressionTransform with transform_source_type = MaterialVectorCoordTransformSource.TRANSFORMSOURCE_INSTANCE, transform_type = MaterialVectorCoordTransform.TRANSFORM_WORLD → world half extent incl. scale. length(.xy) separates small instances from a huge floor (verified). Don't use ObjectRadius / ObjectBounds for this: they are primitive-level, i.e. the whole ISM component.
  • Whole-object / chunked switching without noise: time = field(pivot) + hash(pivot) jitter; for big objects use cubes ci = floor(P / size + 0.37) (the offset keeps the grid off z = 0 floors — an exact boundary there flickers between two cells) and time = field(cell centre) + hash(ci).

Keeping emissive out of Lumen GI (no colour spill onto neighbours)

MaterialExpressionRayTracingQualitySwitch (pins Normal, RayTraced): the RayTraced branch is used by ray-tracing shaders AND by Lumen's surface-cache card capture (LUMEN_CARD_CAPTURE in Common.ush GetRayTracingQualitySwitch). Feed the full emissive to Normal and a reduced/zero emissive to RayTraced → the surface looks the same on screen but adds no bounce light (verified in PIE: a pink cast on the neighbouring wall disappeared). Side effect: it looks dark in ray-traced reflections; screen-space reflections still show it.

Material function with pass-through inputs

FunctionInput: input_name, input_type (unreal.FunctionInputType.FUNCTION_INPUT_VECTOR3, _SCALAR...), sort_priority, use_preview_value_as_default=True + preview_value (the default when the caller leaves it unconnected). FunctionOutput: output_name, sort_priority. Call it from a material: MaterialExpressionMaterialFunctionCall + set_editor_property("material_function", mf); its pins are the function's input/output names.

Injecting a function into existing materials

For each property (BaseColor, Emissive, Roughness, Metallic, Specular, Normal): read the current source node + output name, connect it to the function input of the same name, then connect the function output to the property. Unconnected properties fall back to the input's preview value (use the engine's defaults: BaseColor 0.5 grey (engine default unverified), Roughness 0.5, Specular 0.5, Metallic 0, Normal (0,0,1)). Skip materials that already contain the call. (Not yet tried on Material-Attributes materials.)

Substrate-native materials (Front Material pin)

  • Surfaces: a function with a FUNCTION_INPUT_SUBSTRATE input "Front" → SubstrateSelect(A = Front, B = your Slab, SelectValue = mask) (A when mask ≤ 0.5) → output; insert it between the Front Material source and the pin. Translucent cards: SubstrateWeight(Front, 1 - mask).
  • Single Layer Water (SLW) can't go through Select: "Operator Select cannot be used with the following data flowing through {SLW}". For small water meshes (puddles) leave Front alone and sink the mesh instead: World Position Offset = original + (0, 0, -1000) × mask.
  • Decals (MD_DEFERRED_DECAL, root = SubstrateConvertToDecal, pins DecalMaterial, Coverage): the converter must stay the root, and decals can't use Bounds or instance transforms ("Failed to compile"). Put a function on the Coverage pin instead (coverage × (1 − mask)), mask computed from WorldPosition only (the pixel's own position). Unconnected Coverage = 1 → give the function input a preview value of 1. MEL.get_material_expression_input_names(node) lists the pins; get_inputs_for_material_expression returns sources in the same order (None = unconnected).

Did you catch every master?

Don't trust a list from the material assets: survey what the level actually renders — every MeshComponent (instance counts for ISMs/foliage/PCG) → get_materials() → walk MaterialInstance.parent to the base → count. In a 15k-actor Electric Dreams level this found 10 masters the asset survey had missed (ivy, PCG ground, puddles, signs — the ivy showed as dark streaks on otherwise red cliffs; hiding all 751 decals changed nothing, which ruled decals out). Then force the end state (e.g. a "finish" parameter = 0 so everything is converted), capture, and look for anything not converted.

Flat, unshaded colour on lit materials (graphic "cut-out" look)

Verified with Lumen + Ultra Dynamic Sky: BaseColor 0, Specular 0, Roughness 1, Normal (0,0,1), colour only from Emissive → every pixel of a surface renders the same sRGB (p10 = p90). What still changes it: auto exposure (lock it), vignette (the largest variation), bloom, local exposure, lens flares, fringe, film grain → an unbound PostProcessVolume with high priority overriding vignette_intensity 0, bloom_intensity 0, local_exposure_highlight/shadow_contrast_scale 1, lens_flare_intensity 0, scene_fringe_intensity 0, film_grain_intensity 0 (set override_<name> True on the settings struct, then set the struct back). Height fog still tints distant surfaces; the emissive still bounces into Lumen GI on neighbours (no per-material switch found in 5.8). Calibrate the emissive by measuring rendered pixels (saturated colours shift toward orange/white when too bright; the tonemapper is non-linear, so tune the weak channels separately).

Screen-space colour over sky / whole frame (post-process)

Post-process material: material_domain = MD_POST_PROCESS, blendable_location = BlendableLocation.BL_SCENE_COLOR_AFTER_TONEMAPPING; MaterialExpressionSceneTexture with scene_texture_id = SceneTextureId.PPI_POST_PROCESS_INPUT0 (pin "Color") and PPI_SCENE_DEPTH (mask R); a Custom node lerp(Col.rgb, Red, max(step(SkyDist, Depth) * SkyOn, FullOn)) → Emissive. After tonemapping the input is display-encoded: a target sRGB value / 255 lands exactly ((0.914, 0.165, 0.110) rendered (233, 42, 28)). Depth > ~2 km catches sky, volumetric clouds and the empty horizon. Put it on a PostProcessVolume: settings.weighted_blendables.array += a WeightedBlendable (weight 1, object = material), then set the settings struct back.

Changing a function that 25 materials call (in place)

Rebuilding a material function gives its outputs new ids and disconnects every caller (black surfaces). To change a Custom node inside it: find it (MEL.get_material_function_expressions(mf), match the description), set_editor_property("code", ...), get inputs, append new CustomInputs, set the array back — the existing input links survived (all 15 still wired, checked with MEL.get_inputs_for_material_function_expression(mf, node)), link new sources, update_material_function. Used to add exact-time overrides to a red-takeover mask: MPC vectors (x, y, radius, time); objects whose pivot lies within the radius switch exactly then, ignoring the field (a hero tree, a rock).

Title / overlay in a post-process material (after tonemapping)

  • In BL_SCENE_COLOR_AFTER_TONEMAPPING you work in display values: blends match a compositor (Screen: 1 - (1-bg)(1-a); Add) and colours come out exact (no fog, exposure, tonemapper).
  • A card floating in the world without rendering geometry: WorldPosition (reconstructed from depth) and CameraPositionWS → per-pixel ray; intersect the card plane (centre / right / up from MPC vectors), uv inside → sample; compare the hit distance with the scene distance for occlusion. Works for every camera (five LED screens) and is filtered by the texture's mips (no AA needed after TSR).
  • An animation as frames without the media framework: pack them into one tiled texture (e.g. 300 frames of 2048x128 in 8 x 38 tiles = 16384 x 4864, G8) and pick the tile from MPC Time: frame-exact in Movie Render Queue. Keep the content off the tile borders so mips can't bleed. (Texture2DArray can't be built from Python in 5.8: the factory's InitialTextures and the array's source textures aren't exposed.)
  • ProRes 4444 from After Effects was premultiplied (rgb ≈ alpha for white): use rgb as the Screen/Add amount.

Data textures (e.g. a float field)

  • Write a 16-bit grayscale PNG with numpy + zlib (no PIL needed), import with AssetImportTask (replace_existing=True, automated=True), then set srgb=False, compression_settings=TC_SINGLE_FLOAT (R32F), mip_gen_settings=TMGS_NO_MIPMAPS, never_stream=True, address_x/y=TA_CLAMP. Import goes through Interchange in 5.8.
  • Row 0 of the PNG = V 0 of the texture.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.