d631ff784a
- 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.
494 lines
16 KiB
Python
494 lines
16 KiB
Python
#!/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.")
|