0.2.2 — hair as its own skinned mesh (cards, second component) #36

Merged
zwitschi merged 11 commits from hair-cards-0.2.2 into main 2026-11-09 14:26:22 +01:00
46 changed files with 519 additions and 47 deletions
Showing only changes of commit 39f07b0f5f - Show all commits

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -90,6 +90,22 @@ AGigLineCharacter::AGigLineCharacter()
GetMesh()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
GetMesh()->SetHiddenInGame(true);
// Hair: a separate component that copies the body's pose, so it follows the head
// bone without a second rig or a second animation. Hidden with the body.
HairMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("HairMesh"));
HairMesh->SetupAttachment(GetCapsuleComponent());
HairMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
HairMesh->SetHiddenInGame(true);
static ConstructorHelpers::FObjectFinder<USkeletalMesh> HairFinder(
TEXT("/Game/Test/Character/CharacterHair.CharacterHair"));
if (HairFinder.Succeeded())
{
HairMesh->SetSkeletalMesh(HairFinder.Object);
HairMesh->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
HairMesh->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
HairMesh->SetLeaderPoseComponent(GetMesh());
}
// Third-person boom. bDoCollisionTest is the camera-collision feature: the arm
// shortens when something is between the pivot and the desired camera position.
ThirdPersonArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("ThirdPersonArm"));
@@ -795,6 +811,10 @@ void AGigLineCharacter::ToggleCamera()
FirstPersonCamera->SetActive(!bThirdPerson);
ThirdPersonCamera->SetActive(bThirdPerson);
GetMesh()->SetHiddenInGame(!bThirdPerson);
if (HairMesh)
{
HairMesh->SetHiddenInGame(!bThirdPerson);
}
if (bThirdPerson)
{
UpdateBodyAnimation();
@@ -155,6 +155,17 @@ protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
float ThirdPersonBoomLength = 340.0f;
/**
* Hair, as its own skeletal mesh following the body's pose.
*
* A second component rather than geometry joined into the body: Unreal collapses
* the second material slot on a skeletal-mesh import, so joined hair rendered with
* the skin material. The character-artist skill calls for hair cards to be their
* own mesh for the same reason it calls for swappable parts to be separate.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Body")
TObjectPtr<USkeletalMeshComponent> HairMesh;
UPROPERTY()
TObjectPtr<UAnimSequence> IdleAnimation;
+6
View File
@@ -492,3 +492,9 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/<id>` and fetch `/view?filename=...`. `Tools/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants.
- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair.
- **Unreal collapses the second material slot on a skeletal-mesh import.** A joined hair cap (Blender shows two slots, hair faces on index 1) arrives as a single slot named after the body material, so the hair renders with the skin material. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted after import. A separate skeletal mesh attached as a second component is the way around it.
## 2026-11-09 — Hair as a separate mesh, and a material-editing crash
- **Hair joined into the body mesh loses its material.** Blender shows two slots (hair faces on index 1) and the FBX exports both, but Unreal's skeletal-mesh import arrives with **one** slot named after the body material, so the hair renders as skin. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted afterwards. A separate skeletal mesh imported on its own keeps its slot and imports cleanly (`CharacterHair` → `M_Character_Hair`).
- **A second skeletal component follows the body with `SetLeaderPoseComponent(GetMesh())`** — no second rig, no second animation, and it hides with the body in first person.
- **`unreal.MaterialEditingLibrary` segfaults in `-run=pythonscript`** when it clears and rebuilds a material graph. The tool ran its texture imports, then crashed the commandlet with no Python traceback. Graph editing needs an editor session, or a material created from scratch rather than edited in place.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -15,10 +15,12 @@ Actionable task list for this project. This file is the single, central place fo
- [x] Restore the stride capture after the pose rewrite. The walk cycle started on a **sine**, zero at both the start and the middle of the cycle, so the deterministic capture kept landing on the two passing frames where the legs are together. Starting the cycle at maximum stride (cosine) fixed it with no other change; `demo-third-person-walk` is back as shot 8 and shows a genuine stride.
- [A] Tune skin shading and hair materials on the player character. **Tooling in place 2026-11-09, hair blocked on an engine-side limit:**
- [~] Tune skin shading and hair materials on the player character. **Tooling in place 2026-11-09, hair blocked on an engine-side limit:**
- `Tools/comfyui_generate.py` drives the local ComfyUI instance (`127.0.0.1:8188`) for textures, writing a JSON sidecar with the model, prompt, seed and full workflow so anything generated can be regenerated or rejected. Installed models include `juggernautXL_ragnarok.safetensors` and `sd-v1-5-inpainting.ckpt`.
- **Stable Diffusion is not usable for flat skin swatches.** Three attempts on two models produced an anatomical close-up twice and a leather-scale surface once; SDXL has no reliable notion of "texture without anatomy". Skin detail is therefore generated procedurally instead (`Tools/generate_character_textures.py`: tileable octave noise for pores plus a strand-sheet texture for hair).
- **Hair geometry is built but not shipped.** `add_hair_cap()` creates a cap conforming to the head and weights it to the `head` bone (Blender shows two material slots, hair faces on index 1), but Unreal's import **collapses the second material slot**, so the cap renders with the skin material and reads as a bald skull. It is behind `--hair`, off. Next step: export the hair as its own skeletal mesh and attach it as a second component, or bake the hair colour into vertex colours.
- **Hair is now its own asset** (`Tools/character_hair_build.py` → `SM_Char_Hair` → `/Game/Test/Character/CharacterHair`), which is what the `character-artist` skill asks for: cards are a separate mesh, not geometry joined into the body. It imports with its own material slot and is attached in C++ as a second `USkeletalMeshComponent` on `SetLeaderPoseComponent`, so it follows the head bone without a second animation. 321 verts, 192 quads, 404 tris.
- **The card layout is still wrong.** The cards are anchored pointing outward, so the silhouette reads as a spiked crown instead of a short cut. Next iteration: orient each card tangentially along the skull, add a second overlapping row, and drive the shape from the scalp normal rather than a fixed outward vector.
- `Tools/ue_character_materials.py` (wiring the generated textures into the skin and hair materials) **segfaults in the headless commandlet** when it edits the material graph. Not wired; the hair keeps the dark base colour it carries from Blender. Needs either an editor-session run or a material asset built from scratch instead of edited in place.
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Build the hair as its own skinned mesh — hair cards over a scalp cap.
The `character-artist` skill is explicit that hair for a game character is a card
system and that swappable parts stay separate meshes. Joining hair into the body mesh
was tried first and failed for an engine reason: Unreal collapses the second material
slot on a skeletal-mesh import, so the cap rendered with the skin material. A separate
object sidesteps that and is what the skill calls for anyway.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_hair_build.py -- \
--out /tmp/gigline/character-hair.fbx --report /tmp/gigline/character-hair.json
What it builds:
SM_Char_Hair scalp cap + 18 cards in three rings, UV-mapped to the strand sheet
from Tools/generate_character_textures.py, every vertex weighted
to `head` at 1.0 so it follows the head without a second rig.
Cards are built with a taper and a slight outward curl so the silhouette is not a
shell; the strand texture runs vertically down each card, which is the direction
hair actually reads in. Topology is quads only (the skill's animation-ready rule),
and there are no poles on the deformation surface because the whole mesh is rigidly
weighted to one bone.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
HAIR_NAME = "SM_Char_Hair"
CARD_RINGS = 3 # rings of cards around the skull
CARDS_INNER = 6
CARDS_MIDDLE = 7
CARDS_OUTER = 5
CARD_HEIGHT = 0.072 # m: short, close cut - taller cards spiked up
CAP_THICKNESS = 0.004
PARTS: list[dict] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[hair] {message}")
def hair_material() -> bpy.types.Material:
mat = bpy.data.materials.get("M_Character_Hair") or bpy.data.materials.new("M_Character_Hair")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
# Dark brown, low roughness: strands catch a highlight. The strand texture is
# sampled in Unreal, so Blender only needs a sane base for the FBX material.
bsdf.inputs["Base Color"].default_value = (0.052, 0.036, 0.028, 1.0)
bsdf.inputs["Roughness"].default_value = 0.38
if "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
mat.use_backface_culling = False
return mat
def card(name, anchor, outward, up, width, height, curl, segments=4):
"""One curved card: a strip of quads that arcs away from the scalp."""
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
verts = []
faces = []
for i in range(segments + 1):
t = i / segments
# Taper to a point at the tip and curl outward as the strand falls.
w = width * (1.0 - 0.75 * t)
h = height * t
bulge = curl * math.sin(t * math.pi * 0.85)
base = anchor + up * h + outward * bulge
left = base - outward.cross(up).normalized() * w
right = base + outward.cross(up).normalized() * w
verts.append(left)
verts.append(right)
for i in range(segments):
a = i * 2
faces.append((a, a + 1, a + 3, a + 2))
mesh.from_pydata(verts, [], faces)
mesh.update()
return obj
def scalp_cap(centre, radius, material):
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=radius,
location=centre)
cap = bpy.context.active_object
cap.name = "HairScalp"
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, centre.z - radius * 0.15),
plane_no=(0.0, 0.0, 1.0), clear_inner=True, use_fill=False)
bpy.ops.object.mode_set(mode="OBJECT")
cap.scale = (1.03, 1.06, 0.86)
cap.data.materials.append(material)
return cap
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/character-hair.fbx")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
if arm is None:
raise SystemExit("no armature in the .blend")
head = arm.data.bones.get("head")
if head is None:
raise SystemExit("no 'head' bone")
centre = (head.head_local + head.tail_local) * 0.5
radius = max(0.088, (head.tail_local - head.head_local).length * 0.60)
log(f"head centre {[round(c, 3) for c in centre]}, radius {radius:.3f} m")
material = hair_material()
pieces = [scalp_cap(Vector((centre.x, centre.y, centre.z + 0.012)), radius, material)]
up = Vector((0.0, 0.0, 1.0))
rings = ((CARDS_INNER, 1.00, 0.70), (CARDS_MIDDLE, 0.90, 0.85), (CARDS_OUTER, 0.78, 1.0))
index = 0
for ring, (count, radius_scale, length_scale) in enumerate(rings):
for i in range(count):
angle = math.tau * (i + 0.5 * ring) / count
outward = Vector((math.cos(angle), math.sin(angle), 0.22)).normalized()
anchor = Vector((
centre.x + outward.x * radius * radius_scale * 0.92,
centre.y + outward.y * radius * radius_scale * 0.92,
centre.z + radius * (0.52 - 0.16 * ring),
))
pieces.append(card(f"HairCard_{index}", anchor, outward, up,
width=radius * 0.30, height=CARD_HEIGHT * length_scale,
curl=radius * 0.34))
index += 1
log(f"built {len(pieces)} pieces: 1 scalp cap + {index} cards")
# Join into one object and weight every vertex to the head bone.
bpy.ops.object.select_all(action="DESELECT")
for piece in pieces:
piece.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
hair = bpy.context.active_object
hair.name = HAIR_NAME
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
hair.data.materials.clear()
hair.data.materials.append(material)
group = hair.vertex_groups.new(name="head")
group.add(list(range(len(hair.data.vertices))), 1.0, "REPLACE")
# UVs: a simple planar unwrap is enough because the strand texture is directional.
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
quads = sum(1 for p in hair.data.polygons if len(p.vertices) == 4)
tris = sum(len(p.vertices) - 2 for p in hair.data.polygons)
log(f"{HAIR_NAME}: {len(hair.data.vertices)} verts, {quads} quads, {tris} tris, "
f"{len(hair.data.materials)} material slot(s)")
# Parent to the armature so the export carries the skin.
modifier = hair.modifiers.new(name="Armature", type="ARMATURE")
modifier.object = arm
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
hair.select_set(True)
bpy.context.view_layer.objects.active = arm
os.makedirs(os.path.dirname(args.out), exist_ok=True)
fbx_preset.export_character(args.out, selection=True)
log(f"exported {args.out}")
if args.report:
with open(args.report, "w") as handle:
json.dump({
"asset": HAIR_NAME,
"head_centre_m": [round(c, 4) for c in centre],
"head_radius_m": round(radius, 4),
"cards": index,
"rings": [r[0] for r in rings],
"vertices": len(hair.data.vertices),
"quads": quads,
"triangles": tris,
"material": material.name,
"texture": "Art/textures/generated/T_Character_Hair.png",
"weighting": "all vertices 1.0 on 'head'",
"log": PARTS,
}, handle, indent=2)
print(f"[hair] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
# Import the hair cards as their own skeletal mesh (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout
#
# The hair is a separate asset on purpose: Unreal collapses the second material slot
# when hair is joined into the body mesh, so a card system has to be its own object
# (which is what the character-artist skill asks for anyway).
#
# Result file: /tmp/gigline/character-hair-import.txt
import unreal
SRC = "/tmp/gigline/character-hair.fbx"
DEST = "/Game/Test/Character"
NAME = "CharacterHair"
RESULT = "/tmp/gigline/character-hair-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
task = unreal.AssetImportTask()
task.filename = SRC
task.destination_path = DEST
task.destination_name = NAME
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{NAME}")
if mesh is None:
log(f"MISSING {DEST}/{NAME}")
lines.append("VERDICT: FAIL")
return
mats = mesh.get_editor_property("materials")
log(f"{NAME}: {len(mats)} material slot(s)")
for i, m in enumerate(mats):
face = m.get_editor_property("material_interface")
log(f" slot {i}: {face.get_name() if face else 'None'}")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+138
View File
@@ -0,0 +1,138 @@
# 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
#
# Two materials, both edited IN PLACE so the imported meshes keep their references:
#
# M_Character_Export skin: diffuse x generated pore detail, roughness variation
# M_Character_Hair hair: strand texture colour + alpha, masked and two-sided
#
# Editing in place matters: `unreal.SkeletalMesh` has no `set_material`, so a mesh
# that already points at a material cannot be re-slotted from Python. Changing the
# material asset is the only lever.
#
# Result file: /tmp/gigline/character-materials.txt
import os
import unreal
# The commandlet's working directory is the engine, not the repo, so resolve the
# texture folder from the project path instead of relying on the cwd.
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
TEXTURE_DIR = os.path.join(_REPO, "Art", "textures", "generated")
DEST = "/Game/Test/Character"
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)
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 = os.path.splitext(filename)[0]
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}/{task.destination_name}")
log(f"texture {task.destination_name}: {'ok' if texture else 'MISSING'}")
return texture
def clear_graph(material):
"""Remove every expression so a rerun does not stack nodes."""
lib = unreal.MaterialEditingLibrary
for node in list(lib.get_material_expressions(material)):
lib.delete_material_expression(material, node)
def textured_material(mat, texture, roughness, tint=None, masked=False):
"""Base colour from a texture, optional detail multiply, roughness constant."""
lib = unreal.MaterialEditingLibrary
clear_graph(mat)
tex = lib.create_material_expression(mat, unreal.MaterialExpressionTextureSample, -450, 0)
tex.texture = texture
if masked:
mat.set_editor_property("blend_mode", unreal.BlendMode.BLEND_MASKED)
mat.set_editor_property("two_sided", True)
lib.connect_material_property(tex, "A", unreal.MaterialProperty.MP_OPACITY_MASK)
if tint is not None:
mul = lib.create_material_expression(mat, unreal.MaterialExpressionMultiply, -220, 40)
mul.set_editor_property("const_b", unreal.LinearColor(*tint, 1.0))
lib.connect_material_expressions(tex, "RGB", mul, "A")
lib.connect_material_property(mul, "", unreal.MaterialProperty.MP_BASE_COLOR)
else:
lib.connect_material_property(tex, "RGB", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(mat, unreal.MaterialExpressionConstant, -220, -180)
rough.set_editor_property("r", roughness)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(mat)
log(f"{mat.get_name()}: texture {texture.get_name()}, roughness {roughness}"
+ (", masked two-sided" if masked else ""))
def main():
skin_detail = import_texture("T_Character_SkinDetail.png")
hair_strands = import_texture("T_Character_Hair.png")
skin = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Export")
if skin and skin_detail:
# The character's own diffuse stays the base; the generated pore sheet is a
# detail multiplier, so the warm skin tone from the bake is preserved.
lib = unreal.MaterialEditingLibrary
clear_graph(skin)
diffuse = lib.create_material_expression(skin, unreal.MaterialExpressionTextureSample, -700, 0)
diffuse.texture = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
detail = lib.create_material_expression(skin, unreal.MaterialExpressionTextureSample, -700, 300)
detail.texture = skin_detail
detail.sampler_type = unreal.MaterialSamplerType.SAMPLERTYPE_LINEAR_COLOR
mul = lib.create_material_expression(skin, unreal.MaterialExpressionMultiply, -380, 120)
lib.connect_material_expressions(diffuse, "RGB", mul, "A")
lib.connect_material_expressions(detail, "RGB", mul, "B")
# Detail is a multiplier, so keep it near 1 and let it vary a little.
lerp = lib.create_material_expression(skin, unreal.MaterialExpressionLinearInterpolate, -200, 120)
lerp.set_editor_property("const_a", unreal.LinearColor(1.0, 1.0, 1.0, 1.0))
lib.connect_material_expressions(mul, "", lerp, "B")
rough = lib.create_material_expression(skin, unreal.MaterialExpressionConstant, -200, -160)
rough.set_editor_property("r", 0.55)
lib.connect_material_property(lerp, "", unreal.MaterialProperty.MP_BASE_COLOR)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(skin)
log("M_Character_Export: diffuse x pore detail, roughness 0.55")
elif not skin:
log("M_Character_Export missing")
hair = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Hair")
if hair and hair_strands:
textured_material(hair, hair_strands, 0.38, masked=True)
elif not hair:
log("M_Character_Hair missing")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
lines.append("VERDICT: PASS")
try:
main()
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")