"""Variant skins of a Meshy figure (H1, docs/luneraria/CHAMPIONS-KONZEPT.md: skin stage "Variante"). Reads the albedo maps build_figure.py wrote (Assets/Luneraria/Art/Textures/-albedo-2k.png, glow mask in alpha) and the ORM map beside them (B = metalness), and writes a recoloured albedo per skin (-albedo-2k.png): texels in the hue band (the blue cloth, runes and moonstone) move to the skin's hue with more saturation and less brightness, metal texels get a tint and darken, skin, hair and leather stay. The glow mask in alpha is kept, so the runes glow in the new colour. LuneMaterialFactory makes a Surface material AI_
_ from it (normal and ORM of the standard maps) and LuneMaterialSet.Dress swaps it in for heroes that carry the skin. blender --background --python tools/meshy/skin_variant.py -- tools/meshy/sylvara/skins.json """ import json import sys from pathlib import Path import bpy import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parent)) from build_figure import pixels, save_png # noqa: E402 ROOT = Path(__file__).resolve().parents[2] TEXTURES = ROOT / 'Assets/Luneraria/Art/Textures' def hsv(rgb): value = rgb.max(-1) low = rgb.min(-1) delta = value - low saturation = np.where(value > 1e-6, delta / np.maximum(value, 1e-6), 0.0) r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] safe = np.maximum(delta, 1e-6) hue = np.where(value == r, ((g - b) / safe) % 6.0, np.where(value == g, (b - r) / safe + 2.0, (r - g) / safe + 4.0)) * 60.0 return np.where(delta > 1e-6, hue, 0.0), saturation, value def rgb_of(hue, saturation, value): c = value * saturation h = (hue % 360.0) / 60.0 x = c * (1.0 - np.abs(h % 2.0 - 1.0)) zero = np.zeros_like(c) sector = np.floor(h).astype(int) % 6 table = [(c, x, zero), (x, c, zero), (zero, c, x), (zero, x, c), (x, zero, c), (c, zero, x)] out = np.zeros(hue.shape + (3,), dtype=np.float32) for index, (r, g, b) in enumerate(table): mask = sector == index out[mask] = np.stack([r[mask], g[mask], b[mask]], -1) return out + (value - c)[..., None] def recolour(albedo, metal, rule): rgb = albedo[..., :3] hue, saturation, value = hsv(rgb) low, high = rule['hue_from'] # A band may wrap around red (e.g. [330, 25]). in_hue = ((hue >= low) & (hue <= high)) if low <= high else ((hue >= low) | (hue <= high)) band = in_hue & (saturation > rule.get('min_saturation', 0.06)) shifted = rgb_of(np.full_like(hue, float(rule['hue_to'])), np.clip(saturation * rule['saturation_gain'], 0, 1), value * rule['value_gain']) out = np.where(band[..., None], shifted, rgb) metallic = (metal > rule['metal_from']) & ~band tint = np.array(rule['metal_tint'], dtype=np.float32) * rule['metal_value'] out = np.where(metallic[..., None], out * tint, out) albedo[..., :3] = np.clip(out, 0.0, 1.0) return float(band.mean()), float(metallic.mean()) def main(): spec = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text()) report = {} for skin in spec['skins']: for pair in skin['maps']: albedo_image = bpy.data.images.load(str(TEXTURES / (pair['from'] + '-albedo-2k.png'))) orm_image = bpy.data.images.load(str(TEXTURES / (pair['from'] + '-orm-2k.png'))) albedo_image.colorspace_settings.name = 'Non-Color' orm_image.colorspace_settings.name = 'Non-Color' albedo = pixels(albedo_image) orm = pixels(orm_image) band, metal = recolour(albedo, orm[..., 2], skin['recolor']) save_png(albedo, TEXTURES / (pair['to'] + '-albedo-2k.png'), 4) report[pair['to']] = {'hue_band': round(band, 4), 'metal': round(metal, 4)} print('LUNE_SKINS', json.dumps(report)) main()