Files
gig-line/Tools/generate_character_textures.py
T
zwitschi 54c579ab1a 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.
2026-11-09 13:09:39 +01:00

169 lines
6.1 KiB
Python

#!/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())