OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / wad_loader.gd

297 lines · gd ref:
## Quake WAD2 texture loader — decodes the named textures from a .wad into a Texture2DArray
## (one layer per material), using the Quake palette to turn 8-bit indices into RGB. Byte
## layout verified offline against base.wad (WAD2, 568 miptex lumps). Piece 1 of faithful
## Quake texturing: this is the atlas the DC shader samples via planar UV.
class_name QuakeWadLoader
extends RefCounted

# Load `names` (lowercase Quake texture names) from `wad_path`, decoded via `palette_path`
# (768-byte Quake palette). Returns { "array": Texture2DArray, "layers": {name: int},
# "dims": {name: Vector2i} }. Every layer is resized to size×size so the array is uniform,
# but `dims` keeps each texture's ORIGINAL Quake width/height — the per-face UV projection
# needs it to tile correctly (aspect is lost by the resize; e.g. the 288×64 "quake" logo).
# Missing names get a magenta placeholder layer + size×size dims so nothing breaks.
static func load(wad_path: String, palette_path: String, names: PackedStringArray, size: int = 64) -> Dictionary:
	var pal := FileAccess.get_file_as_bytes(palette_path)
	if pal.size() < 768:
		push_error("[WadLoader] palette %s too small (%d)" % [palette_path, pal.size()])
		return {}
	var wad := FileAccess.get_file_as_bytes(wad_path)
	if wad.size() < 12 or wad.decode_u8(0) != 0x57 or wad.decode_u8(1) != 0x41:  # 'W','A'
		push_error("[WadLoader] %s is not a WAD2" % wad_path)
		return {}
	var numlumps := wad.decode_s32(4)
	var diroff := wad.decode_s32(8)
	# Index miptex lumps by name (uppercase in the lump, match case-insensitively).
	var lump := {}
	for i in numlumps:
		var b := diroff + i * 32
		var filepos := wad.decode_s32(b)
		var typ := wad.decode_u8(b + 12)
		if typ != 0x44:   # TYP_MIPTEX
			continue
		var nm := ""
		for c in range(16):
			var ch := wad.decode_u8(b + 16 + c)
			if ch == 0: break
			nm += char(ch)
		lump[nm.to_lower()] = filepos

	var images: Array[Image] = []
	var layers := {}
	var dims := {}
	for raw_name in names:
		var name := String(raw_name).to_lower()
		if layers.has(name):
			continue
		var img: Image
		if lump.has(name):
			img = _decode_miptex(wad, int(lump[name]), pal)
		if img == null:
			img = _placeholder()
			dims[name] = Vector2i(size, size)   # unknown → neutral tile (no aspect skew)
		else:
			dims[name] = Vector2i(img.get_width(), img.get_height())  # ORIGINAL, pre-resize
		if img.get_width() != size or img.get_height() != size:
			img.resize(size, size, Image.INTERPOLATE_LANCZOS)
		if img.get_format() != Image.FORMAT_RGB8:
			img.convert(Image.FORMAT_RGB8)
		img.generate_mipmaps()   # the sampler asks for filter_nearest_mipmap — without this the
		                          # Texture2DArray has NO mip chain → far textures alias (sparkle)
		layers[name] = images.size()
		images.append(img)
	if images.is_empty():
		return {}
	var arr := Texture2DArray.new()
	arr.create_from_images(images)
	return {"array": arr, "layers": layers, "dims": dims}

# Decode ONE texture at its NATIVE size — no square resize, no mipmaps, no atlas.
#
# `load()` forces every layer to size×size so the Texture2DArray is uniform, which is
# right for wall textures but destroys any texture whose LAYOUT matters. A Quake sky
# is 256×128 and is really TWO 128×128 layers side by side (right = solid starfield,
# left = cloud layer with palette index 0 meaning transparent); squashed into a square
# the two halves blend into each other and the sky renders as mottled noise. The sky
# shader needs the original.
static func load_native(wad_path: String, palette_path: String, name: String) -> Image:
	var pal := FileAccess.get_file_as_bytes(palette_path)
	if pal.size() < 768:
		return null
	var wad := FileAccess.get_file_as_bytes(wad_path)
	if wad.size() < 12 or wad.decode_u8(0) != 0x57 or wad.decode_u8(1) != 0x41:
		return null
	var numlumps := wad.decode_s32(4)
	var diroff := wad.decode_s32(8)
	var want := name.to_lower()
	for i in numlumps:
		var b := diroff + i * 32
		if wad.decode_u8(b + 12) != 0x44:   # TYP_MIPTEX
			continue
		var nm := ""
		for c in range(16):
			var ch := wad.decode_u8(b + 16 + c)
			if ch == 0: break
			nm += char(ch)
		if nm.to_lower() == want:
			return _decode_miptex(wad, wad.decode_s32(b), pal)
	return null


