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.
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 973 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 539 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 973 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -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.")
|
||||
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 42 KiB |