438d68d50f
build / mod (push) Successful in 3m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""Общая палитра и генератор текстур мода AirDef (используется tools/gen_models.py).
|
|
|
|
Все текстуры детерминированы (seed). Имя текстуры задаёт материал в HD (см. docs/HD-RENDERER.md, «Материалы»):
|
|
bare_* — голый металл / стекло (оверлей 42: гладко, полностью металлично): стволы, сопла, антенны, стёкла `bare_glass*`;
|
|
glow_* — светящиеся детали (оверлей 40): жар сопла, лампы;
|
|
прочее — крашеный металл (оверлей 41).
|
|
Имя `T__b` — обгоревший вариант текстуры T (остовы), `T@x3y1` — вариант для грани, растянутой в 3 раза по u
|
|
(в моделях грань длиннее 16 единиц тянет всю текстуру), картина заранее сжата, чтобы пятна камуфляжа не вытягивались.
|
|
"""
|
|
import random
|
|
|
|
from PIL import Image
|
|
|
|
# ------------------------------------------------------------------ палитра
|
|
PAL = {
|
|
"nato_green": [(70, 80, 54), (86, 74, 50), (34, 38, 30)], # NATO: зелёный, коричневый, чёрный
|
|
"flecktarn": [(88, 98, 62), (60, 74, 44), (112, 88, 56), (30, 32, 26)],
|
|
"ru_green": [(78, 94, 58), (52, 68, 40), (112, 94, 62), (32, 34, 26)],
|
|
"ru_khaki": [(112, 112, 82), (88, 92, 62), (64, 66, 46)],
|
|
"nato_tan": [(152, 132, 98), (126, 108, 78), (98, 86, 64)],
|
|
"olive": (74, 82, 58),
|
|
"rubber": (24, 24, 25),
|
|
"steel": (120, 122, 126),
|
|
"dark": (50, 52, 52),
|
|
"white": (236, 236, 230),
|
|
"grey": (150, 154, 158),
|
|
"red": (180, 40, 36),
|
|
}
|
|
|
|
|
|
def clamp(v):
|
|
return max(0, min(255, int(v)))
|
|
|
|
|
|
def col(c, d=0):
|
|
return tuple(clamp(v + d) for v in c[:3])
|
|
|
|
|
|
def mix(a, b, t):
|
|
return tuple(clamp(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
|
|
|
|
class Canvas:
|
|
"""Полотно в «физических» пикселях: S*sx × S*sy, на выходе — S×S со сжатием (антиалиасинг по BOX)."""
|
|
|
|
def __init__(self, S, sx=1, sy=1, base=(0, 0, 0), seed=0):
|
|
self.S, self.sx, self.sy = S, sx, sy
|
|
self.W, self.H = S * sx, S * sy
|
|
self.im = Image.new("RGBA", (self.W, self.H), tuple(base) + (255,))
|
|
self.px = self.im.load()
|
|
self.rnd = random.Random(seed)
|
|
|
|
def put(self, x, y, c, a=1.0):
|
|
if 0 <= x < self.W and 0 <= y < self.H:
|
|
if a >= 1:
|
|
self.px[x, y] = tuple(c[:3]) + (255,)
|
|
else:
|
|
o = self.px[x, y]
|
|
self.px[x, y] = tuple(clamp(o[i] + (c[i] - o[i]) * a) for i in range(3)) + (255,)
|
|
|
|
def noise(self, amp):
|
|
r = self.rnd
|
|
for y in range(self.H):
|
|
for x in range(self.W):
|
|
o = self.px[x, y]
|
|
d = r.randint(-amp, amp)
|
|
self.px[x, y] = (clamp(o[0] + d), clamp(o[1] + d), clamp(o[2] + d), 255)
|
|
|
|
def rect(self, x0, y0, x1, y1, c, a=1.0):
|
|
for y in range(int(y0), int(y1)):
|
|
for x in range(int(x0), int(x1)):
|
|
self.put(x, y, c, a)
|
|
|
|
def disc(self, cx, cy, r, c, a=1.0):
|
|
for y in range(int(cy - r - 1), int(cy + r + 2)):
|
|
for x in range(int(cx - r - 1), int(cx + r + 2)):
|
|
if (x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= r * r:
|
|
self.put(x, y, c, a)
|
|
|
|
def ellipse(self, cx, cy, rx, ry, c, wob=0.0):
|
|
r = self.rnd
|
|
for y in range(int(cy - ry - 2), int(cy + ry + 3)):
|
|
for x in range(int(cx - rx - 2), int(cx + rx + 3)):
|
|
if ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1 + r.random() * wob:
|
|
self.put(x, y, tuple(clamp(v + r.randint(-4, 4)) for v in c))
|
|
|
|
def panels(self, px_w, px_h, c_line, c_lite, rivets=True):
|
|
"""Швы листов: сетка с неровными листами, блик под швом и заклёпки по краям."""
|
|
r = self.rnd
|
|
y = r.randrange(px_h)
|
|
while y < self.H:
|
|
x = r.randrange(px_w)
|
|
for yy in range(int(y), int(y) + 1):
|
|
for xx in range(self.W):
|
|
self.put(xx, yy, c_line, 0.55)
|
|
self.put(xx, yy + 1, c_lite, 0.3)
|
|
y += px_h * (0.8 + r.random() * 0.5)
|
|
x = r.randrange(px_w)
|
|
while x < self.W:
|
|
for yy in range(self.H):
|
|
self.put(int(x), yy, c_line, 0.45)
|
|
self.put(int(x) + 1, yy, c_lite, 0.25)
|
|
x += px_w * (0.8 + r.random() * 0.5)
|
|
if rivets:
|
|
for _ in range(self.W * self.H // 160):
|
|
self.put(r.randrange(self.W), r.randrange(self.H), c_lite, 0.7)
|
|
|
|
def dirt(self, amount, c=(30, 24, 18)):
|
|
"""Подтёки сверху вниз и грязь снизу."""
|
|
r = self.rnd
|
|
for _ in range(int(self.W * amount)):
|
|
x = r.randrange(self.W)
|
|
y0 = r.randrange(self.H)
|
|
for k in range(r.randint(3, max(4, self.H // 3))):
|
|
self.put(x, y0 + k, c, 0.16 * (1 - k / (self.H // 3 + 4)))
|
|
for y in range(self.H):
|
|
t = y / self.H
|
|
if t > 0.7:
|
|
for x in range(self.W):
|
|
self.put(x, y, c, (t - 0.7) * 0.5)
|
|
|
|
def wear(self, amount, c):
|
|
r = self.rnd
|
|
for _ in range(int(self.W * self.H * amount)):
|
|
self.put(r.randrange(self.W), r.randrange(self.H), c, 0.6)
|
|
|
|
def out(self):
|
|
im = self.im
|
|
if self.sx > 1 or self.sy > 1:
|
|
im = im.resize((self.S, self.S), Image.BOX)
|
|
return im
|
|
|
|
|
|
# ------------------------------------------------------------------ узоры
|
|
def painted(base, seed, S=32, sx=1, sy=1, panel=(14, 10), amp=3, dirt=0.1, wear=0.012, rivets=True):
|
|
"""Крашеный лист: шум, швы, заклёпки, подтёки, сколы."""
|
|
c = Canvas(S, sx, sy, base, seed)
|
|
c.noise(amp)
|
|
c.panels(panel[0] * 1, panel[1] * 1, col(base, -22), col(base, 16), rivets)
|
|
c.dirt(dirt)
|
|
c.wear(wear, col(base, 40))
|
|
return c.out()
|
|
|
|
|
|
def camo(kind, seed, S=64, sx=1, sy=1):
|
|
cols = PAL[kind]
|
|
c = Canvas(S, sx, sy, cols[0], seed)
|
|
c.noise(3)
|
|
r = c.rnd
|
|
area = (c.W * c.H) / 1024.0
|
|
if kind == "flecktarn":
|
|
for k in range(1, len(cols)):
|
|
for _ in range(int(area * 20)):
|
|
x, y = r.randrange(c.W), r.randrange(c.H)
|
|
c.ellipse(x, y, r.uniform(1.5, 4.5), r.uniform(1.2, 3.2), cols[k], 0.5)
|
|
else:
|
|
for k in range(1, len(cols)):
|
|
for _ in range(int(area * (5 if k == 1 else 3))):
|
|
x, y = r.randrange(c.W), r.randrange(c.H)
|
|
c.ellipse(x, y, r.uniform(4, 11), r.uniform(2.5, 6), cols[k], 0.35)
|
|
c.panels(22, 16, col(cols[0], -34), col(cols[0], 12), True)
|
|
c.dirt(0.12)
|
|
c.wear(0.006, col(cols[0], 46))
|
|
return c.out()
|
|
|
|
|
|
def tire_tex(seed, S=32, sx=1, sy=1):
|
|
c = Canvas(S, sx, sy, PAL["rubber"], seed)
|
|
c.noise(3)
|
|
for x in range(0, c.W, 3): # протектор: ёлочка
|
|
for y in range(c.H):
|
|
if (y // 3) % 2 == 0:
|
|
c.put(x, y, (46, 46, 46), 0.9)
|
|
else:
|
|
c.put(x + 1, y, (46, 46, 46), 0.7)
|
|
for y in range(c.H):
|
|
c.put(0, y, (60, 60, 58), 0.8)
|
|
c.dirt(0.1, (60, 48, 36))
|
|
return c.out()
|
|
|
|
|
|
def hub_tex(seed, S=32):
|
|
c = Canvas(S, 1, 1, (70, 72, 70), seed)
|
|
c.noise(3)
|
|
c.disc(S / 2, S / 2, S * 0.46, (34, 34, 34)) # боковина шины
|
|
c.disc(S / 2, S / 2, S * 0.30, (118, 120, 122)) # диск
|
|
c.disc(S / 2, S / 2, S * 0.2, (84, 86, 88))
|
|
for k in range(8):
|
|
import math
|
|
a = k * math.pi / 4
|
|
c.disc(S / 2 + math.cos(a) * S * 0.25, S / 2 + math.sin(a) * S * 0.25, 1, (36, 36, 38))
|
|
c.disc(S / 2, S / 2, 2.4, (50, 52, 54))
|
|
return c.out()
|
|
|
|
|
|
def track_tex(seed, S=32, sx=1, sy=1):
|
|
"""Верх/низ гусеницы: траки поперёк."""
|
|
c = Canvas(S, sx, sy, (36, 36, 34), seed)
|
|
c.noise(3)
|
|
for y in range(0, c.H, 3):
|
|
for x in range(c.W):
|
|
c.put(x, y, (78, 76, 70), 0.9)
|
|
c.put(x, y + 1, (20, 20, 20), 0.5)
|
|
c.dirt(0.1, (70, 58, 44))
|
|
return c.out()
|
|
|
|
|
|
def track_side(seed, S=32, sx=1, sy=1):
|
|
"""Борт гусеничного блока: ленивец, 7 опорных катков, ведущее колесо и траки сверху и снизу."""
|
|
c = Canvas(S, sx, sy, (34, 34, 32), seed)
|
|
c.noise(3)
|
|
cy = 9
|
|
for i in range(8):
|
|
x = 10 + i * (c.W - 20) / 7
|
|
c.disc(x, cy, 6.2, (22, 22, 22))
|
|
c.disc(x, cy, 4.6, (68, 70, 66))
|
|
c.disc(x, cy, 1.8, (36, 38, 36))
|
|
for x in range(0, c.W, 2):
|
|
c.put(x, 0, (86, 84, 76))
|
|
c.put(x, 1, (26, 26, 24))
|
|
c.put(x, 17, (86, 84, 76))
|
|
c.dirt(0.15, (74, 60, 46))
|
|
return c.out()
|
|
|
|
|
|
def glass_tex(seed, S=32, tint=(36, 56, 74), refl=True):
|
|
c = Canvas(S, 1, 1, tint, seed)
|
|
c.noise(3)
|
|
for y in range(S): # небо отражается сверху вниз
|
|
for x in range(S):
|
|
t = 1 - y / S
|
|
c.put(x, y, (150, 180, 200), 0.28 * t * t)
|
|
if refl:
|
|
for i in range(S):
|
|
c.put(i, S - 1 - i, (190, 210, 224), 0.5)
|
|
c.put(min(S - 1, i + 3), S - 1 - i, (170, 196, 212), 0.3)
|
|
for i in range(S): # рама
|
|
for k in (0, S - 1):
|
|
c.put(i, k, (20, 22, 22))
|
|
c.put(k, i, (20, 22, 22))
|
|
return c.out()
|
|
|
|
|
|
def metal_tex(base, seed, S=32, brush=True, sx=1, sy=1):
|
|
"""Голый металл: шлифовка полосами, лёгкие царапины."""
|
|
c = Canvas(S, sx, sy, base, seed)
|
|
r = c.rnd
|
|
for y in range(c.H):
|
|
d = r.randint(-5, 5)
|
|
for x in range(c.W):
|
|
c.put(x, y, col(base, d + r.randint(-2, 2)))
|
|
for _ in range(c.W // 3):
|
|
y = r.randrange(c.H)
|
|
x0 = r.randrange(c.W)
|
|
for k in range(r.randint(3, 9)):
|
|
c.put(x0 + k, y, col(base, 32), 0.6)
|
|
return c.out()
|
|
|
|
|
|
def band_tex(base, bands, seed, S=32, transpose=False, text=None):
|
|
"""Ракета: полосы по длине (bands: (центр 0..1, ширина px, цвет)), трафарет. Для верха/низа — транспонировано."""
|
|
c = Canvas(S, 1, 1, base, seed)
|
|
c.noise(2)
|
|
for f, w, cc in bands:
|
|
x0 = int(f * S - w / 2)
|
|
for x in range(x0, x0 + int(w)):
|
|
for y in range(S):
|
|
c.put(x, y, cc)
|
|
c.panels(12, 99, col(base, -26), col(base, 12), True)
|
|
if text:
|
|
for (x0, y0, w) in text: # «надпись» — тёмные штрихи
|
|
for k in range(0, w, 2):
|
|
c.rect(x0 + k, y0, x0 + k + 1, y0 + 3, col(base, -90))
|
|
c.wear(0.01, col(base, 30))
|
|
im = c.out()
|
|
return im.transpose(Image.TRANSPOSE) if transpose else im
|
|
|
|
|
|
def lattice(seed, S=32, base=(70, 76, 56), dot=(150, 154, 130)):
|
|
"""Антенное полотно / ФАР: решётка элементов."""
|
|
c = Canvas(S, 1, 1, base, seed)
|
|
c.noise(2)
|
|
for y in range(1, S - 1, 2):
|
|
for x in range(1, S - 1, 2):
|
|
c.put(x, y, dot, 0.9)
|
|
c.put(x + 1, y, col(base, -26), 0.8)
|
|
for k in range(0, S, 8):
|
|
for i in range(S):
|
|
c.put(k, i, col(base, -30))
|
|
c.put(i, k, col(base, -30))
|
|
return c.out()
|
|
|
|
|
|
def ember(seed, S=32):
|
|
return Canvas(S, 1, 1, (40, 30, 24), seed).out()
|
|
|
|
|
|
def burn(im, seed, kind="paint"):
|
|
"""Обгоревший вариант: уголь, копоть сверху, ржавые пятна, прогары."""
|
|
S = im.size[0]
|
|
r = random.Random(seed)
|
|
out = Image.new("RGBA", im.size)
|
|
src, dst = im.load(), out.load()
|
|
for y in range(S):
|
|
for x in range(S):
|
|
p = src[x, y]
|
|
g = (p[0] * 0.3 + p[1] * 0.5 + p[2] * 0.2)
|
|
k = 0.45 if kind != "glass" else 0.15
|
|
c = mix(p, (g * 0.55, g * 0.5, g * 0.46), 0.75)
|
|
c = tuple(clamp(c[i] * (0.3 + 0.35 * k) + 6) for i in range(3))
|
|
# копоть: темнее сверху
|
|
c = tuple(clamp(v * (0.75 + 0.25 * y / S)) for v in c)
|
|
dst[x, y] = c + (255,)
|
|
for _ in range(S * S // 28): # ржавые и обожжённые пятна
|
|
x, y = r.randrange(S), r.randrange(S)
|
|
rad = r.uniform(1, 3)
|
|
tone = r.choice([(96, 52, 28), (70, 40, 26), (22, 20, 20), (110, 80, 52)])
|
|
for yy in range(int(y - rad), int(y + rad + 1)):
|
|
for xx in range(int(x - rad), int(x + rad + 1)):
|
|
if (xx - x) ** 2 + (yy - y) ** 2 <= rad * rad and 0 <= xx < S and 0 <= yy < S:
|
|
o = dst[xx, yy]
|
|
dst[xx, yy] = tuple(clamp(o[i] + (tone[i] - o[i]) * 0.6) for i in range(3)) + (255,)
|
|
if kind == "glass": # трещины
|
|
for _ in range(4):
|
|
x, y = r.randrange(S), r.randrange(S)
|
|
for k in range(r.randint(6, 14)):
|
|
x += r.choice([-1, 0, 1])
|
|
y += r.choice([0, 1])
|
|
if 0 <= x < S and 0 <= y < S:
|
|
dst[x, y] = (150, 160, 166, 255)
|
|
return out
|