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