feat(tex): ComfyUI tool, procedural character textures, hair cap behind a flag

Tools/comfyui_generate.py drives the local ComfyUI instance for generated textures
and writes a sidecar with model, prompt, seed and workflow, so a generated asset can
be regenerated or rejected instead of being an unexplained PNG. Models list and
generation both verified against 127.0.0.1:8188.

Stable Diffusion is not usable for flat skin swatches, and that is worth recording:
three attempts produced an anatomical close-up twice and a leather-scale surface once.
Skin detail is generated procedurally instead - tileable octave noise for pores, plus
a strand sheet for hair - in Tools/generate_character_textures.py, with sidecars.

Hair geometry exists (cap conforming to the head, weighted to the head bone, second
material slot in Blender) 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 and off; the next step is a separate skeletal mesh attached as a second
component.
This commit is contained in:
zwitschi committed 2026-11-09 13:09:39 +01:00
1 parent 89a9c59747
commit 54c579ab1a
19 files changed
+494 -5

No files matched your search

@@ -0,0 +1,20 @@
{
"tool": "Tools/generate_character_textures.py",
"asset": "T_Character_Hair",
"size": 512,
"procedural": true,
"meta": {
"strands": 190,
"base": [
44,
32,
26
],
"highlight": [
96,
74,
58
]
},
"note": "generated, not photographed; no third-party licence"
}
Binary file not shown.
@@ -0,0 +1,20 @@
{
"tool": "Tools/generate_character_textures.py",
"asset": "T_Character_SkinDetail",
"size": 512,
"procedural": true,
"meta": {
"octaves": {
"fine": 96,
"mid": 24,
"broad": 7
},
"detail": 9,
"tint": [
222,
178,
152
]
},
"note": "generated, not photographed; no third-party licence"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -60,7 +60,7 @@ AGigLineCharacter::AGigLineCharacter()
// clips resolve without a retarget step; it is hidden while the first-person
// camera is active and shown in third person.
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
TEXT("/Game/Test/Character/CharacterV4.CharacterV4"));
TEXT("/Game/Test/Character/CharacterV6.CharacterV6"));
if (BodyFinder.Succeeded())
{
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
@@ -74,13 +74,13 @@ AGigLineCharacter::AGigLineCharacter()
GetMesh()->SetMaterial(0, SkinFinder.Object);
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
TEXT("/Game/Test/Character/CharacterV4Human_rig_GigLine_Idle.CharacterV4Human_rig_GigLine_Idle"));
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Idle.CharacterV6Human_rig_GigLine_Idle"));
if (IdleFinder.Succeeded())
{
IdleAnimation = IdleFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
TEXT("/Game/Test/Character/CharacterV4Human_rig_GigLine_Walk.CharacterV4Human_rig_GigLine_Walk"));
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Walk.CharacterV6Human_rig_GigLine_Walk"));
if (WalkFinder.Succeeded())
{
WalkAnimation = WalkFinder.Object;
+5 -1
View File
@@ -15,7 +15,11 @@ 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.
- [ ] Tune skin shading and hair materials on the player character.
- [A] 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.
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
## 0.2.3 — Camera clipping fixes (patch)
+64
View File
@@ -127,6 +127,54 @@ def log(msg):
print(f"[anim] {msg}")
HAIR_MATERIAL = "M_Character_Hair"
def add_hair_cap(arm, mesh):
"""Add a short-hair cap conforming to the head, skinned to the head bone.
The character exported from MPFB is bald, and a bald head reads as a mannequin
before anything else does. A cap plus a strand texture is enough at review
distance; hair cards and a groomed cut are a later pass.
"""
head = arm.data.bones.get("head")
if head is None or mesh is None:
log("hair: no head bone or no mesh - skipped")
return None
centre = (head.head_local + head.tail_local) * 0.5
radius = max(0.085, (head.tail_local - head.head_local).length * 0.62)
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=14, radius=radius,
location=(centre.x, centre.y, centre.z + 0.012))
cap = bpy.context.active_object
cap.name = "HairCap"
# Keep the upper hemisphere plus a little: a full sphere reads as a helmet.
cut_z = centre.z - radius * 0.12
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0),
clear_inner=True, use_fill=False)
bpy.ops.object.mode_set(mode="OBJECT")
# Slightly ovoid, wider than deep, sitting back on the skull.
cap.scale = (1.06, 1.10, 0.92)
hair_mat = bpy.data.materials.get(HAIR_MATERIAL) or bpy.data.materials.new(HAIR_MATERIAL)
hair_mat.use_nodes = True
bsdf = hair_mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = (0.055, 0.038, 0.030, 1.0)
bsdf.inputs["Roughness"].default_value = 0.42
cap.data.materials.append(hair_mat)
bpy.ops.object.select_all(action="DESELECT")
cap.select_set(True)
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.join()
log(f"hair: cap added at head centre, radius {radius:.3f} m, material {HAIR_MATERIAL}")
return mesh
def clear_animation(arm):
"""Drop any pre-existing actions so a rerun does not stack clips."""
for action in list(bpy.data.actions):
@@ -317,6 +365,10 @@ def main(argv=None) -> int:
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
ap.add_argument("--report", default=None)
# Hair is built but not exported yet: Unreal's import collapses the second
# material slot, so the cap renders with the skin material and reads as a bald
# skull. Off until the hair is a separate asset (see TODO.md).
ap.add_argument("--hair", action="store_true")
args = ap.parse_args(argv)
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
@@ -332,6 +384,18 @@ def main(argv=None) -> int:
idle = clip_idle(arm, CLIPS["Idle"])
walk = clip_walk(arm, CLIPS["Walk"])
# Hair before export so the cap is part of the skinned mesh; a separate asset
# would need its own bone attachment and a second material assignment.
verts_before = len(mesh.data.vertices) if mesh else 0
if args.hair:
add_hair_cap(arm, mesh)
else:
log("hair: skipped (--hair to include; blocked on material slots, see TODO.md)")
if mesh and len(mesh.data.vertices) > verts_before:
group = mesh.vertex_groups.get("head") or mesh.vertex_groups.new(name="head")
group.add(list(range(verts_before, len(mesh.data.vertices))), 1.0, "REPLACE")
log(f"hair: {len(mesh.data.vertices) - verts_before} vertices weighted to 'head'")
bpy.ops.object.mode_set(mode="OBJECT")
# Export armature + mesh together: the preset's character flags carry the
# bake_anim settings that make Unreal create AnimSequences.
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Generate image assets from the local ComfyUI instance.
The project needs textures it cannot photograph: skin detail, hair strands, fabric
and surface grime. ComfyUI runs locally at `127.0.0.1:8188`, so this is a
**human-triggered local tool**, not a network service — nothing in CI calls it, and
no credential is involved.
# what is installed
python3 Tools/comfyui_generate.py models
# one image
python3 Tools/comfyui_generate.py txt2img \
--prompt "seamless photoreal human skin texture, fine pores, neutral light" \
--negative "text, watermark, hair, clothing" \
--model juggernautXL_ragnarok.safetensors \
--size 1024x1024 --seed 7 \
--out Art/textures/generated/T_Character_SkinDetail.png
Outputs land in the given path and a JSON sidecar records the exact prompt, model,
seed and workflow, so an asset can be regenerated or rejected later without
guessing how it was made.
Licence: the output is a generated image from a locally hosted model. `THIRD_PARTY
NOTICES.md` must record the model and its licence before the texture ships; the
sidecar carries what is needed to do that.
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
HOST = os.environ.get("COMFYUI_HOST", "http://127.0.0.1:8188")
DEFAULT_MODEL = "juggernautXL_ragnarok.safetensors"
def api(path: str) -> dict:
with urllib.request.urlopen(f"{HOST}{path}", timeout=30) as response:
return json.loads(response.read().decode())
def post(path: str, body: dict) -> dict:
request = urllib.request.Request(
f"{HOST}{path}", data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read().decode()
return json.loads(raw) if raw.strip() else {}
def checkpoints() -> list[str]:
info = api("/object_info/CheckpointLoaderSimple")
return info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
def workflow(prompt: str, negative: str, model: str, width: int, height: int,
seed: int, steps: int, cfg: float, sampler: str, scheduler: str) -> dict:
"""A minimal SDXL/SD1.5 txt2img graph in ComfyUI's API format.
Node ids are strings and references are `[node_id, output_index]`, which is the
format `/prompt` expects — the UI's workflow JSON is a different shape and cannot
be posted directly.
"""
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}},
"2": {"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["1", 1]}},
"4": {"class_type": "EmptyLatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1}},
"5": {"class_type": "KSampler",
"inputs": {"seed": seed, "steps": steps, "cfg": cfg,
"sampler_name": sampler, "scheduler": scheduler,
"denoise": 1.0, "model": ["1", 0],
"positive": ["2", 0], "negative": ["3", 0],
"latent_image": ["4", 0]}},
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}},
"7": {"class_type": "SaveImage",
"inputs": {"images": ["6", 0], "filename_prefix": "gigline"}},
}
def generate(args) -> int:
width, height = (int(v) for v in args.size.lower().split("x"))
seed = args.seed if args.seed is not None else random.randrange(0, 2**31)
graph = workflow(args.prompt, args.negative, args.model, width, height, seed,
args.steps, args.cfg, args.sampler, args.scheduler)
queued = post("/prompt", {"prompt": graph})
prompt_id = queued.get("prompt_id")
if not prompt_id:
print(f"error: ComfyUI rejected the prompt: {queued}", file=sys.stderr)
return 1
print(f"queued {prompt_id} ({args.model}, {width}x{height}, seed {seed})")
deadline = time.time() + args.timeout
while time.time() < deadline:
history = api(f"/history/{prompt_id}")
entry = history.get(prompt_id)
if entry and entry.get("outputs"):
images = []
for node in entry["outputs"].values():
images.extend(node.get("images", []))
if not images:
print("error: job finished with no image", file=sys.stderr)
return 1
image = images[0]
query = urllib.parse.urlencode({
"filename": image["filename"],
"subfolder": image.get("subfolder", ""),
"type": image.get("type", "output")})
with urllib.request.urlopen(f"{HOST}/view?{query}", timeout=60) as response:
data = response.read()
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "wb") as handle:
handle.write(data)
sidecar = os.path.splitext(args.out)[0] + ".json"
with open(sidecar, "w") as handle:
json.dump({
"tool": "Tools/comfyui_generate.py",
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"prompt": args.prompt,
"negative": args.negative,
"model": args.model,
"size": [width, height],
"seed": seed,
"steps": args.steps,
"cfg": args.cfg,
"sampler": args.sampler,
"scheduler": args.scheduler,
"workflow": graph,
"comfyui_prompt_id": prompt_id,
"note": "generated locally; record the model licence in "
"THIRD_PARTY_NOTICES.md before shipping",
}, handle, indent=2)
print(f"wrote {args.out} (+ sidecar)")
return 0
time.sleep(args.poll)
print("error: timed out waiting for the image", file=sys.stderr)
return 1
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("models", help="list installed checkpoints")
t = sub.add_parser("txt2img", help="generate one image")
t.add_argument("--prompt", required=True)
t.add_argument("--negative", default="text, watermark, signature, blurry, low quality")
t.add_argument("--model", default=DEFAULT_MODEL)
t.add_argument("--size", default="1024x1024")
t.add_argument("--seed", type=int, default=None)
t.add_argument("--steps", type=int, default=28)
t.add_argument("--cfg", type=float, default=5.5)
t.add_argument("--sampler", default="dpmpp_2m")
t.add_argument("--scheduler", default="karras")
t.add_argument("--out", required=True)
t.add_argument("--timeout", type=float, default=300.0)
t.add_argument("--poll", type=float, default=1.5)
args = ap.parse_args(argv)
if args.cmd == "models":
for name in checkpoints():
print(name)
return 0
return generate(args)
if __name__ == "__main__":
raise SystemExit(main())
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Procedural character textures: skin detail and hair strands.
The local ComfyUI instance was tried first for skin, and failed in a way worth
recording: every "flat skin swatch" prompt produced an anatomical body part, twice
out of three attempts, and the third produced a leather-scale surface. SDXL has no
useful notion of "texture swatch without anatomy", and a character texture that
needs manual rejection on every seed is worse than noise we control.
So skin detail is generated here instead: fine tileable noise with a low-frequency
break-up, which is what the base colour actually needs (pores, not a pattern). Hair
is generated as a strand sheet with alpha, for hair cards.
python3 Tools/generate_character_textures.py --out Art/textures/generated
"""
import argparse
import json
import math
import os
import random
import struct
import zlib
SKIN = {
"name": "T_Character_SkinDetail",
"size": 512,
# Base tone sits near the material's measured skin albedo so a multiply blend
# keeps the character's colour rather than tinting it.
"tint": (222, 178, 152),
"detail": 9,
}
HAIR = {
"name": "T_Character_Hair",
"size": 512,
"base": (44, 32, 26),
"highlight": (96, 74, 58),
"strands": 190,
}
def write_png(path, width, height, pixels, alpha=True):
"""Write RGBA or RGB pixels with zlib, no image library needed."""
raw = bytearray()
for y in range(height):
raw.append(0)
start = y * width * (4 if alpha else 3)
step = 4 if alpha else 3
for x in range(width):
i = start + x * step
raw += pixels[i:i + step]
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data +
struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff))
colour_type = 6 if alpha else 2
ihdr = struct.pack(">IIBBBBB", width, height, 8, colour_type, 0, 0, 0)
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
png += chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")
with open(path, "wb") as handle:
handle.write(png)
def value_noise(size, cells, seed):
"""Tileable value noise: a lattice that wraps, bilinearly interpolated."""
rnd = random.Random(seed)
lattice = [[rnd.random() for _ in range(cells)] for _ in range(cells)]
out = [0.0] * (size * size)
for y in range(size):
fy = y / size * cells
y0 = int(fy) % cells
y1 = (y0 + 1) % cells
ty = fy - int(fy)
ty = ty * ty * (3 - 2 * ty)
for x in range(size):
fx = x / size * cells
x0 = int(fx) % cells
x1 = (x0 + 1) % cells
tx = fx - int(fx)
tx = tx * tx * (3 - 2 * tx)
a = lattice[y0][x0] * (1 - tx) + lattice[y0][x1] * tx
b = lattice[y1][x0] * (1 - tx) + lattice[y1][x1] * tx
out[y * size + x] = a * (1 - ty) + b * ty
return out
def skin_texture(spec, out_dir):
size, tint, detail = spec["size"], spec["tint"], spec["detail"]
# Three octaves: fine pores, mid break-up, broad blotch. All tileable.
fine = value_noise(size, 96, 7)
mid = value_noise(size, 24, 13)
broad = value_noise(size, 7, 21)
px = bytearray(size * size * 3)
for i in range(size * size):
n = (fine[i] - 0.5) * detail * 1.0 + (mid[i] - 0.5) * detail * 0.6 + (broad[i] - 0.5) * detail * 0.4
for c in range(3):
v = tint[c] + n
px[i * 3 + c] = max(0, min(255, int(v)))
path = os.path.join(out_dir, spec["name"] + ".png")
write_png(path, size, size, px, alpha=False)
return path, {"octaves": {"fine": 96, "mid": 24, "broad": 7}, "detail": detail, "tint": tint}
def hair_texture(spec, out_dir):
size = spec["size"]
base, hi = spec["base"], spec["highlight"]
rnd = random.Random(5)
px = bytearray(size * size * 4)
# Vertical strands: each is a line with slight wander, alpha fading at the edges.
strands = []
for _ in range(spec["strands"]):
strands.append({
"x": rnd.uniform(0, size),
"w": rnd.uniform(0.6, 2.2),
"a": rnd.uniform(0.35, 0.95),
"wander": rnd.uniform(2.0, 9.0),
"phase": rnd.uniform(0, math.tau),
"bright": rnd.random(),
})
for y in range(size):
for x in range(size):
i = (y * size + x) * 4
alpha = 0.0
lift = 0.0
for s in strands:
sx = (s["x"] + math.sin(y / size * math.tau + s["phase"]) * s["wander"]) % size
d = abs(x - sx)
d = min(d, size - d)
if d < s["w"]:
a = (1.0 - d / s["w"]) * s["a"]
if a > alpha:
alpha = a
lift = s["bright"]
if alpha <= 0.02:
px[i:i + 4] = bytes((0, 0, 0, 0))
continue
shade = [base[c] + (hi[c] - base[c]) * lift for c in range(3)]
px[i:i + 3] = bytes(max(0, min(255, int(v))) for v in shade)
px[i + 3] = int(min(1.0, alpha) * 255)
path = os.path.join(out_dir, spec["name"] + ".png")
write_png(path, size, size, px, alpha=True)
return path, {"strands": spec["strands"], "base": base, "highlight": hi}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="Art/textures/generated")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
results = {}
for spec, maker in ((SKIN, skin_texture), (HAIR, hair_texture)):
path, meta = maker(spec, args.out)
sidecar = os.path.splitext(path)[0] + ".json"
with open(sidecar, "w") as handle:
json.dump({"tool": "Tools/generate_character_textures.py",
"asset": spec["name"], "size": spec["size"],
"procedural": True, "meta": meta,
"note": "generated, not photographed; no third-party licence"},
handle, indent=2)
results[spec["name"]] = path
print(f"wrote {path} (+ sidecar)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -19,7 +19,7 @@ import unreal
SRC = "/tmp/gigline/character-anim.fbx"
DEST = "/Game/Test/Character"
PREFIX = "CharacterV4"
PREFIX = "CharacterV6"
RESULT = "/tmp/gigline/character-anim-import.txt"
lines = []