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.
169 lines
6.1 KiB
Python
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())
|