## 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}