0.2.2 — hair as its own skinned mesh (cards, second component) #36
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.
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;
|
||||
|
||||
|
||||
@@ -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.
@@ -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.
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user