One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
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/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())
|