## Texture packs (hyper-games #94 P1, docs/texturing-p1-design.md §7): the catalog of
## every texture a brush face may name, and the Texture2DArrays dc_level.gdshader
## samples.
##
## A texture id is "<pack>/<name>" — the pack dir under ROOT plus the name in its
## pack.json — so packs can be added, removed or reordered without retexturing a
## level; layers are assigned at build time, never stored.
##
## Only redistributable packs live here. id's Quake textures stay OUT of releases
## (user decision 2026-10-02): LibreQuake (BSD-3) covers the Quake look, and
## START.MAP's own WAD path remains a dev-box tool outside res://.
##
## Static, not an autoload: the catalog is read-only data, and every caller (the
## level material, later the texture browser) only needs lookups and builds.
class_name TexturePacks
extends RefCounted
const ROOT := "res://texture_packs"
## Discovery order: the default builder texture comes from the FIRST pack (§6, "the
## first texture of the first loaded pack"), and §7a makes Kenney's grid the default.
## Packs not listed here follow, sorted.
const PACK_ORDER: Array[String] = ["kenney-prototype", "ambientcg", "polyhaven", "librequake", "trimsheets", "composed"]
## Defaults for a texture that ships without the map. Flat normal; ORM = AO 1,
## roughness 0.8, metal 0.
const FLAT_NORMAL := Color8(128, 128, 255)
const PLAIN_ORM := Color8(255, 204, 0)
static var _catalog: Dictionary = {} # id -> entry (pack.json record + "dir", "pack")
static var _order: PackedStringArray = []
## id -> entry. Built once; pack.json files do not change while the game runs.
static func catalog() -> Dictionary:
if _order.is_empty():
_scan()
return _catalog
static func entry(id: String) -> Dictionary:
return catalog().get(id, {})
static func has(id: String) -> bool:
return catalog().has(id)
## Every id, packs in PACK_ORDER, textures in pack.json order.
static func ids() -> PackedStringArray:
catalog()
return _order
## The texture a new brush gets before the player picks one.
static func default_id() -> String:
var all := ids()
return all[0] if not all.is_empty() else ""
static func _scan() -> void:
_catalog = {}
_order = PackedStringArray()
var packs: Array[String] = []
for p in PACK_ORDER:
packs.append(p)
var rest: Array[String] = []
for d in DirAccess.get_directories_at(ROOT):
if not packs.has(d):
rest.append(d)
rest.sort()
packs.append_array(rest)
for p in packs:
var path := ROOT.path_join(p).path_join("pack.json")
if not FileAccess.file_exists(path):
continue
var data: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if not (data is Dictionary):
push_warning("[TexturePacks] %s is not a JSON object — pack skipped" % path)
continue
for t: Dictionary in (data as Dictionary).get("textures", []):
var id := "%s/%s" % [p, String(t.get("name", ""))]
var e := t.duplicate()
e["pack"] = p
e["dir"] = ROOT.path_join(p)
_catalog[id] = e
_order.append(id)
## Build the three arrays for `wanted` at res x res. Layer 0 is the "missing"
## checker; known ids follow in the order given (so a caller that only APPENDS keeps
## every earlier id on the same layer). Returns
## { albedo, norm, orm: Texture2DArray, info: {id: {layer, tile_u, tile_v}},
## missing_layer: 0, loaded: PackedStringArray, unknown: PackedStringArray }
## `info` is exactly what DCLevel.set_texture_info takes.
static func build(wanted: PackedStringArray, res: int) -> Dictionary:
var out := build_images(wanted, res)
for key: String in ["albedo", "norm", "orm"]:
var arr := Texture2DArray.new()
arr.create_from_images(out[key])
out[key] = arr
return out
## build() before the arrays: the same dictionary with Array[Image] per map. Kept
## separate because a Texture2DArray cannot be read back headless (the dummy
## renderer keeps no texture data), so this is what a gate inspects pixel by pixel.
static func build_images(wanted: PackedStringArray, res: int) -> Dictionary:
var alb: Array[Image] = [_checker(res)]
var nrm: Array[Image] = [_fill(res, FLAT_NORMAL)]
var orm: Array[Image] = [_fit_orm(null, null, res, false)] # RGBA8 like every ORM layer
var info := {}
var loaded := PackedStringArray()
var unknown := PackedStringArray()
for id in wanted:
if info.has(id) or unknown.has(id):
continue
var e := entry(id)
var a: Image = _load(e, "albedo") if not e.is_empty() else null
if a == null:
if not e.is_empty():
push_warning("[TexturePacks] %s: albedo did not load" % id)
unknown.append(id)
continue
var nearest := String(e.get("filter", "")) == "nearest"
alb.append(_fit(a, res, nearest))
nrm.append(_fit(_load(e, "normal"), res, nearest, FLAT_NORMAL))
orm.append(_fit_orm(_load(e, "orm"), _load(e, "height"), res, nearest))
info[id] = {"layer": alb.size() - 1,
"tile_u": float(e.get("tile_u_m", 1.0)), "tile_v": float(e.get("tile_v_m", 1.0)),
# Relief depth for the Parallax tier; 0 = flat (no height map shipped).
"height_m": float(e.get("height_m", 0.0)) if String(e.get("height", "")) != "" else 0.0}
# TRIM / HOTSPOT sheets (#94 P2): the rects in the albedo's own pixels + its size,
# so DCLevel can fit HOTSPOT faces (dc_face_tex_row_hotspot).
if e.get("rects") is Array and not (e["rects"] as Array).is_empty():
info[id]["rects"] = e["rects"]
info[id]["px"] = e.get("size", [a.get_width(), a.get_height()])
loaded.append(id)
return {"albedo": alb, "norm": nrm, "orm": orm,
"info": info, "missing_layer": 0, "loaded": loaded, "unknown": unknown}
static var _thumbs: Dictionary = {} # id -> ImageTexture (px-sized), for the browser
## The preview if it was already made, else null — never decodes (the strip uses it
## to build buttons instantly and makes the missing ones lazily).
static func thumbnail_cached(id: String) -> Texture2D:
return _thumbs.get(id)
## A small preview for the texture strip. Built from the albedo once and cached; the
## full-size texture is released straight after, so a scrolled-through browser holds
## 64 px images, not hundreds of 1024 px uploads. null for an unknown id.
static func thumbnail(id: String, px: int = 64) -> Texture2D:
if _thumbs.has(id):
return _thumbs[id]
var e := entry(id)
var img: Image = _load(e, "albedo") if not e.is_empty() else null
if img == null:
return null
img = img.duplicate()
if img.has_mipmaps():
img.clear_mipmaps()
if img.get_format() != Image.FORMAT_RGB8:
img.convert(Image.FORMAT_RGB8)
var nearest := String(e.get("filter", "")) == "nearest"
img.resize(px, px, Image.INTERPOLATE_NEAREST if nearest else Image.INTERPOLATE_BILINEAR)
var t := ImageTexture.create_from_image(img)
_thumbs[id] = t
return t
## One map of a pack texture, or null when the pack does not ship it.
static func _load(e: Dictionary, key: String) -> Image:
var rel := String(e.get(key, ""))
if rel == "" or not e.has("dir"):
return null
var tex := load(String(e["dir"]).path_join(rel)) as Texture2D
if tex == null:
return null
var img := tex.get_image()
if img == null or img.is_empty():
return null
if img.is_compressed():
img.decompress()
return img
## res x res RGB8 with mips — create_from_images needs every layer identical in size,
## format and mip count. Pixel-art packs scale NEAREST so a 64 px texel stays square.
static func _fit(img: Image, res: int, nearest: bool, fallback: Color = Color.BLACK) -> Image:
if img == null:
return _fill(res, fallback)
img = img.duplicate()
if img.has_mipmaps():
img.clear_mipmaps()
if img.get_format() != Image.FORMAT_RGB8:
img.convert(Image.FORMAT_RGB8)
if img.get_width() != res or img.get_height() != res:
img.resize(res, res, Image.INTERPOLATE_NEAREST if nearest else Image.INTERPOLATE_LANCZOS)
img.generate_mipmaps()
return img
## The ORM layer as RGBA8: rgb = AO / roughness / metal, ALPHA = HEIGHT (white = the
## face plane) for the Parallax tier. No height map -> alpha 1, which the POM march
## reads as "no relief". RGB8 is stored as RGBA8 by the renderer anyway, so the
## alpha costs no VRAM.
static func _fit_orm(img: Image, height: Image, res: int, nearest: bool) -> Image:
var o := _fit(img, res, nearest, PLAIN_ORM)
o.clear_mipmaps()
o.convert(Image.FORMAT_RGBA8)
if height != null:
var h := height.duplicate() as Image
if h.has_mipmaps():
h.clear_mipmaps()
h.convert(Image.FORMAT_L8)
if h.get_width() != res or h.get_height() != res:
h.resize(res, res, Image.INTERPOLATE_NEAREST if nearest else Image.INTERPOLATE_LANCZOS)
var od := o.get_data()
var hd := h.get_data()
for i in res * res:
od[i * 4 + 3] = hd[i]
o.set_data(res, res, false, Image.FORMAT_RGBA8, od)
o.generate_mipmaps()
return o
static func _fill(res: int, c: Color) -> Image:
var img := Image.create(res, res, false, Image.FORMAT_RGB8)
img.fill(c)
img.generate_mipmaps()
return img
## Magenta / black, 8 x 8 cells — the "this texture is not here" read every editor uses.
static func _checker(res: int) -> Image:
var img := Image.create(res, res, false, Image.FORMAT_RGB8)
var cell := maxi(res / 8, 1)
for cy in 8:
for cx in 8:
img.fill_rect(Rect2i(cx * cell, cy * cell, cell, cell),
Color(1, 0, 1) if (cx + cy) % 2 == 0 else Color(0, 0, 0))
img.generate_mipmaps()
return img