Files
gig-line/Tools/ue_character_materials.py
T
zwitschi bea6aa3e05 light: raise the sun so the character leaves building shadow (needs approval)
The skin was never grey. A red test colour reached the render and the slot was confirmed
holding M_Character_Skin, so the material and the assignment were both fine - the body
was simply standing in building shadow and lit almost entirely by the blue skylight
(intensity 2.6). Warm skin under blue fill reads blue-grey.

Raising the sun from pitch -62 to -74, same camera:
  face  (99, 98, 100) -> (131, 121, 117)
  arms  (67, 74, 81)  -> (71, 78, 85)

This touches every approved shot, so it sits on the branch for the owner's approval
rather than being folded in. Also records that the Unreal commandlet silently drops
unknown switches, which is why a --unlit-test flag never took effect.
2026-11-09 14:20:07 +01:00

165 lines
7.4 KiB
Python

# Wire the generated character textures into Unreal materials (0.2.2).
#
# python3 Tools/generate_character_textures.py --out Art/textures/generated
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_materials.py -nullrhi -unattended -nopause -stdout
#
# Why new materials instead of editing the imported ones:
#
# * `unreal.MaterialEditingLibrary` SEGFAULTS in `-run=pythonscript` when it clears
# and rebuilds an existing material graph. The first version of this tool did that
# and crashed the commandlet with no traceback.
# * `unreal.SkeletalMesh` has no `set_material`, so a mesh slot cannot be repointed
# from Python. The pawn therefore assigns the skin material at runtime (see
# AGigLineCharacter) and the hair's existing MaterialInstanceConstant gets a new
# parent, which is a property write rather than a graph edit.
#
# Assets created:
#
# M_Character_Skin diffuse x generated pore detail, roughness 0.55
# M_Character_Hair_Mat strand sheet colour + alpha, masked and two-sided
#
# Result file: /tmp/gigline/character-materials.txt
import os
import unreal
DEST = "/Game/Test/Character"
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
TEXTURE_DIR = os.path.join(_REPO, "Art", "textures", "generated")
RESULT = "/tmp/gigline/character-materials.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_texture(filename):
path = os.path.join(TEXTURE_DIR, filename)
name = os.path.splitext(filename)[0]
existing = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if not os.path.exists(path):
log(f"missing {path}")
return None
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
texture = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
log(f"texture {name}: {'ok' if texture else 'MISSING'}"
+ ("" if existing is None else " (replaced)"))
return texture
def new_material(name):
"""Create an empty material, replacing one from a previous run."""
full = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(full):
unreal.EditorAssetLibrary.delete_asset(full)
tools = unreal.AssetToolsHelpers.get_asset_tools()
material = tools.create_asset(name, DEST, unreal.Material, unreal.MaterialFactoryNew())
# Without this flag Unreal refuses to use the material on a skeletal mesh and
# silently falls back to the default material, which renders near-black here.
material.set_editor_property("used_with_skeletal_mesh", True)
return material
def skin_material(diffuse, detail, unlit_test=False):
material = new_material("M_Character_Skin")
lib = unreal.MaterialEditingLibrary
# Base colour is a constant, not the baked diffuse. Sampling T_Character_Diffuse
# rendered the body black: the MPFB mesh's UVs do not map onto that bake, so every
# lookup fell outside the texture. A constant tone is correct-looking and honest;
# the pore sheet waits until the UVs are verified.
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -420, 0)
# Measured, not guessed: the red test colour reached the render, so the assignment
# was always working - the skin simply reads dark under the demo's key light. This
# tone is lifted to sit in shadow the way the blueprint's skin does.
base.set_editor_property("constant", unreal.LinearColor(0.78, 0.55, 0.44, 1.0))
if unlit_test:
# Diagnostic: unlit renders the constant exactly, so the render tells us whether
# a wrong skin tone comes from lighting/shading or from the material itself.
material.set_editor_property("shading_model", unreal.MaterialShadingModel.MSM_UNLIT)
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_EMISSIVE_COLOR)
else:
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -200, -200)
rough.set_editor_property("r", 0.58)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
log(("M_Character_Skin: UNLIT TEST, emissive " if unlit_test else
"M_Character_Skin: constant skin tone, roughness 0.58 ")
"(baked diffuse not used - it samples black on this mesh)")
return material
def hair_material(strands):
material = new_material("M_Character_Hair_Mat")
lib = unreal.MaterialEditingLibrary
material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_MASKED)
material.set_editor_property("two_sided", True)
sample = lib.create_material_expression(material, unreal.MaterialExpressionTextureSample, -520, 0)
sample.texture = strands
# Alpha only: the strand sheet's RGB is a pale highlight map and using it as the
# base colour made the hair read blond and thin.
lib.connect_material_property(sample, "A", unreal.MaterialProperty.MP_OPACITY_MASK)
colour = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -520, 220)
colour.set_editor_property("constant", unreal.LinearColor(0.030, 0.020, 0.014, 1.0))
lib.connect_material_property(colour, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -220, -180)
rough.set_editor_property("r", 0.40)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
log("M_Character_Hair_Mat: dark constant colour, strand sheet as opacity mask, "
"masked two-sided, roughness 0.40")
return material
def main(unlit_test=False):
diffuse = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
log(f"diffuse: {'ok' if diffuse else 'MISSING'}")
detail = import_texture("T_Character_SkinDetail.png")
strands = import_texture("T_Character_Hair.png")
if diffuse and detail:
skin_material(diffuse, detail, unlit_test=unlit_test)
if strands:
hair = hair_material(strands)
# Repoint the imported instance rather than editing its graph.
instance = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Hair")
if isinstance(instance, unreal.MaterialInstanceConstant):
instance.set_editor_property("parent", hair)
unreal.MaterialEditingLibrary.update_material_instance(instance)
log("M_Character_Hair instance: parent -> M_Character_Hair_Mat")
else:
log(f"M_Character_Hair is {instance.get_class().get_name() if instance else 'missing'}, "
"not an instance - skipped")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
lines.append("VERDICT: PASS")
try:
# Environment, not a command-line flag: the commandlet drops unknown switches, so
# the flag never reached Python. An env var is inherited.
main(unlit_test=os.environ.get("GIGLINE_UNLIT_TEST") == "1")
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")