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.
This commit is contained in:
2026-07-01 00:10:19 -04:00
parent 001ba3952e
commit d631ff784a
57 changed files with 1717 additions and 8 deletions
+493
View File
@@ -0,0 +1,493 @@
#!/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.")