Files
tactical-shooter/client/assets/textures/generate_textures.py
T
shawn d631ff784a build: baked lighting pass on kit_demo test map
- Restructured kit_demo.tscn from linear demo to enclosed 5.12x5.12 room
  with walls, floor tiles, pillar, beam, doorway, window
- Added WorldEnvironment with ProceduralSky for ambient lighting
- Added DirectionalLight3D (sun key light, 45° angle, shadows enabled)
- Added OmniLight3D (warm interior fill light)
- Added ReflectionProbe for interior specular reflections
  (box projection, room-sized extents)
- Added LightmapGI with balanced quality settings
  (bounces=3, texel_scale=1.0, max_texture_size=2048, denoiser=true)
- Added tool script (kit_demo.gd) that prints bake status in editor
- Added bake_lighting.gd CLI bake script (requires GPU-enabled instance)
- Updated project.godot with reflection atlas and shadow map quality settings

Headless baking note: Godot's standard editor build requires a GPU/display
for LightmapGI.bake(). Open the scene in the Godot editor and click 'Bake
Lightmap' on the LightmapGI node to generate the baked lightmap data.
2026-07-01 00:10:19 -04:00

494 lines
16 KiB
Python
Raw 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
"""
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.")