# Header-only pass: {lowercased name: Vector2i(width, height)} for every miptex lump in
# the WAD, WITHOUT decoding pixels. Cheap (reads the 32-byte dir entries + two u32 per
# lump). The importer needs each face texture's original size to normalise its per-face
# UV projection into tile units before the atlas resize erases the aspect ratio.
static func load_dims(wad_path: String) -> Dictionary:
	var wad := FileAccess.get_file_as_bytes(wad_path)
	if wad.size() < 12 or wad.decode_u8(0) != 0x57 or wad.decode_u8(1) != 0x41:
		return {}
	var numlumps := wad.decode_s32(4)
	var diroff := wad.decode_s32(8)
	var out := {}
	for i in numlumps:
		var b := diroff + i * 32
		if wad.decode_u8(b + 12) != 0x44:   # TYP_MIPTEX
			continue
		var filepos := wad.decode_s32(b)
		var nm := ""
		for c in range(16):
			var ch := wad.decode_u8(b + 16 + c)
			if ch == 0: break
			nm += char(ch)
		var w := wad.decode_u32(filepos + 16)
		var h := wad.decode_u32(filepos + 20)
		if w > 0 and h > 0 and w <= 4096 and h <= 4096:
			out[nm.to_lower()] = Vector2i(int(w), int(h))
	return out

static func _decode_miptex(wad: PackedByteArray, fp: int, pal: PackedByteArray) -> Image:
	var w := wad.decode_u32(fp + 16)
	var h := wad.decode_u32(fp + 20)
	var mip0 := wad.decode_u32(fp + 24)   # offset to mip level 0, relative to miptex start
	if w == 0 or h == 0 or w > 4096 or h > 4096:
		return null
	var start := fp + int(mip0)
	if start + int(w * h) > wad.size():
		return null
	var out := PackedByteArray()
	out.resize(int(w * h) * 3)
	for i in int(w * h):
		var idx := wad.decode_u8(start + i)
		out[i * 3 + 0] = pal[idx * 3 + 0]
		out[i * 3 + 1] = pal[idx * 3 + 1]
		out[i * 3 + 2] = pal[idx * 3 + 2]
	return Image.create_from_data(int(w), int(h), false, Image.FORMAT_RGB8, out)

static func _placeholder() -> Image:
	var img := Image.create(4, 4, false, Image.FORMAT_RGB8)
	img.fill(Color(1, 0, 1))   # magenta = unknown texture
	return img


# Load PBR variant maps (roughness-from-gloss + emissive glow) from the hemebond/quake-textures
# build output (`<name>_gloss.jpg` / `<name>_glow.jpg`), one layer per name in the SAME order as
# `load()` so a fragment's material layer indexes all atlases consistently. Missing maps → black
# (gloss 0 → shader flips to roughness 1 = matte; glow 0 = no emission). Roughness is
# packed into the glow atlas ALPHA, so one fetch serves both. Returns
# { glow: Texture2DArray (rgb=glow, a=gloss), norm: Texture2DArray, n_*: int } or {}.
static func load_pbr(dir: String, names: PackedStringArray, size: int = 64) -> Dictionary:
	var glow_imgs: Array[Image] = []
	var norm_imgs: Array[Image] = []
	var n_gloss := 0
	var n_glow := 0
	var n_norm := 0
	for raw_name in names:
		var name := String(raw_name).to_lower()
		var g := _load_ext_image(dir.path_join(name + "_gloss.jpg"), size, Color(0, 0, 0))
		var w := _load_ext_image(dir.path_join(name + "_glow.jpg"), size, Color(0, 0, 0))
		# Normal map: GENERATE from the _bump heightmap with DarkPlaces' exact math (Z = 255*6/
		# bumpscale), NOT hemebond's baked _norm.tga. hemebond's _norm uses Z=1 → near-sideways
		# normals (median ~88°); DP's Z=382 (bumpscale 4) gives gentle relief (median ~0°). This
		# is WHY DP looks subtle — the bumpscale IS the strength, calculated, not a magic number.
		var nm := _dp_normal_from_bump(dir.path_join(name + "_bump.jpg"), 128)
		if g.found:
			n_gloss += 1
		if w.found:
			n_glow += 1
		if nm.found:
			n_norm += 1
		# Mip the PBR atlases too (same filter_nearest_mipmap sampler) — the normal atlas
		# especially: unmipped far normals are what drive the specular "roughness highlight".
		# Roughness rides in the ALPHA of the glow atlas rather than in an atlas of
		# its own. Both come from the same source resolution, so the pack is exact,
		# and the shader reads them from ONE textureGrad instead of two — under path
		# tracing that fetch happens per hit, so halving this pair is worth more than
		# the layer of RGB it costs. (The normal atlas stays separate: it is
		# generated at 128 while these are `size`, so packing it would force a
		# resolution change.)
		var packed := _pack_glow_rough(w.img, g.img)
		packed.generate_mipmaps()
		nm.img.generate_mipmaps()
		glow_imgs.append(packed)
		norm_imgs.append(nm.img)
	if glow_imgs.is_empty():
		return {}
	var glow := Texture2DArray.new()
	glow.create_from_images(glow_imgs)
	var norm := Texture2DArray.new()
	norm.create_from_images(norm_imgs)
	return {"glow": glow, "norm": norm,
		"n_gloss": n_gloss, "n_glow": n_glow, "n_norm": n_norm}


