#!/usr/bin/env python3
"""Fetch the built-in demo texture packs (#94 P1, design §7a) into texture_packs/.
Reproducible: every source is pinned (asset ids, LibreQuake commit), so re-running
produces the same tree. Each pack gets a pack.json (design §7) and its licence.
Per texture the pack keeps only what the loader uses:
albedo colour map
normal DirectX-convention normal (+G = +v, image DOWN) — dc_level.gdshader adds
sn.y * v_axis with no flip, so DX is the convention it reads directly
orm R = ambient occlusion, G = roughness, B = metalness (Poly Haven's `arm`
layout; ambientCG maps are packed into it here)
height the source's displacement map as a 16-BIT PNG (white = high) — for parallax
(POM / silhouette POM) and normal generation. ambientCG: the 1K-PNG zip's
16-bit Displacement; Poly Haven: the float EXR (its PNG is only 8-bit).
.blend, usdc, mtlx, opacity and preview renders are dropped.
Per texture with a height map, pack.json also gets
height_m the relief depth in metres (height 0..1 -> 0..height_m). FITTED by least squares
against the shipped normal (the slope the normal encodes = height_m x the height
gradient per metre), so POM and generated normals use the material's own scale.
A texture with a height map but NO normal gets one GENERATED from the height (DirectX,
DEFAULT_HEIGHT_M when nothing to fit against).
--check regression check on an existing tree, no network: regenerate every normal that
ships alongside a height map and correlate it with the shipped one (R, G, mean
angle). Fails if any channel correlation is <= CHECK_MIN_CORR — a flipped green
channel shows up as a NEGATIVE G correlation.
Needs numpy + OpenEXR (Poly Haven heights): ~/.venvs/texpack/bin/python tools/fetch_texture_packs.py
Usage: tools/fetch_texture_packs.py [--out texture_packs] [--cache DIR] [--only PACK] [--check]
"""
import argparse
import concurrent.futures
import io
import json
import os
import re
import sys
import tempfile
import urllib.parse
import urllib.request
import zipfile
import numpy as np
from PIL import Image
AMBIENTCG = ["Bricks105", "Concrete034", "Plaster001", "Tiles141", "WoodFloor064",
"Planks037A", "DiamondPlate009", "Metal041B", "PaintedMetal004",
"CorrugatedSteel007A", "Asphalt033", "PavingStones151"]
POLYHAVEN = ["castle_wall_slates", "cliff_side", "broken_wall", "aerial_rocks_02",
"clean_pebbles"]
KENNEY_URL = ("https://kenney.nl/media/pages/assets/prototype-textures/"
"a88c69fa18-1677578307/kenney_prototype-textures.zip")
LQ_REPO = "lavenderdotpet/LibreQuake"
LQ_THEMES = ["lq_tech", "lq_metal", "lq_medieval", "lq_terra", "lq_wood"]
# 1 px = 1 Quake unit, and quake_entities.gd WORLD_SCALE = 0.028 m per unit.
QUAKE_M_PER_PX = 0.028
JPG_QUALITY = 92
DEFAULT_HEIGHT_M = 0.02 # relief depth when a height map has no normal to fit against
CHECK_MIN_CORR = 0.3
# Albedo-only textures (LibreQuake) get normals from DeepBump (GPL-3 tool, run here at
# fetch time only — not shipped). Pinned; cloned into the cache, never vendored.
DEEPBUMP_REPO = "https://github.com/HugoTini/DeepBump.git"
DEEPBUMP_COMMIT = "fad19ba"
DEFAULT_CACHE = os.path.expanduser("~/.cache/hyper-tps-fetch")
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": "hyper-tps-fetch/1"})
with urllib.request.urlopen(req, timeout=120) as r:
return r.read()
def get_json(url):
return json.loads(get(url))
def cached(cache, name, url):
if cache:
p = os.path.join(cache, name)
if os.path.exists(p):
with open(p, "rb") as f:
return f.read()
data = get(url)
if cache:
os.makedirs(cache, exist_ok=True)
with open(os.path.join(cache, name), "wb") as f:
f.write(data)
return data
def write(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
def write_pack(out, pack):
pack["textures"].sort(key=lambda t: t["name"])
write(os.path.join(out, "pack.json"),
(json.dumps(pack, indent=1, sort_keys=False) + "\n").encode())
print(f"{pack['id']}: {len(pack['textures'])} textures", file=sys.stderr)
def tex(name, albedo, w, h, tile_u, tile_v, normal="", orm="", height=""):
return {"name": name, "albedo": albedo, "normal": normal, "orm": orm, "height": height,
"size": [w, h], "tile_u_m": round(tile_u, 4), "tile_v_m": round(tile_v, 4),
"kind": "tiling", "rects": None}
def pack_orm(size, ao=None, rough=None, metal=None):
"""R=AO (default white), G=roughness (default 0.8), B=metal (default 0)."""
def ch(img, default):
if img is None:
return Image.new("L", size, default)
return img.convert("L").resize(size, Image.BILINEAR)
return Image.merge("RGB", (ch(ao, 255), ch(rough, 204), ch(metal, 0)))
def jpg(img):
b = io.BytesIO()
img.save(b, "JPEG", quality=JPG_QUALITY, optimize=True)
return b.getvalue()
def png16(h01):
"""A 0..1 float array -> 16-bit greyscale PNG bytes."""
b = io.BytesIO()
Image.fromarray((np.clip(h01, 0.0, 1.0) * 65535.0 + 0.5).astype(np.uint16)).save(b, "PNG")
return b.getvalue()
def exr_height(data):
"""A single-channel displacement EXR (Poly Haven: Y, half float, 0..1) -> float array."""
import OpenEXR # only Poly Haven needs it
with tempfile.NamedTemporaryFile(suffix=".exr") as f:
f.write(data)
f.flush()
with OpenEXR.File(f.name) as e:
ch = e.channels()
key = "Y" if "Y" in ch else next(k for k in ch if k not in ("A",))
return np.asarray(ch[key].pixels, dtype=np.float64)
def load_height(path):
"""height.png (16- or 8-bit) -> float 0..1."""
im = Image.open(path)
if im.mode in ("I;16", "I;16B", "I"):
return np.asarray(im, dtype=np.float64) / 65535.0
return np.asarray(im.convert("L"), dtype=np.float64) / 255.0
def load_normal_dx(path, shape):
"""normal.* -> float -1..1 xyz, resized to (h, w) = shape."""
im = Image.open(path).convert("RGB")
if im.size != (shape[1], shape[0]):
im = im.resize((shape[1], shape[0]), Image.BILINEAR)
return np.asarray(im, dtype=np.float64) / 127.5 - 1.0
def height_gradient(h, tile_u_m, tile_v_m):
"""d(height)/d(metre) along u (image right) and v (image DOWN): central differences
with WRAP, since every pack texture tiles."""
rows, cols = h.shape
gu = (np.roll(h, -1, 1) - np.roll(h, 1, 1)) / (2.0 * tile_u_m / cols)
gv = (np.roll(h, -1, 0) - np.roll(h, 1, 0)) / (2.0 * tile_v_m / rows)
return gu, gv
def height_to_normal_dx(h, height_m, tile_u_m, tile_v_m):
"""Tangent-space normal, DirectX convention (+G = image down, what dc_level.gdshader
reads): n = normalize(-dz/du, -dz/dv, 1). Godot's Image.bump_map_to_normal_map writes
the OpenGL green instead (measured: G correlates NEGATIVELY with the packs' DX maps)."""
gu, gv = height_gradient(h, tile_u_m, tile_v_m)
n = np.stack([-gu * height_m, -gv * height_m, np.ones_like(h)], -1)
return n / np.linalg.norm(n, axis=-1, keepdims=True)
def normal_rgb8(n):
return Image.fromarray(np.clip(n * 127.5 + 127.5 + 0.5, 0, 255).astype(np.uint8), "RGB")
def fit_height_m(h, n, tile_u_m, tile_v_m):
"""Least-squares relief depth: the slope a normal encodes is (-nx/nz, -ny/nz), and it
should equal height_m x the height gradient per metre."""
gu, gv = height_gradient(h, tile_u_m, tile_v_m)
nz = np.maximum(n[..., 2], 0.2)
su, sv = -n[..., 0] / nz, -n[..., 1] / nz
den = float((gu * gu + gv * gv).sum())
return float((gu * su + gv * sv).sum()) / den if den > 0 else 0.0
_deepbump = None
def deepbump_module(cache):
"""DeepBump's color->normals module at DEEPBUMP_COMMIT (cloned once into the cache)."""
global _deepbump
if _deepbump is None:
import subprocess
d = os.path.join(cache or DEFAULT_CACHE, f"DeepBump-{DEEPBUMP_COMMIT}")
if not os.path.exists(os.path.join(d, "deepbump256.onnx")):
subprocess.run(["git", "clone", "-q", DEEPBUMP_REPO, d], check=True)
subprocess.run(["git", "-C", d, "checkout", "-q", DEEPBUMP_COMMIT], check=True)
head = subprocess.run(["git", "-C", d, "rev-parse", "--short=7", "HEAD"], check=True,
capture_output=True, text=True).stdout.strip()
if head != DEEPBUMP_COMMIT:
sys.exit(f"DeepBump checkout {d} is at {head}, want {DEEPBUMP_COMMIT}")
sys.path.insert(0, d)
import module_color_to_normals
_deepbump = module_color_to_normals
return _deepbump
def deepbump_normal_png(albedo_bytes, cache):
"""Albedo PNG bytes -> DirectX normal PNG bytes at the SAME size (one normal per texel,
so nearest sampling keeps the pixel grid). DeepBump wrap-pads internally, so the result
tiles. Its green is OpenGL (measured: G anti-correlates with the packs' DX normals once R
agrees), so G is flipped. Cached by albedo hash."""
import hashlib
key = hashlib.sha1(albedo_bytes).hexdigest()
cdir = os.path.join(cache or DEFAULT_CACHE, f"deepbump-normals-{DEEPBUMP_COMMIT}")
cpath = os.path.join(cdir, key + ".png")
if os.path.exists(cpath):
with open(cpath, "rb") as f:
return f.read()
rgb = np.asarray(Image.open(io.BytesIO(albedo_bytes)).convert("RGB"), dtype=np.float32) / 255.0
n = deepbump_module(cache).apply(np.transpose(rgb, (2, 0, 1)), "LARGE", None) # C,H,W 0..1
n = np.transpose(n, (1, 2, 0)) * 2.0 - 1.0
n[..., 1] *= -1.0
buf = io.BytesIO()
normal_rgb8(n).save(buf, "PNG")
os.makedirs(cdir, exist_ok=True)
with open(cpath, "wb") as f:
f.write(buf.getvalue())
return buf.getvalue()
def finish_height(out, t):
"""Fit height_m (or default it) and generate a missing normal. t is the pack.json entry."""
if not t["height"]:
return
h = load_height(os.path.join(out, t["height"]))
if t["normal"]:
hm = fit_height_m(h, load_normal_dx(os.path.join(out, t["normal"]), h.shape),
t["tile_u_m"], t["tile_v_m"])
t["height_m"] = round(hm, 5) if hm > 0 else DEFAULT_HEIGHT_M
else:
t["height_m"] = DEFAULT_HEIGHT_M
rel = os.path.join(os.path.dirname(t["height"]), "normal.png")
buf = io.BytesIO()
normal_rgb8(height_to_normal_dx(h, t["height_m"], t["tile_u_m"], t["tile_v_m"])).save(buf, "PNG")
write(os.path.join(out, rel), buf.getvalue())
t["normal"] = rel
t["normal_generated"] = "height"
def fetch_ambientcg(root, cache):
out = os.path.join(root, "ambientcg")
pack = {"id": "ambientcg", "name": "ambientCG", "author": "Lennart Demes (ambientCG)",
"license": "CC0-1.0", "url": "https://ambientcg.com", "textures": []}
for a in AMBIENTCG:
z = zipfile.ZipFile(io.BytesIO(cached(cache, f"{a}_1K-JPG.zip",
f"https://ambientcg.com/get?file={a}_1K-JPG.zip")))
maps = {}
for n in z.namelist():
m = re.match(rf"{a}_1K-JPG_(\w+)\.jpg$", n)
if m:
maps[m.group(1)] = Image.open(io.BytesIO(z.read(n)))
meta = get_json(f"https://ambientcg.com/api/v2/full_json?id={a}&include=dimensionsData")
d = meta["foundAssets"][0]
col = maps["Color"]
w, h = col.size
write(os.path.join(out, a, "albedo.jpg"), z.read(f"{a}_1K-JPG_Color.jpg"))
write(os.path.join(out, a, "normal.jpg"), z.read(f"{a}_1K-JPG_NormalDX.jpg"))
write(os.path.join(out, a, "orm.jpg"), jpg(pack_orm(
(w, h), maps.get("AmbientOcclusion"), maps.get("Roughness"), maps.get("Metalness"))))
# Height from the 1K-PNG zip: its Displacement is 16-bit (the JPG one is 8-bit + JPEG
# blocks). Stored as-is.
height = ""
zp = zipfile.ZipFile(io.BytesIO(cached(cache, f"{a}_1K-PNG.zip",
f"https://ambientcg.com/get?file={a}_1K-PNG.zip")))
if f"{a}_1K-PNG_Displacement.png" in zp.namelist():
write(os.path.join(out, a, "height.png"), zp.read(f"{a}_1K-PNG_Displacement.png"))
height = f"{a}/height.png"
# ambientCG dimensions are centimetres; 0 = not declared -> assume 2 m square.
tu = d.get("dimensionX", 0) / 100.0 or 2.0
tv = d.get("dimensionY", 0) / 100.0 or tu * h / w
pack["textures"].append(tex(a, f"{a}/albedo.jpg", w, h, tu, tv,
f"{a}/normal.jpg", f"{a}/orm.jpg", height))
finish_height(out, pack["textures"][-1])
write(os.path.join(out, "LICENSE.txt"), b"ambientCG assets are CC0 1.0 Universal "
b"(public domain). https://docs.ambientcg.com/license/\n")
write_pack(out, pack)
def fetch_polyhaven(root, cache):
out = os.path.join(root, "polyhaven")
pack = {"id": "polyhaven", "name": "Poly Haven", "author": "Poly Haven contributors",
"license": "CC0-1.0", "url": "https://polyhaven.com", "textures": []}
for a in POLYHAVEN:
files = get_json(f"https://api.polyhaven.com/files/{a}")
info = get_json(f"https://api.polyhaven.com/info/{a}")
got = {}
for key, dst in (("Diffuse", "albedo.jpg"), ("nor_dx", "normal.jpg"), ("arm", "orm.jpg")):
if key not in files:
continue
url = files[key]["1k"]["jpg"]["url"]
got[dst] = cached(cache, f"ph_{a}_{dst}", url)
write(os.path.join(out, a, dst), got[dst])
# Height from the float EXR (Poly Haven's PNG displacement is only 8-bit).
height = ""
if "Displacement" in files:
exr = cached(cache, f"ph_{a}_disp.exr", files["Displacement"]["1k"]["exr"]["url"])
write(os.path.join(out, a, "height.png"), png16(exr_height(exr)))
height = f"{a}/height.png"
w, h = Image.open(io.BytesIO(got["albedo.jpg"])).size
dim = info.get("dimensions") or [2000, 2000] # millimetres
pack["textures"].append(tex(a, f"{a}/albedo.jpg", w, h, dim[0] / 1000.0,
dim[1] / 1000.0, f"{a}/normal.jpg", f"{a}/orm.jpg", height))
pack["textures"][-1]["author"] = ", ".join(sorted(info.get("authors", {})))
finish_height(out, pack["textures"][-1])
write(os.path.join(out, "LICENSE.txt"), b"Poly Haven assets are CC0 1.0 Universal "
b"(public domain). https://polyhaven.com/license\n")
write_pack(out, pack)
# TRIMSHEETS.COM (user, 2026-10-03: "get anything available"; 1024 shipped, 2048 sources
# kept). Site statement: "The free trim sheets are available under the Creative Commons
# CC0 Public Domain License and may be used for commercial purposes, attribution is
# optional." The catalogue credits individual artists; each texture carries its credit.
# Inspected 2026-10-03: 12 entries, 2048² PBR (albedo, normal, displacement, AO,
# roughness, metalness), NO rect / layout data.
TRIMSHEETS = "https://trimsheets.com"
# Excluded, measured: 10 Beach Boardwalk Stylized (906x842 though listed 1024², normal R
# anti-correlates with its own displacement — the maps disagree); 11 Wood Stylized (no
# displacement, and its albedo is Concrete Trimsheet's file — a catalogue error).
TRIMSHEETS_SKIP = {"10", "11"}
TRIMSHEETS_TILE_M = 2.0 # physical sheet size: NOT published; 2 m assumed (1024 px/m source)
TRIMSHEETS_RES = 1024 # shipped size; the 2048 sources stay in texture_sources/
TRIMSHEETS_MIN_CORR = 0.2 # below this the normal/displacement pair is inconclusive
def _trimsheet_normal_sign(h, n):
"""+1 if the normal is DirectX against its own displacement, -1 if OpenGL, 0 if the
pair is inconclusive (|corr| < TRIMSHEETS_MIN_CORR). h 0..1, n -1..1, same shape."""
gv = np.roll(h, -1, 0) - np.roll(h, 1, 0) # +v = image DOWN
c = np.corrcoef(-gv.ravel(), n[..., 1].ravel())[0, 1]
if not np.isfinite(c) or abs(c) < TRIMSHEETS_MIN_CORR:
return 0
return 1 if c > 0 else -1
def fetch_trimsheets(root, cache, sources="texture_sources"):
out = os.path.join(root, "trimsheets")
src = os.path.join(sources, "trimsheets")
pack = {"id": "trimsheets", "name": "Trimsheets.com", "author": "trimsheets.com contributors",
"license": "CC0-1.0", "url": "https://trimsheets.com", "textures": []}
cat = get_json(TRIMSHEETS + "/public/json/trims.json")
for k, v in sorted(cat.items(), key=lambda kv: int(kv[0])):
if k in TRIMSHEETS_SKIP:
continue
name = re.sub(r"[^a-z0-9]+", "_", v["name"].strip().lower()).strip("_")
raw = {}
for m, p in v["images"].items():
ext = os.path.splitext(p)[1].lower()
data = cached(cache, f"trims_{k}_{m}{ext}", TRIMSHEETS + urllib.parse.quote(p))
write(os.path.join(src, name, m + ext), data) # the 2048 SOURCE, kept
raw[m] = Image.open(io.BytesIO(data))
size = (TRIMSHEETS_RES, TRIMSHEETS_RES)
fit = lambda im: im.resize(size, Image.LANCZOS)
albedo = fit(raw["albedo"].convert("RGB"))
write(os.path.join(out, name, "albedo.jpg"), jpg(albedo))
n = np.asarray(fit(raw["normal"].convert("RGB")), np.float64) / 127.5 - 1.0
h = np.asarray(fit(raw["displacement"].convert("L")), np.float64) / 255.0 \
if "displacement" in raw else None
sign = _trimsheet_normal_sign(h, n) if h is not None else 0
if sign <= 0:
n[..., 1] = -n[..., 1] # OpenGL -> DirectX (inconclusive: the artist's usual GL)
b = io.BytesIO()
normal_rgb8(n).save(b, "PNG")
write(os.path.join(out, name, "normal.png"), b.getvalue())
write(os.path.join(out, name, "orm.jpg"), jpg(pack_orm(
size, raw.get("ao"), raw.get("roughness"), raw.get("metalness"))))
height = ""
if h is not None and sign != 0:
write(os.path.join(out, name, "height.png"), png16(h))
height = f"{name}/height.png"
t = tex(name, f"{name}/albedo.jpg", TRIMSHEETS_RES, TRIMSHEETS_RES, TRIMSHEETS_TILE_M,
TRIMSHEETS_TILE_M, f"{name}/normal.png", f"{name}/orm.jpg", height)
t["source"] = f"trimsheets/{name}"
t["source_size"] = list(raw["albedo"].size)
t["normal_convention"] = {1: "dx", -1: "gl (flipped)", 0: "inconclusive, assumed gl"}[sign]
if v.get("creditText") or v.get("creditUrl"):
t["credit"] = v.get("creditUrl", "")
if "trimsheet" in v["name"].lower():
t["kind"] = "trim" # rects authored separately (hotspot design §2A)
pack["textures"].append(t)
finish_height(out, t)
write(os.path.join(out, "LICENSE.txt"), b"Trimsheets.com: \"The free trim sheets are "
b"available under the Creative Commons CC0 Public Domain License and may be used "
b"for commercial purposes, attribution is optional.\" https://trimsheets.com\n"
b"Per-texture credits are in pack.json (\"credit\").\n")
write_pack(out, pack)
write(os.path.join(sources, ".gdignore"), b"") # sources are not game assets
# KENNEY: DERIVED surface maps (user, 2026-10-03). The prototype textures are flat paint
# plus thin marking lines in an EXACT palette — measured: one or two fill colours each own
# >= 5 % of the pixels, every line colour well under 3 % — so the surface is derived from
# the palette, not guessed by a network: fill pixels are the surface, line pixels are
# shallow grooves (deeper the more they contrast with the fill, so the main grid sits
# deeper than the faint sub-grid), bevelled by a small blur so the normal has width.
# Roughness / AO values are a DESIGN choice (matte prototype paint), not measured.
KENNEY_FILL_MIN = 0.05 # a colour covering >= 5 % of the texture is a fill
# RAISED TILES (user, 2026-10-03; the first 1.5 mm / 1.5 px groove was invisible at
# play distance): every grid cell is a panel whose edge CHAMFERS down to the line over
# KENNEY_CHAMFER_PX, so the lines read as the gaps between raised tiles.
KENNEY_GROOVE_M = 0.004 # tile height above a full-contrast line: 4 mm
KENNEY_CHAMFER_PX = 8 # chamfer width in px (1024 px per metre -> 8 mm)
KENNEY_BEVEL_PX = 1.0 # light blur after the chamfer, to round the crease
KENNEY_ROUGH_FILL = 0.65 # matte paint
KENNEY_ROUGH_LINE = 0.45 # marking paint, a little glossier
KENNEY_AO_GROOVE = 0.75 # AO at the bottom of a full-depth groove
def kenney_derive(albedo_bytes):
"""Albedo PNG bytes -> (height 0..1 float, normal PIL, orm PIL). 1 = surface."""
rgb = np.asarray(Image.open(io.BytesIO(albedo_bytes)).convert("RGB")).astype(np.int64)
key = (rgb[..., 0] << 16) | (rgb[..., 1] << 8) | rgb[..., 2]
_, inv, cnt = np.unique(key.ravel(), return_inverse=True, return_counts=True)
is_fill = (cnt / key.size >= KENNEY_FILL_MIN)[inv.reshape(key.shape)]
lum = rgb.mean(-1)
fill_lum = lum[is_fill].mean() if is_fill.any() else lum.mean()
contrast = np.where(is_fill, 0.0, np.abs(lum - fill_lum))
top = contrast.max()
depth = contrast / top if top > 0 else contrast # 0 surface .. 1 deepest line
def blur(a):
# Separable Gaussian with WRAP (the texture tiles). Pillow's blur rejects floats.
r = int(np.ceil(3 * KENNEY_BEVEL_PX))
k = np.exp(-0.5 * (np.arange(-r, r + 1) / KENNEY_BEVEL_PX) ** 2)
k /= k.sum()
for axis in (0, 1):
a = sum(w * np.roll(a, s, axis) for s, w in zip(range(-r, r + 1), k))
return a
# CHAMFER: each pixel sinks by the strongest nearby line, weighted by distance —
# max over r of depth(dilated by r) * (1 - r / CHAMFER), smoothstepped so the tile
# face is flat and the edge rolls off. 8-neighbour dilation (Chebyshev distance),
# wrapped because the texture tiles.
sink = depth.copy()
grown = depth.copy()
for r in range(1, KENNEY_CHAMFER_PX + 1):
grown = np.maximum.reduce([np.roll(np.roll(grown, dy, 0), dx, 1)
for dy in (-1, 0, 1) for dx in (-1, 0, 1)])
t = 1.0 - r / (KENNEY_CHAMFER_PX + 1.0)
sink = np.maximum(sink, grown * (t * t * (3.0 - 2.0 * t)))
h = np.clip(1.0 - blur(sink), 0.0, 1.0)
normal = normal_rgb8(height_to_normal_dx(h, KENNEY_GROOVE_M, 1.0, 1.0))
line = np.clip(blur((~is_fill).astype(np.float64)), 0.0, 1.0)
rough = KENNEY_ROUGH_FILL + (KENNEY_ROUGH_LINE - KENNEY_ROUGH_FILL) * line
ao = 1.0 - (1.0 - h) * (1.0 - KENNEY_AO_GROOVE)
l8 = lambda a: Image.fromarray((np.clip(a, 0, 1) * 255 + 0.5).astype(np.uint8), "L")
size = (rgb.shape[1], rgb.shape[0])
orm = pack_orm(size, l8(ao), l8(rough), None)
return h, normal, orm
def fetch_kenney(root, cache):
out = os.path.join(root, "kenney-prototype")
pack = {"id": "kenney-prototype", "name": "Kenney Prototype Textures 1.0",
"author": "Kenney (www.kenney.nl)", "license": "CC0-1.0",
"url": "https://kenney.nl/assets/prototype-textures", "textures": []}
z = zipfile.ZipFile(io.BytesIO(cached(cache, "kenney_prototype-textures.zip", KENNEY_URL)))
for n in sorted(z.namelist()):
m = re.match(r"PNG/(\w+)/texture_(\d+)\.png$", n)
if not m:
continue
colour, num = m.group(1).lower(), m.group(2)
rel = f"{colour}/texture_{num}.png"
data = z.read(n)
write(os.path.join(out, rel), data)
w, h = Image.open(io.BytesIO(data)).size
# The textures are labelled "1 x 1 meter, 1024 x 1024".
hgt, nrm, orm = kenney_derive(data)
base = f"{colour}/texture_{num}"
write(os.path.join(out, base + "_height.png"), png16(hgt))
b = io.BytesIO()
nrm.save(b, "PNG")
write(os.path.join(out, base + "_normal.png"), b.getvalue())
b = io.BytesIO()
orm.save(b, "PNG")
write(os.path.join(out, base + "_orm.png"), b.getvalue())
t = tex(f"{colour}_{num}", rel, w, h, 1.0, 1.0, normal=base + "_normal.png",
orm=base + "_orm.png", height=base + "_height.png")
t["height_m"] = KENNEY_GROOVE_M
t["normal_generated"] = "kenney-palette"
pack["textures"].append(t)
write(os.path.join(out, "LICENSE.txt"), z.read("License.txt"))
write_pack(out, pack)
def fetch_librequake(root, cache):
out = os.path.join(root, "librequake")
commit = get_json(f"https://api.github.com/repos/{LQ_REPO}/commits/main")["sha"]
tree = get_json(f"https://api.github.com/repos/{LQ_REPO}/git/trees/{commit}?recursive=1")
want = [t["path"] for t in tree["tree"] if t["type"] == "blob" and
re.match(rf"texture-wads/({'|'.join(LQ_THEMES)})/[^/]+\.png$", t["path"])]
raw = f"https://raw.githubusercontent.com/{LQ_REPO}/{commit}/"
def one(path):
theme, fname = path.split("/")[1:3]
data = cached(cache, f"lq_{commit[:12]}_{theme}_{fname}", raw + urllib.request.quote(path))
rel = f"{theme}/{fname}"
write(os.path.join(out, rel), data)
w, h = Image.open(io.BytesIO(data)).size
t = tex(f"{theme}/{fname[:-4]}", rel, w, h, w * QUAKE_M_PER_PX, h * QUAKE_M_PER_PX)
# Quake name conventions, kept for the loader: + animated, { alpha-tested,
# _fbr has fullbright (palette 224-255) pixels — not a separate glow map.
t["filter"] = "nearest"
return t
pack = {"id": "librequake", "name": "LibreQuake textures", "author":
"Contributors to the LibreQuake project", "license": "BSD-3-Clause",
"url": f"https://github.com/{LQ_REPO}/tree/{commit}/texture-wads", "textures": []}
with concurrent.futures.ThreadPoolExecutor(8) as ex:
pack["textures"] = list(ex.map(one, want))
# Normals: the textures are albedo-only. Sequential — onnxruntime already uses all cores.
import contextlib
for i, t in enumerate(pack["textures"]):
with open(os.path.join(out, t["albedo"]), "rb") as f:
src = f.read()
with contextlib.redirect_stdout(io.StringIO()): # DeepBump prints per call
nb = deepbump_normal_png(src, cache)
rel = t["albedo"][:-4] + "_normal.png"
write(os.path.join(out, rel), nb)
t["normal"] = rel
t["normal_generated"] = f"deepbump@{DEEPBUMP_COMMIT}"
if (i + 1) % 100 == 0:
print(f"librequake: normals {i + 1}/{len(pack['textures'])}", file=sys.stderr)
for doc, dst in (("docs/COPYING", "COPYING"), ("docs/CREDITS", "CREDITS"),
("docs/README-IMPORTANT-LICENCE-INFO", "README-IMPORTANT-LICENCE-INFO")):
write(os.path.join(out, dst), get(raw + doc))
write_pack(out, pack)
def check(root):
"""Regenerate every shipped normal that has a height map beside it; correlate."""
rows, bad = 0, []
for pack in sorted(os.listdir(root)):
pj = os.path.join(root, pack, "pack.json")
if not os.path.exists(pj):
continue
out = os.path.dirname(pj)
for t in json.load(open(pj))["textures"]:
if not t.get("height") or not t.get("normal") or t.get("normal_generated"):
continue
h = load_height(os.path.join(out, t["height"]))
ship = load_normal_dx(os.path.join(out, t["normal"]), h.shape)
hm = t.get("height_m", DEFAULT_HEIGHT_M)
gen = height_to_normal_dx(h, hm, t["tile_u_m"], t["tile_v_m"])
cr = np.corrcoef(gen[..., 0].ravel(), ship[..., 0].ravel())[0, 1]
cg = np.corrcoef(gen[..., 1].ravel(), ship[..., 1].ravel())[0, 1]
sn = ship / np.maximum(np.linalg.norm(ship, axis=-1, keepdims=True), 1e-6)
ang = np.degrees(np.arccos(np.clip((gen * sn).sum(-1), -1, 1))).mean()
print(f"{pack}/{t['name']:24s} height_m {hm:.4f} R {cr:+.2f} G {cg:+.2f} "
f"mean angle {ang:5.1f} deg")
rows += 1
if min(cr, cg) <= CHECK_MIN_CORR:
bad.append(t["name"])
print(f"check: {rows} textures, {len(bad)} below {CHECK_MIN_CORR}: {bad}", file=sys.stderr)
return 1 if bad or rows == 0 else 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="texture_packs")
ap.add_argument("--cache", default="")
ap.add_argument("--only", default="")
ap.add_argument("--check", action="store_true")
a = ap.parse_args()
if a.check:
sys.exit(check(a.out))
for name, fn in (("ambientcg", fetch_ambientcg), ("polyhaven", fetch_polyhaven),
("kenney-prototype", fetch_kenney), ("librequake", fetch_librequake),
("trimsheets", fetch_trimsheets)):
if not a.only or a.only == name:
fn(a.out, a.cache)
if __name__ == "__main__":
main()