--- name: materials display_name: Material System description: Create and edit materials and material instances — graph nodes, parameters, functions, custom HLSL, and instance overrides (MaterialService + MaterialNodeService). Use when the user asks to create or edit a material/material instance, wire material nodes, add material parameters, set blend/shading modes, or recreate a material graph. For landscape materials load landscape-materials. Also use for generated organic PBR detail, texture seams, projection coordinates, and surface-normal diagnosis. vibeue_classes: - MaterialService - MaterialNodeService unreal_classes: - EditorAssetLibrary keywords: - material - shader - expression - node - parameter - texture --- > 🧠 **Brains complement:** IF an `unreal-engine-skills-manager` tool (external MCP) exists in this session, call it with `{action: "load", skill: "materials-and-shaders"}` for UE domain knowledge on this topic — correct APIs, architecture, best practices — and treat it as the rubric for any review / "best practices" question. If no such tool is available (e.g. running under Claude Code or Codex without that MCP), skip this line entirely and proceed with this skill alone — do NOT attempt the call. # Material System Skill For generated textures on large organic surfaces, seams after triplanar projection, or overly strong tissue relief, read [generated organic surfaces](references/generated-organic-surfaces.md). For gray/default surfaces after mesh reimport, use the asset-management skill's LOD-section mapping helper before changing the shader or buying new textures. ## Critical Rules ### 🚨 Connecting Function Call Inputs: Use Bare Names, Not Display Names `get_expression_pins(mat, expr_id)` (MaterialNodeService) returns input pins with **display labels including type suffixes** like `"TextureObject (T2d)"`, `"TextureSize (V3)"`, `"Use High Quality Normals (SB)"`. Connections are made through the **engine** `MaterialTools.connect_expressions` call, and UE's connect API matches against the *bare* input name only — **never pass these display labels** as `to_input_name`: ```python MT = "editor_toolset.toolsets.material.MaterialTools" # ❌ WRONG — display label with type suffix; connection fails silently or gets ignored call_tool(tool_name="connect_expressions", toolset_name=MT, arguments={"from_expression": src_id, "from_output_name": "", "to_expression": fn_id, "to_input_name": "TextureObject (T2d)"}) # ✅ CORRECT — bare name call_tool(tool_name="connect_expressions", toolset_name=MT, arguments={"from_expression": src_id, "from_output_name": "", "to_expression": fn_id, "to_input_name": "TextureObject"}) ``` The authoritative source for input names is `get_function_info(function_path).inputs` — those are the bare names (`TextureObject`, `TextureSize`, `WorldPosition`, etc.). For built-in expressions, use `get_inputs_for_material_expression` from `unreal.MaterialEditingLibrary`. > ⚠️ Why this is easy to miss: `get_expression_pins` returns `is_connected=True` for both real and phantom connections, and our service used to silently produce phantom wires when given display names. The wires would appear in the graph export but the shader compiler would never see the texture flow through. Always verify with `unreal.MaterialEditingLibrary.get_used_textures(mat)` after wiring — if it returns 0 on a material that samples textures, your connections are phantom. ### 🚨 Verifying Wiring: Use `get_material_diagnostics` **`MaterialNodeService.get_material_diagnostics(path)` is the canonical way to verify a material is sampling textures and compiling cleanly.** It returns the real compile errors, the textures the shader actually references, and node-type counts. Always run it after non-trivial graph rewiring: ```python diag = unreal.MaterialNodeService.get_material_diagnostics("/Game/Materials/M_Foo") assert diag.success print(f"compiled ok: {diag.is_compiled_ok}") print(f"compile errors ({len(diag.compile_errors)}):") for e in diag.compile_errors: print(f" {e}") print(f"referenced textures ({len(diag.referenced_texture_paths)}):") for t in diag.referenced_texture_paths: print(f" {t}") print(f"expression count: {diag.expression_count}") print(f"texture sample count: {diag.texture_sample_count}") print(f"texture object parameter count: {diag.texture_object_parameter_count}") print(f"function call count: {diag.function_call_count}") ``` Sample compile errors caught by this: - `"(Function WorldAlignedTexture) (Node TextureSample) Sampler type is Color, should be Masks for /Game/.../T_xevtfjz_2K_ORM"` — ORM/data textures need `SAMPLERTYPE_Masks` or `SAMPLERTYPE_LinearColor`, not the default `SAMPLERTYPE_Color`. - Type mismatch errors when wiring scalars to vector inputs without proper conversion. - Missing required inputs on function calls. **Why prefer this over the older signals:** `unreal.MaterialEditingLibrary.get_used_textures(mat)` is **unreliable** for multi-branch graphs (BC + Normal + ORM, or anything with ComponentMask after a function-call output). It returns `0` even when the material is sampling textures correctly. Don't use it as proof of broken wiring. Visual confirmation via the material editor preview is also useful but harder. Asset-editor capture now goes through the **engine** `EditorAppToolset` (toolset_name `EditorToolset.EditorAppToolset` via `call_tool`) — it opens, focuses, and captures the asset editor. (The old `unreal.ScreenshotService` and its `capture_asset_editor` are gone.) After the capture, `attach_image(...)` the returned file to inspect it. > ⚠️ Best-effort focus: when many asset editors are already open as tabs, the editor tab may not switch reliably. Close other asset editors first if the screenshot keeps catching the wrong tab: > > ```python > ed = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem) > for path in [other_open_paths]: > ed.close_all_editors_for_asset(unreal.load_asset(path)) > ``` ### 🚨 Tiling on Basic Shapes: Prefer a Child MI with `Tiling` Override For Megascans `M_MS_Srf` and similar surface materials with a `Tiling` scalar parameter, the cleanest per-actor tiling fix is a **child material instance with `Tiling` overridden** — *not* a UV transform on the mesh. UV transforms apply to all faces uniformly and break orientation; the parent material's UV mapping is already correct per face. ```python # ✅ CORRECT pattern for "this disc shows brick too sparse / too dense" # Material instances are created and overridden through the engine MaterialInstanceTools toolset. MIT = "editor_toolset.toolsets.material_instance.MaterialInstanceTools" call_tool(tool_name="create_material_instance", toolset_name=MIT, arguments={"parent_material": "/Game/Fab/Megascans/.../MI_xevtfjz", "asset_name": "MI_xevtfjz_Disc", "folder_path": "/Game/Materials/"}) ip = "/Game/Materials/MI_xevtfjz_Disc" call_tool(tool_name="set_scalar_parameter", toolset_name=MIT, arguments={"instance_path": ip, "parameter_name": "Tiling", "value": 4.0}) unreal.EditorAssetLibrary.save_asset(ip) # assign ip to the actor ``` For triplanar / world-aligned (where the same material works on cube, sphere, cylinder with no UV concerns), build a master material wrapping `WorldAlignedTexture` / `WorldAlignedNormal` (functions live under `/Engine/Functions/Engine_MaterialFunctions01/Texturing/`). Use `TextureObjectParameter` (not `TextureSampleParameter2D`) to feed the `TextureObject` input on those functions — the function brings its own sampler. Feed `TextureSize` from a `Constant3Vector` or `Multiply(Scalar, Constant3Vector(1,1,1))`; **a bare scalar will broadcast incorrectly to V3** in this input slot. ### 🚨 Inspect Before Modifying Existing Materials Before adding, removing, or reconnecting nodes in an **existing** material, you **MUST** export and review the current graph first: ```python import unreal, json path = "/Game/Materials/M_Existing" # Step 1: Get high-level info (blend mode, shading model) info = unreal.MaterialService.get_material_info(path) print(f"Blend: {info.blend_mode}, Shading: {info.shading_model}") # Step 2: Export the full node graph graph = json.loads(unreal.MaterialNodeService.export_material_graph(path)) print(f"Expressions: {len(graph['expressions'])}") print(f"Connections: {len(graph['connections'])}") print(f"Output connections: {len(graph['output_connections'])}") # Step 3: Review what already exists for expr in graph['expressions']: name = expr.get('parameter_name') or expr.get('class') print(f" [{expr['id']}] {expr['class']} - {name}") for oc in graph['output_connections']: print(f" Output '{oc['property']}' ← expr {oc['expression_id']}") ``` **Why this matters:** - The material may already have nodes connected to the output you want to modify - Blindly adding nodes creates duplicates and orphaned connections - You need to know what IDs exist so you can reconnect or replace, not just append - `export_material_graph` returns the ground truth — use it to plan your edits **Workflow for modifying existing materials:** 1. **Export** the graph JSON and review expressions + connections 2. **Plan** your changes — identify which nodes to keep, replace, or add 3. **Disconnect** existing connections if needed before reconnecting 4. **Add** only the new nodes that don't already exist 5. **Reconnect** outputs to their correct sources 6. **Compile** and save ### 🚨 Return Types — `create_parameter`/`create_function_call` Return `.id`, NOT the Raw Object Material/instance **creation** now goes through the engine toolsets (`MaterialTools.create_material` returns the **Material object** itself — not a result-with-`.asset_path`; `MaterialInstanceTools` creates the instance), so build the asset path yourself from `folder_path + asset_name`: ```python # Engine: create_material returns the Material object (no .asset_path) — reference by path you chose. call_tool(tool_name="create_material", toolset_name="editor_toolset.toolsets.material.MaterialTools", arguments={"folder_path": "/Game/Materials/", "asset_name": "M_MyMat"}) path = "/Game/Materials/M_MyMat" # Engine: create the instance via MaterialInstanceTools, then reference the path you chose. call_tool(tool_name="create_material_instance", toolset_name="editor_toolset.toolsets.material_instance.MaterialInstanceTools", arguments={"parent_material": "/Game/Materials/M_Base", "asset_name": "MI_Red", "folder_path": "/Game/Materials/"}) instance_path = "/Game/Materials/MI_Red" ``` The surviving VibeUE graph-authoring calls **still** return **result objects** — extract the field: ```python # create_parameter / create_function_call → MaterialExpressionInfo expr = unreal.MaterialNodeService.create_parameter(path, "Vector", "BaseColor", "Surface", "1,0,0,1", -400, 0) node_id = expr.id # ← use .id, NOT expr itself ``` > ⚠️ Passing a `MaterialExpressionInfo` where a string is expected gives: > `TypeError: Nativize: Cannot nativize 'MaterialExpressionInfo' as 'String'` ### ⚠️ Two Services - **MaterialService** - Create materials, instances, manage properties - **MaterialNodeService** - Build material graphs, expressions, parameters ### ⚠️ Compile After Graph Changes Full graph workflow: **create nodes → connect nodes → connect to material output → compile → save.** ```python mns = unreal.MaterialNodeService expr = mns.create_parameter(path, "Vector", "BaseColor", ...) # Wire an expression output straight into a material output (BaseColor, Normal, Roughness, ...). # output_name="" selects output 0; property accepts "BaseColor" or "MP_BaseColor" (case-insensitive). mns.connect_expression_to_output(path, expr.id, "", "BaseColor") # returns True only after a read-back # ...and to clear it again: mns.disconnect_output(path, "BaseColor") unreal.MaterialService.compile_material(path) # REQUIRED after graph changes unreal.EditorAssetLibrary.save_asset(path) ``` > ⚠️ **expr.id is session-specific** (`"_"`) — it changes on editor restart. For a > durable reference use `expr.object_path`, which every id-based call (`connect_expression_to_output`, > `get_expression_details`, `set_expression_property`, ...) also accepts. Empty / bogus / foreign ids > are now rejected (they used to silently resolve to expression index 0). The engine's own `MaterialTools.connect_to_output` (`call_tool`, `material_property="MP_BaseColor"`) still works and is interchangeable — use whichever fits the surrounding code. ### ⚠️ Parameter Types | Type | Use | Example Value | |------|-----|---------------| | `Scalar` | Single float | `0.5` | | `Vector` | Color/vector | `(R=1.0,G=0.0,B=0.0,A=1.0)` | | `Texture` | Texture parameter | `/Game/T_Diffuse.T_Diffuse` | | `TextureObject` | Texture object (no sampler) | `/Game/T_Diffuse.T_Diffuse` | | `StaticSwitch` | Static bool switch | `true` or `false` | ### ⚠️ Material Output Names - `BaseColor`, `Metallic`, `Specular`, `Roughness` - `EmissiveColor`, `Normal`, `Opacity`, `OpacityMask` - `WorldPositionOffset`, `AmbientOcclusion` ### ⚠️ Discovering Node Types & Categories To answer "what nodes / categories are available", use the MaterialNodeService discovery calls — do **not** hunt through `discover_python_module` or a material-editor palette API (there isn't a Python one): ```python # All material-expression categories (Math, Color, Constants, Coordinates, Parameters, ...) cats = unreal.MaterialNodeService.get_categories() # Search node types by name/category (returns MaterialExpressionTypeInfo: # class_name, display_name, category, description, is_parameter) types = unreal.MaterialNodeService.discover_types(category="", search_term="Add", max_results=100) ``` To create a node, pass the bare class name minus the `MaterialExpression` prefix (`"Add"`, `"Multiply"`, `"Constant3Vector"`, `"OneMinus"`, `"LinearInterpolate"`/`"Lerp"`) as `expression_class` to the engine `MaterialTools.add_expression` call (single node), or to the surviving `MaterialNodeService.batch_create_expressions` (many nodes in one transaction). ### ⚠️ Check Node Existence ```python # WRONG - crashes if not found add_node = next((n for n in nodes if "Add" in n.display_name)) # CORRECT add_node = next((n for n in nodes if "Add" in n.display_name), None) if add_node: pins = unreal.MaterialNodeService.get_expression_pins(mat_path, add_node.id) ``` ### ⚠️ Property Names Use `discover_python_class()` first (batch: `class_name='unreal.A, unreal.B'`): - `MaterialExpressionTypeInfo` uses `display_name`, NOT `name` - `MaterialOutputConnectionInfo` uses `connected_expression_id`, NOT `expression_id` Complete field lists (don't guess): | Struct | Fields | |---|---| | `MaterialExpressionInfo` (from `list_expressions` / `get_expression_info`) | `id`, `class_name`, `display_name`, `category`, `description`, `pos_x`, `pos_y`, `is_parameter`, `parameter_name`, `input_names`, `output_names` — the class is `class_name`, **not `expression_class`** | | `MaterialNodePropertyInfo` (from expression property lists) | `name`, `value`, `property_type`, `is_editable` — it's `name` here, **not `property_name`** | | `MaterialSummary` (from `MaterialService.summarize`) | `material_name`, `material_path`, `blend_mode`, `shading_model`, `material_domain`, `two_sided`, `expression_count`, `parameter_count`, `parameter_names`, `key_properties`, `editable_properties` — it's `material_name`/`material_path`/`parameter_count`, **not `name`/`path`/`num_parameters`** | | `MaterialDetailedInfo` (from `MaterialService.get_material_info`) | `material_name`, `material_path`, `blend_mode`, `shading_model`, `material_domain`, `two_sided`, `expression_count`, `texture_sample_count`, `is_material_instance`, `parent_material`, `parameters` — it's `two_sided`, **not `is_two_sided`** | | `MaterialPropertyInfo_Custom` (from `MaterialService.list_properties` / `get_property_info`) | `property_name`, `display_name`, `property_type`, `current_value`, `allowed_values`, `category`, `is_editable`, `is_advanced` — it's `property_name`/`current_value`, **not `name`/`value`** (differs from `MaterialNodePropertyInfo` above) | ### 🚨 Setting Material Properties: `set_property` Takes Display OR Internal Name `MaterialService.set_property(path, name, value)` / `get_property` accept **either** the editor display name (`"Two Sided"`, `"Blend Mode"`, `"Opacity Mask Clip Value"`) **or** the internal property name (`"TwoSided"`, `"BlendMode"`, `"OpacityMaskClipValue"`) — matching is case-insensitive and ignores spaces. Both of these work: ```python unreal.MaterialService.set_property(path, "Two Sided", "true") # display name unreal.MaterialService.set_property(path, "BlendMode", "BLEND_Masked") # internal name unreal.MaterialService.set_property(path, "OpacityMaskClipValue", "0.33") unreal.MaterialService.compile_material(path) # enum/blend changes need a recompile unreal.MaterialService.save_material(path) ``` > ⚠️ `set_property` returns a **bool** (`True`/`False`), it does **not** raise on an unknown > property — a `False` means the name didn't resolve or the value didn't parse. Don't assume > success: check the return, or verify with `get_property` / `summarize` afterward. Enum values > are the `BLEND_*` / `MSM_*` / `MD_*` identifiers (see below), not the friendly labels. **Discover legal enum values, don't guess:** `list_properties` / `get_property_info` populate `allowed_values` for enum properties — including the classic `TEnumAsByte` ones (`BlendMode`, `ShadingModel`, `MaterialDomain`). Read them instead of probing `unreal` for the enum class: ```python for p in unreal.MaterialService.list_properties(path): if p.property_name == "BlendMode": print(p.allowed_values) # ['BLEND_Opaque','BLEND_Masked','BLEND_Translucent','BLEND_Additive', ...] ``` Common material property internal names: `TwoSided`, `BlendMode`, `ShadingModel`, `MaterialDomain`, `OpacityMaskClipValue`, `bUsedWithStaticLighting`, `DitheredLODTransition`. --- ## Task Index | Task | Workflow | Sample script | |------|----------|---------------| | Create a material (+ params) | `workflows.md` → Create a basic material | `scripts/create_material.txt` | | Create a material instance | `workflows.md` → Create a material instance | `scripts/create_instance.txt` | | Add texture / math / function / HLSL nodes | `workflows.md` | — | | Build a graph with batch ops | `workflows.md` → Batch create/connect/set | `scripts/material_graph_batch.txt` | | Inspect/export an existing graph | `reference.md` → Export JSON schema | `scripts/export_graph.txt` | | Recreate a material from export | `reference.md` → Recreate from export | — | | Verify wiring / compile | Critical Rules → `get_material_diagnostics` | — | ## Sub-docs - **`workflows.md`** — create material/instance, texture/math/function/HLSL/collection/switch nodes, set properties, and batch create/connect/set. - **`reference.md`** — parameter types, output names, enum formats, the `export_material_graph` JSON schema, recreate-from-export steps, and Material Function authoring. ## Verification After non-trivial wiring run `MaterialNodeService.get_material_diagnostics(path)` and confirm `is_compiled_ok` and the expected `referenced_texture_paths` — do **not** rely on `get_used_textures` (unreliable for multi-branch graphs). `compile_material` + `save_asset` to persist. ## Related skills - **landscape-materials** — `LandscapeLayerBlend` nodes. - **landscape-auto-material** — production landscape materials (functions, RVT, instances). ## Additional gotchas - `StaticMesh.get_editor_property("static_materials")` returns struct COPIES, so writing the array back is a silent no-op; use `set_material(index, mat)` and confirm with `get_material(index)`. - `MEL.set_material_instance_scalar_parameter_value` returns False on success, and the static-switch setter returns False while the write still lands — verify by readback; MI reads return 0.0 and writes no-op while PIE runs. - Changing `body_setup.collision_trace_flag` does not rebuild the physics mesh; toggle `StaticMeshEditorSubsystem.enable_section_collision` off then on. - Enumerate expressions with `ObjectIterator(unreal.MaterialExpression)` filtered on `path.startswith(mat_path + ":")` (class-specific iterators miss nodes); `delete_material_expression` enables in-place rebuilds that keep instance overrides when parameter names match. - Pin names: StaticSwitchParameter inputs are `True`/`False`; LandscapeLayerBlend inputs are `"Layer "`/`"Height "`; `LayerBlendInput` fields are invisible to `dir()` but `set_editor_property("layer_name"|"blend_type"|"preview_weight")` works. - Gate optional master-material features behind STATIC switches (a scalar "off" still samples); measure with `MEL.get_statistics`. A material used on a Niagara sprite renderer needs `bUsedWithNiagaraSprites`. - Delete a material with `AssetDiscoveryService.delete_asset_unattended`; it refuses (and returns a result struct naming the holder) when the material is still referenced or held by a Python global, instead of wedging on a modal dialog.