## RGB from the glow map, A from the gloss map's red channel. Done on the raw byte
## buffers rather than per-pixel get/set: this runs once per material at load, and
## 66 layers of get_pixel() is a visible import stall.
static func _pack_glow_rough(glow: Image, gloss: Image) -> Image:
	var w := glow.get_width()
	var h := glow.get_height()
	var gl := gloss
	if gl.get_width() != w or gl.get_height() != h:
		gl = gloss.duplicate()
		gl.resize(w, h, Image.INTERPOLATE_BILINEAR)
	if glow.get_format() != Image.FORMAT_RGB8:
		glow = glow.duplicate()
		glow.convert(Image.FORMAT_RGB8)
	if gl.get_format() != Image.FORMAT_RGB8:
		gl = gl.duplicate()
		gl.convert(Image.FORMAT_RGB8)
	var gd := glow.get_data()
	var sd := gl.get_data()
	var n := w * h
	var out := PackedByteArray()
	out.resize(n * 4)
	for i in n:
		out[i * 4 + 0] = gd[i * 3 + 0]
		out[i * 4 + 1] = gd[i * 3 + 1]
		out[i * 4 + 2] = gd[i * 3 + 2]
		out[i * 4 + 3] = sd[i * 3 + 0]
	return Image.create_from_data(w, h, false, Image.FORMAT_RGBA8, out)

# DarkPlaces' r_shadow_bumpscale_bumpmap default — the Z scale that makes normals gentle.
const BUMPSCALE := 4.0

# Generate a normal map from a _bump heightmap using DarkPlaces' exact Image_HeightmapToNormalmap
# math: n = normalize(left-right, below-above, 255*6/bumpscale); alpha = height. This is DP's own
# normal calculation — gentle by construction. Falls back to a flat normal if no _bump.
static func _dp_normal_from_bump(path: String, size: int) -> Dictionary:
	if not FileAccess.file_exists(path):
		return {"img": _flat_normal(size), "found": false}
	var bump := Image.load_from_file(path)
	if bump == null:
		return {"img": _flat_normal(size), "found": false}
	if bump.get_width() != size or bump.get_height() != size:
		bump.resize(size, size, Image.INTERPOLATE_LANCZOS)
	if bump.get_format() != Image.FORMAT_RGB8:
		bump.convert(Image.FORMAT_RGB8)
	var w := bump.get_width()
	var h := bump.get_height()
	var src := bump.get_data()                      # RGB8, w*h*3
	var p := PackedFloat32Array()
	p.resize(w * h)
	for i in w * h:
		p[i] = float(src[i * 3] + src[i * 3 + 1] + src[i * 3 + 2])   # height 0..765
	var ib := (255.0 * 6.0) / BUMPSCALE
	var out := PackedByteArray()
	out.resize(w * h * 4)
	for y in h:
		for x in w:
			var xl := (x - 1 + w) % w
			var xr := (x + 1) % w
			var ya := (y - 1 + h) % h
			var yb := (y + 1) % h
			var nx := p[y * w + xl] - p[y * w + xr]
			var ny := p[yb * w + x] - p[ya * w + x]
			var l := sqrt(nx * nx + ny * ny + ib * ib)
			if l < 1e-6:
				l = 1.0
			var idx := (y * w + x) * 4
			out[idx + 0] = int(clampf(128.0 + (nx / l) * 127.0, 0.0, 255.0))
			out[idx + 1] = int(clampf(128.0 + (ny / l) * 127.0, 0.0, 255.0))
			out[idx + 2] = int(clampf(128.0 + (ib / l) * 127.0, 0.0, 255.0))
			out[idx + 3] = int(p[y * w + x] / 3.0)
	return {"img": Image.create_from_data(w, h, false, Image.FORMAT_RGBA8, out), "found": true}

static func _flat_normal(size: int) -> Image:
	var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
	img.fill(Color(0.5, 0.5, 1.0, 1.0))
	return img

# Load an external (absolute-path) image → size×size, or a `default_color` fill if missing.
# keep_alpha → RGBA8 (preserves the normal map's height alpha); else RGB8.
static func _load_ext_image(path: String, size: int, default_color: Color, keep_alpha: bool = false) -> Dictionary:
	var fmt := Image.FORMAT_RGBA8 if keep_alpha else Image.FORMAT_RGB8
	var img: Image = null
	var found := false
	if FileAccess.file_exists(path):
		img = Image.load_from_file(path)
		found = img != null
	if img == null:
		img = Image.create(size, size, false, fmt)
		img.fill(default_color)
	if img.get_width() != size or img.get_height() != size:
		img.resize(size, size, Image.INTERPOLATE_LANCZOS)
	if img.get_format() != fmt:
		img.convert(fmt)
	return {"img": img, "found": found}