OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / texture_packs.gd

247 lines · gd ref:
## 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