145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
|
|
#!/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()
|