Files
animation/tools/tailor/textures/make_hunter_cloth.py
T

145 lines
5.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
make_hunter_cloth.py -- coarse woven brown cloth for the hunter wrap skirt.
python tools/tailor/textures/make_hunter_cloth.py
Derived from the reference renders
`male-clothing-hunter-gpt-v2{,-side,-back}.png`: a dark warm-brown coarse plain
weave, roughly burlap/harakeke-sack in character, with visible thread grain and
wear mottling.
WHY THE COLOUR IS BAKED IN, NOT GREY
The clothing pipeline has NO alpha and `apply_fabric_texture()` wires the PNG
straight into Base Color, *replacing* the part's flat `color` rather than
multiplying it. So a grayscale weave renders grey in-game, not brown. Every
value below is a final albedo, not a mask.
WHY THESE RGB NUMBERS
Sampled from the reference renders: garment mean (56,35,26) with highlights to
about (92,61,45). Those are *lit* pixels from a dim studio setup, so the albedo
sits above them -- BASE is set brighter so that in-game lighting lands the
garment back on the reference's apparent tone instead of crushing it to near
black. Warm ramp throughout: r > g > b, r-b about 45.
WHY DPI AND NOT IMAGE SCALE
In MD the PNG's DPI sets the cloth's physical size, so tiling is controlled by
dpi, never by resizing the image. This tile represents CLOTH_MM of fabric:
dpi = SIZE / (CLOTH_MM / 25.4). At 100 mm it repeats about 7x across the skirt's
687 mm hem, which is what keeps the weave reading as thread rather than pattern.
DETERMINISM
No `random` and no time source -- a fixed LCG plus a fixed hash, so the file is
byte-identical run to run. This PNG is a build input; a texture that changes
under you turns a placement regression into a wild goose chase.
"""
import os
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "hunter_cloth.png")
SIZE = 512 # px, square
CLOTH_MM = 100.0 # physical span this tile represents
DPI = SIZE / (CLOTH_MM / 25.4)
BASE = (108, 80, 60) # mid warm brown albedo
THREAD_PITCH = 16 # px per thread; 512px/100mm -> ~3.1 mm threads (coarse)
OVER_LIFT = 16 # threads on top of the weave are lighter
UNDER_DROP = 20 # threads passing under are shaded
ROUND_SHADE = 14 # cross-thread rounding falloff
SLUB_RANGE = 10 # per-thread thickness/tone irregularity
MOTTLE = 16 # large-scale wear variation
MOTTLE_CELL = 64 # px per mottle cell
def lcg(seed):
"""Deterministic 0..1 sequence. Fixed constants (glibc), fixed seed."""
state = seed
while True:
state = (1103515245 * state + 12345) % (2 ** 31)
yield state / float(2 ** 31)
def thread_tones(n, seed):
"""One slub value per thread, so a thread's irregularity runs its length --
per-pixel noise would read as sand, not as spun fibre."""
g = lcg(seed)
return [int((next(g) * 2.0 - 1.0) * SLUB_RANGE) for _ in range(n)]
def value_noise(cells, seed):
"""Coarse lattice of values, bilinearly interpolated -> smooth wear blotches."""
g = lcg(seed)
grid = [[(next(g) * 2.0 - 1.0) for _ in range(cells + 1)] for _ in range(cells + 1)]
def sample(x, y):
fx, fy = x * cells / SIZE, y * cells / SIZE
x0, y0 = int(fx), int(fy)
tx, ty = fx - x0, fy - y0
# smoothstep so cell borders don't show as creases
tx, ty = tx * tx * (3 - 2 * tx), ty * ty * (3 - 2 * ty)
a = grid[y0][x0] * (1 - tx) + grid[y0][x0 + 1] * tx
b = grid[y0 + 1][x0] * (1 - tx) + grid[y0 + 1][x0 + 1] * tx
return a * (1 - ty) + b * ty
return sample
def main():
n_threads = SIZE // THREAD_PITCH
warp = thread_tones(n_threads, seed=20260812)
weft = thread_tones(n_threads, seed=90210)
mottle = value_noise(SIZE // MOTTLE_CELL, seed=5150)
img = Image.new("RGB", (SIZE, SIZE))
px = img.load()
for y in range(SIZE):
j = (y // THREAD_PITCH) % n_threads
# position across the weft thread, -1..1, for rounding
vy = ((y % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0
for x in range(SIZE):
i = (x // THREAD_PITCH) % n_threads
vx = ((x % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0
# plain weave: alternate which thread sits on top
warp_on_top = ((i + j) % 2) == 0
if warp_on_top:
lift = OVER_LIFT - int(ROUND_SHADE * vx * vx)
slub = warp[i]
else:
lift = -UNDER_DROP + int(ROUND_SHADE * (1.0 - vy * vy))
slub = weft[j]
wear = int(mottle(x, y) * MOTTLE)
d = lift + slub + wear
# warm ramp: brown shifts warmer as it lightens, cooler in shadow
r = BASE[0] + d
g = BASE[1] + int(d * 0.78)
b = BASE[2] + int(d * 0.62)
px[x, y] = (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
img.save(OUT, dpi=(DPI, DPI))
vals = [px[x, y] for y in range(0, SIZE, 8) for x in range(0, SIZE, 8)]
n = len(vals)
mean = tuple(sum(v[c] for v in vals) // n for c in range(3))
print("wrote %s (%dx%d, dpi %.1f -> %.0f mm of cloth)"
% (OUT, SIZE, SIZE, DPI, CLOTH_MM))
print("mean albedo %s (reference lit mean was (56,35,26))" % (mean,))
print("range r %d-%d g %d-%d b %d-%d"
% (min(v[0] for v in vals), max(v[0] for v in vals),
min(v[1] for v in vals), max(v[1] for v in vals),
min(v[2] for v in vals), max(v[2] for v in vals)))
warm = mean[0] - mean[2]
print("warmth r-b = %d (reference %d)" % (warm, 56 - 26))
if warm < 30:
print("WARNING: not warm enough -- will read as grey cloth")
if __name__ == "__main__":
main()