"""Общая палитра и генератор текстур мода 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 # ------------------------------------------------------------------ узоры LIFT = 1.35 # краска светлее: средняя яркость камуфляжа ~85-100 sRGB (вживую выглядело почти чёрным) def lifted(c, k=LIFT): return tuple(clamp(v * k) for v in c[:3]) def painted(base, seed, S=32, sx=1, sy=1, panel=(14, 10), amp=3, dirt=0.1, wear=0.012, rivets=True, lift=LIFT): """Крашеный лист: шум, швы, заклёпки, подтёки, сколы.""" base = lifted(base, lift) 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 = [lifted(v) for v in 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 FONT = {"0": "111101101101111", "1": "010110010010111", "2": "111001111100111", "3": "111001111001111", "4": "101101111001001", "5": "111100111001111", "6": "111100111101111", "7": "111001001001001", "8": "111101111101111", "9": "111101111001111", "-": "000000111000000"} STAR = ["...#...", "...#...", "#######", ".#####.", "..###..", ".##.##.", ".#...#."] def marking(kind, bg, seed, text="312", S=32): """Табличка-маркировка на борт (непрозрачная: фон — цвет камуфляжа). Рисунок в левом верхнем углу 16x8 пикселей (грань 8x4 единицы модели показывает ровно эту часть текстуры).""" c = Canvas(S, 1, 1, lifted(bg), seed) c.noise(3) ink = (232, 232, 224) if kind == "digits": for i, ch in enumerate(text): for k, bit in enumerate(FONT[ch]): if bit == "1": c.put(2 + i * 4 + k % 3, 1 + k // 3, ink) elif kind == "star": for y, row in enumerate(STAR): for x, ch in enumerate(row): if ch == "#": c.put(4 + x, y, (200, 36, 32)) for x in range(3, 12): c.put(x, 7, ink) elif kind == "cross": for y in range(8): for x in range(4, 12): c.put(x, y, ink if (x in (4, 11) or y in (0, 7)) else (22, 22, 22)) for y in range(8): for x in range(7, 9): c.put(x, y, (22, 22, 22)) for x in range(4, 12): for y in (3, 4): c.put(x, y, (22, 22, 22)) c.dirt(0.05) return c.out() def cap_tex(seed, base=(92, 102, 74), S=32): """Торец ТПК: тёмный круг с кольцом, жёлто-чёрные полосы по краю.""" c = Canvas(S, 1, 1, lifted(base), seed) c.noise(3) c.disc(S / 2, S / 2, S * 0.42, (36, 38, 32)) c.disc(S / 2, S / 2, S * 0.30, (62, 66, 54)) c.disc(S / 2, S / 2, S * 0.12, (110, 114, 96)) for i in range(S): if (i // 3) % 2 == 0: c.put(i, 0, (200, 170, 40)) c.put(i, S - 1, (200, 170, 40)) return c.out()