#!/usr/bin/env python3 """ Generate procedural 1K PBR texture maps for the Tactical Shooter modular art kit. Produces: basecolor, normal, roughness, metallic — all at 1024x1024. Usage: python3 generate_textures.py """ import math import os import random from PIL import Image, ImageFilter, ImageChops, ImageDraw TEXTURE_DIR = os.path.expanduser("/home/oplabs/tactical-shooter/client/assets/textures") SIZE = 1024 random.seed(42) def save_pbr(base_dir, name, basecolor, normal, roughness, metallic): """Save all PBR maps for one material.""" d = os.path.join(base_dir, name) os.makedirs(d, exist_ok=True) basecolor.save(os.path.join(d, "basecolor.png")) normal.save(os.path.join(d, "normal.png")) roughness.save(os.path.join(d, "roughness.png")) metallic.save(os.path.join(d, "metallic.png")) print(f" Generated {name}/ — 4 maps saved") # ── Utility helpers ────────────────────────────────────────────────────── def gaussian_noise(size, scale=1.0, seed=None): """Generate a grayscale image with gaussian noise.""" if seed is not None: random.seed(seed) img = Image.new("L", (size, size)) pix = img.load() for y in range(size): for x in range(size): v = int(random.gauss(128, 64 * scale)) pix[x, y] = max(0, min(255, v)) return img def tile_blur(img, radius): """Blur with tileable boundary by mirroring edges before blur.""" w, h = img.size big = Image.new(img.mode, (w * 3, h * 3)) for dx in range(3): for dy in range(3): if dx == 1 and dy == 1: big.paste(img, (w, h)) else: # Mirror the tile mirror = img.transpose(Image.FLIP_LEFT_RIGHT) if dx % 2 == 0 else img if dy % 2 == 0: mirror = mirror.transpose(Image.FLIP_TOP_BOTTOM) big.paste(mirror, (w * dx, h * dy)) big = big.filter(ImageFilter.GaussianBlur(radius)) return big.crop((w, h, w * 2, h * 2)) def normal_from_height(height_img, strength=2.0): """Generate an RGB normal map from a grayscale height map using Sobel.""" w, h = height_img.size # Get pixel data hpix = height_img.load() normal = Image.new("RGB", (w, h)) npix = normal.load() for y in range(h): for x in range(w): # Sample height with wrapping for tileability sx1 = hpix[(x - 1) % w, y] sx2 = hpix[(x + 1) % w, y] sy1 = hpix[x, (y - 1) % h] sy2 = hpix[x, (y + 1) % h] dx = (sx2 - sx1) / 255.0 dy = (sy2 - sy1) / 255.0 dz = 1.0 / strength length = math.sqrt(dx * dx + dy * dy + dz * dz) nx = dx / length ny = dy / length nz = dz / length # Map from [-1,1] to [0,255] npix[x, y] = ( int((nx * 0.5 + 0.5) * 255), int((ny * 0.5 + 0.5) * 255), int((nz * 0.5 + 0.5) * 255), ) return normal def make_cellular(size, cell_size=32, seed=None): """Generate a cellular/Voronoi-like pattern as grayscale.""" if seed is not None: random.seed(seed) img = Image.new("L", (size, size)) pix = img.load() # Generate cell centers with random heights cells = {} for cx in range(0, size + cell_size, cell_size): for cy in range(0, size + cell_size, cell_size): ox = random.randint(0, cell_size - 1) oy = random.randint(0, cell_size - 1) h = random.randint(40, 200) cells[(cx + ox, cy + oy)] = h for y in range(size): for x in range(size): min_dist = float("inf") best_h = 128 for (cx, cy), h in cells.items(): d = (cx - x) ** 2 + (cy - y) ** 2 if d < min_dist: min_dist = d best_h = h pix[x, y] = best_h return img def concrete_base(size, base_rgb=(180, 175, 168), noise_scale=1.0): """Generate concrete-like basecolor with aggregate variation.""" # Start with light noise noise = gaussian_noise(size, scale=0.15, seed=42) noise = tile_blur(noise, 4) # Add larger variation big = gaussian_noise(size, scale=0.08, seed=43) big = tile_blur(big, 24) # Add some darker speckles (aggregate) speckle = Image.new("L", (size, size), 0) spix = speckle.load() for _ in range(800): x = random.randint(0, size - 1) y = random.randint(0, size - 1) spix[x, y] = random.randint(30, 80) speckle = tile_blur(speckle, 1.5) # Combine combined = Image.new("L", (size, size)) cpix = combined.load() npix = noise.load() bpix = big.load() skpix = speckle.load() for y in range(size): for x in range(size): v = npix[x, y] + bpix[x, y] - 128 + skpix[x, y] - 30 cpix[x, y] = max(0, min(255, v)) # Tint and apply result = Image.new("RGB", (size, size)) rpix = result.load() cp = combined.load() for y in range(size): for x in range(size): f = cp[x, y] / 255.0 rpix[x, y] = ( max(0, min(255, int(base_rgb[0] * f / 180))), max(0, min(255, int(base_rgb[1] * f / 175))), max(0, min(255, int(base_rgb[2] * f / 168))), ) return result # ── Material definitions ───────────────────────────────────────────────── def generate_wall_concrete_01(): """Light clean concrete — primary wall material.""" size = SIZE # Basecolor bc = concrete_base(size, base_rgb=(200, 195, 185)) bc = bc.filter(ImageFilter.SMOOTH) # Height map for normal (slight texture) height = gaussian_noise(size, scale=0.1, seed=100) height = tile_blur(height, 3) normal = normal_from_height(height, strength=1.5) # Roughness — concrete is fairly rough rough = gaussian_noise(size, scale=0.08, seed=200) rough = tile_blur(rough, 6) # Brighten a bit (rougher = lighter in roughness map) rpix = rough.load() for y in range(size): for x in range(size): rpix[x, y] = min(255, rpix[x, y] + 40) # Metallic — none metallic = Image.new("L", (size, size), 0) save_pbr(TEXTURE_DIR, "wall_concrete_01", bc, normal, rough, metallic) def generate_wall_concrete_02(): """Darker, smoother concrete — accent walls.""" size = SIZE bc = concrete_base(size, base_rgb=(145, 140, 135)) bc = bc.filter(ImageFilter.SMOOTH_MORE) height = gaussian_noise(size, scale=0.06, seed=101) height = tile_blur(height, 5) normal = normal_from_height(height, strength=1.2) rough = gaussian_noise(size, scale=0.06, seed=201) rough = tile_blur(rough, 8) rpix = rough.load() for y in range(size): for x in range(size): rpix[x, y] = min(255, rpix[x, y] + 30) metallic = Image.new("L", (size, size), 0) save_pbr(TEXTURE_DIR, "wall_concrete_02", bc, normal, rough, metallic) def generate_floor_tile_01(): """Checkerboard tile floor — interior spaces.""" size = SIZE tile_size = 64 # 64px tiles at 1024 = 16 tiles across bc = Image.new("RGB", (size, size)) bpix = bc.load() for y in range(size): for x in range(size): tx = x // tile_size ty = y // tile_size if (tx + ty) % 2 == 0: # Light tile v = 200 + random.randint(-10, 10) bpix[x, y] = (v, v - 5, v - 10) else: # Dark tile v = 160 + random.randint(-8, 8) bpix[x, y] = (v, v - 3, v - 5) # Add grout lines draw = ImageDraw.Draw(bc) for i in range(0, size + 1, tile_size): draw.line([(i, 0), (i, size)], fill=(70, 65, 60), width=2) draw.line([(0, i), (size, i)], fill=(70, 65, 60), width=2) # Height map for normal height = Image.new("L", (size, size), 128) hpix = height.load() for y in range(size): for x in range(size): tx = x // tile_size ty = y // tile_size gx = x % tile_size gy = y % tile_size # Slight bevel around edges (tile center higher) d = min(gx, tile_size - gx, gy, tile_size - gy) if d < 3: hpix[x, y] = 100 # Grout is lower else: hpix[x, y] = 140 + (d % 4) * 5 # Apply bevel and tile variation hpix = height.load() for y in range(size): for x in range(size): tx = x // tile_size ty = y // tile_size gx = x % tile_size gy = y % tile_size d_edge = min(gx, tile_size - gx, gy, tile_size - gy) if d_edge < 2: hpix[x, y] = 90 elif d_edge < 5: hpix[x, y] = 100 + d_edge * 8 else: # Gentle tile surface variation hpix[x, y] = 140 + ((tx * 13 + ty * 7 + x + y) % 8) * 3 normal = normal_from_height(height, strength=3.0) # Roughness — tiles are smooth rough = Image.new("L", (size, size), 80) rpix = rough.load() for y in range(size): for x in range(size): tx = x // tile_size ty = y // tile_size gx = x % tile_size gy = y % tile_size d_edge = min(gx, tile_size - gx, gy, tile_size - gy) if d_edge < 2: rpix[x, y] = 200 # Grout is rough else: rpix[x, y] = 60 + ((tx * 17 + ty * 11) % 10) * 4 metallic = Image.new("L", (size, size), 0) save_pbr(TEXTURE_DIR, "floor_tile_01", bc, normal, rough, metallic) def generate_floor_concrete_01(): """Industrial dark concrete floor — warehouse/industrial areas.""" size = SIZE # Base bc = concrete_base(size, base_rgb=(130, 128, 123)) bc = bc.filter(ImageFilter.SMOOTH) # Add surface scratches/wear marks draw = ImageDraw.Draw(bc) for _ in range(50): x1 = random.randint(0, size) y1 = random.randint(0, size) length = random.randint(10, 100) angle = random.uniform(0, math.pi * 2) x2 = x1 + int(math.cos(angle) * length) y2 = y1 + int(math.sin(angle) * length) c = random.randint(100, 140) draw.line([(x1, y1), (x2, y2)], fill=(c, c - 3, c - 5), width=1) height = gaussian_noise(size, scale=0.15, seed=102) height = tile_blur(height, 3) hpix = height.load() # Add groove lines (expansion joints) for y in range(size): for x in range(size): gx = x % 256 gy = y % 256 if min(gx, 256 - gx) < 2 or min(gy, 256 - gy) < 2: hpix[x, y] = max(0, hpix[x, y] - 40) normal = normal_from_height(height, strength=2.5) rough = Image.new("L", (size, size), 160) rpix = rough.load() for y in range(size): for x in range(size): gx = x % 256 gy = y % 256 if min(gx, 256 - gx) < 2 or min(gy, 256 - gy) < 2: rpix[x, y] = 220 # Grooves are rougher else: rpix[x, y] = 150 + (hash((x // 8, y // 8)) % 20) metallic = Image.new("L", (size, size), 0) save_pbr(TEXTURE_DIR, "floor_concrete_01", bc, normal, rough, metallic) def generate_metal_structural_01(): """Structural metal — beams, supports, vents.""" size = SIZE # Base — grey metallic bc = Image.new("RGB", (size, size)) bpix = bc.load() for y in range(size): for x in range(size): v = 160 + int(random.gauss(0, 4)) bpix[x, y] = (max(0, min(255, v)), max(0, min(255, v - 2)), max(0, min(255, v - 4))) # Add horizontal brush marks draw = ImageDraw.Draw(bc) for _ in range(200): bx = random.randint(0, size - 1) by = random.randint(0, size - 1) c = random.randint(-6, 6) draw.line([(bx, by), (bx + random.randint(10, 60), by)], fill=(bpix[bx, by][0] + c,) * 3, width=1) # Height map for normal height = gaussian_noise(size, scale=0.05, seed=103) height = tile_blur(height, 2) hpix = height.load() for y in range(size): for x in range(size): hpix[x, y] = max(0, min(255, hpix[x, y] - 10)) # Flatter than concrete normal = normal_from_height(height, strength=1.0) # Subtle # Roughness — smooth metal rough = Image.new("L", (size, size), 40) rpix = rough.load() for y in range(size): for x in range(size): hp = hpix[x, y] rpix[x, y] = 30 + (hp - 128) // 8 # Metallic — yes! metallic = Image.new("L", (size, size), 255) save_pbr(TEXTURE_DIR, "metal_structural_01", bc, normal, rough, metallic) def generate_accent_team_blue(): """Blue team accent — CT side colored panels.""" size = SIZE # Deep blue bc = Image.new("RGB", (size, size)) bpix = bc.load() for y in range(size): for x in range(size): noise = random.randint(-8, 8) bpix[x, y] = ( max(0, min(255, 40 + noise)), max(0, min(255, 80 + noise)), max(0, min(255, 200 + noise)), ) bc = bc.filter(ImageFilter.SMOOTH) # Slight brushed pattern draw = ImageDraw.Draw(bc) for y in range(0, size, 4): v = random.randint(-4, 4) draw.line([(0, y), (size, y)], fill=(40 + v, 80 + v, 200 + v), width=1) height = Image.new("L", (size, size), 128) hpix = height.load() for y in range(size): for x in range(size): hpix[x, y] = 128 + int(random.gauss(0, 3)) hpix = height.load() for y in range(0, size, 4): for x in range(size): hpix[x, y] = 110 # Slight panel line normal = normal_from_height(height, strength=0.8) rough = Image.new("L", (size, size), 50) rpix = rough.load() for y in range(size): for x in range(size): rpix[x, y] = 40 + (hash((x // 16, y // 16)) % 15) # Slightly metallic (painted metal) metallic = Image.new("L", (size, size), 128) save_pbr(TEXTURE_DIR, "accent_team_blue", bc, normal, rough, metallic) def generate_accent_team_red(): """Red/orange team accent — T side colored panels.""" size = SIZE bc = Image.new("RGB", (size, size)) bpix = bc.load() for y in range(size): for x in range(size): noise = random.randint(-8, 8) bpix[x, y] = ( max(0, min(255, 200 + noise)), max(0, min(255, 60 + noise)), max(0, min(255, 30 + noise)), ) bc = bc.filter(ImageFilter.SMOOTH) draw = ImageDraw.Draw(bc) for y in range(0, size, 4): v = random.randint(-4, 4) draw.line([(0, y), (size, y)], fill=(200 + v, 60 + v, 30 + v), width=1) height = Image.new("L", (size, size), 128) hpix = height.load() for y in range(size): for x in range(size): hpix[x, y] = 128 + int(random.gauss(0, 3)) for y in range(0, size, 4): for x in range(size): hpix[x, y] = 110 normal = normal_from_height(height, strength=0.8) rough = Image.new("L", (size, size), 50) rpix = rough.load() for y in range(size): for x in range(size): rpix[x, y] = 40 + (hash((x // 16, y // 16)) % 15) metallic = Image.new("L", (size, size), 128) save_pbr(TEXTURE_DIR, "accent_team_red", bc, normal, rough, metallic) # ── Main ──────────────────────────────────────────────────────────────── if __name__ == "__main__": os.makedirs(TEXTURE_DIR, exist_ok=True) print("Generating PBR textures (1K)…") print(" [1/7] wall_concrete_01") generate_wall_concrete_01() print(" [2/7] wall_concrete_02") generate_wall_concrete_02() print(" [3/7] floor_tile_01") generate_floor_tile_01() print(" [4/7] floor_concrete_01") generate_floor_concrete_01() print(" [5/7] metal_structural_01") generate_metal_structural_01() print(" [6/7] accent_team_blue") generate_accent_team_blue() print(" [7/7] accent_team_red") generate_accent_team_red() print("\nDone — 7 materials × 4 maps = 28 texture files generated at 1K.")