homepage/tools/rig-weights.py

146 lines
6.5 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Веса костей для WebGL-рига персонажа (public/assets/zapret-moe/rig.js).
Персонаж собран из частей: основа без рук и отдельные руки. Каждая часть — своя
сетка поверх текстуры. Кость поворачивает и масштабирует вершины со своим весом
0…1, поэтому картинка тянется, а не рвётся. Руку целиком от плеча двигает
«корень» части (как CSS-анимация рук), а кости внутри гнут кисть и когти.
Маски костей строятся из альфы и цвета самих слоёв и размываются, чтобы вес
плавно затухал. Результат — rig/<имя>.json. Запуск из корня репозитория:
tools/rig-weights.py rkn
"""
import base64
import json
import os
import sys
import numpy as np
from PIL import Image
from scipy import ndimage
CH = 'public/assets/zapret-moe/chars/'
W, H = 1600, 900
CELL = 16 # шаг сетки в пикселях кадра
# Координаты — в системе кадра 1600×900, как у слоёв в home.tmpl.
RIGS = {
'rkn': {
'rim': ['rkn2-rim.webp', 0, 0, 1600, 900],
'base': {
'tex': 'rkn2-base.webp', 'rect': [0, 0, 1600, 900],
# лицо и туловище не качаются вместе с волосами
'protect': [(812, 345, 150, 175), (820, 660, 270, 330)],
'hair': {'hairL': (620, 150), 'hairR': (1000, 120)},
'split': 810,
},
'arms': {
# корень — где рукав уходит в плечо; кисть — запястье и центр ладони
'armL': {'tex': 'rkn2-arm-l.webp', 'rect': [0, 169, 670, 714], 'root': (650, 400),
'wrist': (250, 660), 'palm': (240, 448)},
'armR': {'tex': 'rkn2-arm-r.webp', 'rect': [974, 0, 626, 728], 'root': (990, 330),
'wrist': (1305, 560), 'palm': (1312, 304)},
},
},
}
def smooth(a, px):
return ndimage.gaussian_filter(a, px)
def smoothstep(e0, e1, x):
t = np.clip((x - e0) / (e1 - e0), 0, 1)
return t * t * (3 - 2 * t)
def load(tex, rect):
"""RGBA части и сетки координат кадра под неё."""
im = np.asarray(Image.open(CH + tex).convert('RGBA'), np.float32)
x0, y0, w, h = rect
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
return im, xx + x0, yy + y0
def pin(xx, yy):
# арт обрезан краями кадра: у самой границы вес уходит в ноль,
# чтобы край картинки не заезжал внутрь и не открывал ровный срез
edge = np.minimum(np.minimum(xx, W - 1 - xx), np.minimum(yy, H - 1 - yy))
return smoothstep(0, 40, edge)
def grid(rect, maps):
x0, y0, w, h = rect
cols, rows = max(2, round(w / CELL) + 1), max(2, round(h / CELL) + 1)
gx = np.linspace(0, w - 1, cols).round().astype(int)
gy = np.linspace(0, h - 1, rows).round().astype(int)
out = [np.clip(m[np.ix_(gy, gx)] * 255 + 0.5, 0, 255).astype(np.uint8) for m in maps]
if not out:
out = [np.zeros((rows, cols), np.uint8)]
while len(out) % 4:
out.append(np.zeros_like(out[0]))
return cols, rows, len(out), base64.b64encode(np.stack(out, -1).reshape(-1).tobytes()).decode()
def base_part(cfg):
im, xx, yy = load(cfg['tex'], cfg['rect'])
a = im[..., 3] / 255
body = np.zeros_like(a)
for cx, cy, rx, ry in cfg['protect']:
body = np.maximum(body, (((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2 <= 1).astype(np.float32))
body = np.clip(smooth(body, 35) * 1.3, 0, 1)
hair = np.clip(smooth(a, 20) * 1.2, 0, 1) * (1 - body) * pin(xx, yy)
side = smoothstep(cfg['split'] - 60, cfg['split'] + 60, xx)
bones, maps = [], []
for bid, (px, py) in cfg['hair'].items():
far = smoothstep(60, 520, np.hypot(xx - px, yy - py)) # кончики качаются сильнее корней
maps.append(hair * far * (1 - side if bid.endswith('L') else side))
bones.append({'id': bid, 'pivot': [px, py]})
return bones, maps
def arm_part(cfg):
im, xx, yy = load(cfg['tex'], cfg['rect'])
r, g, b, a = im[..., 0], im[..., 1], im[..., 2], im[..., 3] / 255
# кисть — кожа и чёрные когти рядом с ней; рукав и манжета двигаются только корнем
skin = (r > 140) & (r > b + 25) & (g > 90) & (a > 0.5)
skin = ndimage.binary_opening(skin, iterations=2)
near = ndimage.binary_dilation(skin, iterations=45)
claw = (np.maximum(np.maximum(r, g), b) < 90) & (a > 0.5) & near
hand = (skin | claw).astype(np.float32)
handw = np.clip(smooth(hand, 14) * 1.25, 0, 1) * pin(xx, yy)
px, py = cfg['palm']
wx, wy = cfg['wrist']
fing = np.clip(smooth(hand, 18) * 1.2, 0, 1) * smoothstep(80, 200, np.hypot(xx - px, yy - py)) \
* smoothstep(140, 240, np.hypot(xx - wx, yy - wy)) * pin(xx, yy)
bones = [{'id': 'fing', 'pivot': [px, py]}, {'id': 'hand', 'pivot': [wx, wy]}]
return bones, [fing, handw]
def build(name):
cfg = RIGS[name]
parts = []
bones, maps = base_part(cfg['base'])
cols, rows, stride, wts = grid(cfg['base']['rect'], maps)
parts.append({'id': 'base', 'tex': cfg['base']['tex'], 'rect': cfg['base']['rect'], 'cols': cols, 'rows': rows,
'stride': stride, 'bones': bones, 'weights': wts})
for pid, a in cfg['arms'].items():
bones, maps = arm_part(a)
cols, rows, stride, wts = grid(a['rect'], maps)
# id костей внутри руки делаем уникальными: fingL, handL…
for bn in bones:
bn['id'] += pid[-1]
parts.append({'id': pid, 'tex': a['tex'], 'rect': a['rect'], 'root': list(a['root']), 'cols': cols,
'rows': rows, 'stride': stride, 'bones': bones, 'weights': wts})
data = {'w': W, 'h': H, 'rim': cfg['rim'], 'parts': parts}
os.makedirs('public/assets/zapret-moe/rig', exist_ok=True)
with open(f'public/assets/zapret-moe/rig/{name}.json', 'w') as f:
json.dump(data, f, separators=(',', ':'))
return {p['id']: p for p in parts}
if __name__ == '__main__':
for n in sys.argv[1:] or RIGS:
build(n)
print(n, 'ok')