class_name TerrainMaterial
extends RefCounted
## THE TEXTURED TERRAIN'S LOOK, as data + a panel (user 2026-10-06: "terrain textures /
## spom parameters / uv scale / transforms exposed for geiss terrain"; option (a): an
## in-game BuildDock panel).
##
## One Dictionary (cfg) describes terrain_layers.gdshader's inputs: per mask layer the
## texture-pack set, tile size, parallax relief, UV rotation and offset; globally the
## tint / normal strength, blend softness, parallax scale and fade. apply() pushes it into
## the ShaderMaterial — rebuilding the Texture2DArray atlases ONLY when the sets changed
## (TexturePacks.build reads and resizes images: a hitch, so never on a slider drag).
##
## Persistence is the caller's: `save` is called (debounced by the caller's timer) with
## the whole cfg; the map stores it under a durable, map-scoped GameState key and feeds
## remote changes back through apply(). Kept out of here so a gate drives it headless.
const LAYERS := 5
const DEFAULT_TILE_M := 20.0
const MASK_NAMES := ["Sand (low)", "Ochre", "Olive", "Slate (high)", "Sediment (flat)"]
var mat: ShaderMaterial
var res: int
var cfg: Dictionary = {}
## Called with cfg after a UI change (the map debounces and persists).
var save: Callable = Callable()
## The map's authored texture sets — what "Reset to defaults" returns to.
var base_sets: Array = []
var _built_sets: PackedStringArray = PackedStringArray()
var _ui_root: Control = null
var _ui_lock := false
func _init(material: ShaderMaterial, atlas_res: int) -> void:
mat = material
res = atlas_res
## The look a set list starts with: each set's own tile / relief from its pack.json.
static func defaults(sets: Array) -> Dictionary:
var layers: Array = []
for i in LAYERS:
var id: String = String(sets[i]) if i < sets.size() else TexturePacks.default_id()
var e := TexturePacks.entry(id)
# 20 m tiles by default (user 2026-10-06), not the pack's own ~2 m: at 2 m the
# repeat read as wallpaper across the terraces.
layers.append({"set": id, "tile": DEFAULT_TILE_M, "height": float(e.get("height_m", 0.02)),
"rot": 0.0, "off": [0.0, 0.0]})
return {"layers": layers, "tint": 0.35, "normal": 1.0, "blend": 0.25,
"pom_scale": 1.0, "pom_fade_start": 8.0, "pom_fade_end": 14.0,
"macro": 0.2, "far_scale": 6.0, "far_blend": 0.5, "far_start": 10.0, "far_end": 60.0}
## cfg := defaults overridden by `over` (a saved / remote cfg; unknown keys ignored).
static func merged(base: Dictionary, over: Variant) -> Dictionary:
var out: Dictionary = base.duplicate(true)
if not (over is Dictionary):
return out
for k: String in out.keys():
if k == "layers" or not (over as Dictionary).has(k):
continue
out[k] = float(over[k])
var ol: Variant = (over as Dictionary).get("layers")
if ol is Array:
for i in mini(LAYERS, (ol as Array).size()):
var src: Variant = ol[i]
if not (src is Dictionary):
continue
var dst: Dictionary = out["layers"][i]
if (src as Dictionary).has("set") and TexturePacks.has(String(src["set"])):
dst["set"] = String(src["set"])
for f: String in ["tile", "height", "rot"]:
if (src as Dictionary).has(f):
dst[f] = float(src[f])
if (src as Dictionary).get("off") is Array and (src["off"] as Array).size() == 2:
dst["off"] = [float(src["off"][0]), float(src["off"][1])]
return out
## Push cfg into the material. Returns "" or why the textures did not load (the caller
## then falls back to the plain rock shading, loudly).
func apply(new_cfg: Dictionary) -> String:
cfg = new_cfg
var sets := PackedStringArray()
for l: Dictionary in cfg["layers"]:
sets.append(String(l["set"]))
if sets != _built_sets:
var packs := TexturePacks.build(sets, res)
var info: Dictionary = packs.get("info", {})
var idx := PackedInt32Array()
for id in sets:
if not info.has(id):
return "texture set %s did not load" % id
idx.append(int(info[id]["layer"]))
mat.set_shader_parameter(&"tex_atlas", packs["albedo"])
mat.set_shader_parameter(&"norm_atlas", packs["norm"])
mat.set_shader_parameter(&"orm_atlas", packs["orm"])
mat.set_shader_parameter(&"layer_index", idx)
_built_sets = sets
var tile := PackedFloat32Array()
var relief := PackedFloat32Array()
var rot := PackedFloat32Array()
var off := PackedVector2Array()
for l: Dictionary in cfg["layers"]:
tile.append(maxf(float(l["tile"]), 0.05))
relief.append(maxf(float(l["height"]), 0.0))
rot.append(float(l["rot"]))
off.append(Vector2(float(l["off"][0]), float(l["off"][1])))
mat.set_shader_parameter(&"tile_m", tile)
mat.set_shader_parameter(&"height_m", relief)
mat.set_shader_parameter(&"uv_rot_deg", rot)
mat.set_shader_parameter(&"uv_off", off)
mat.set_shader_parameter(&"tint_strength", float(cfg["tint"]))
mat.set_shader_parameter(&"normal_strength", float(cfg["normal"]))
mat.set_shader_parameter(&"blend_depth", float(cfg["blend"]))
mat.set_shader_parameter(&"pom_scale", float(cfg["pom_scale"]))
mat.set_shader_parameter(&"pom_fade_start", float(cfg["pom_fade_start"]))
mat.set_shader_parameter(&"pom_fade_end", maxf(float(cfg["pom_fade_end"]), float(cfg["pom_fade_start"]) + 0.1))
# Anti-tiling (2026-10-06): macro brightness wash; a far_scale x larger tiling blended
# in between far_start and far_end.
mat.set_shader_parameter(&"macro_strength", float(cfg["macro"]))
mat.set_shader_parameter(&"far_scale", maxf(float(cfg["far_scale"]), 1.0))
mat.set_shader_parameter(&"far_blend", float(cfg["far_blend"]))
mat.set_shader_parameter(&"far_start", float(cfg["far_start"]))
mat.set_shader_parameter(&"far_end", maxf(float(cfg["far_end"]), float(cfg["far_start"]) + 0.1))
if _ui_root != null and is_instance_valid(_ui_root) and not _ui_lock:
_refresh_ui()
return ""
# ---- the panel ----------------------------------------------------------------------
## The BuildDock body. Every control writes cfg, apply()s it and calls `save`.
func build_ui() -> Control:
var root := VBoxContainer.new()
root.name = "TerrainMaterial"
_ui_root = root
_refresh_ui()
return root
func _refresh_ui() -> void:
for c in _ui_root.get_children():
_ui_root.remove_child(c)
c.queue_free()
var g := GridContainer.new()
g.columns = 2
_ui_root.add_child(g)
_num(g, "Parallax depth ×", cfg, "pom_scale", 0.0, 4.0, 0.05)
_num(g, "Parallax fade from (m)", cfg, "pom_fade_start", 0.0, 100.0, 0.5)
_num(g, "Parallax fade to (m)", cfg, "pom_fade_end", 0.1, 120.0, 0.5)
_num(g, "Normal strength", cfg, "normal", 0.0, 2.0, 0.05)
_num(g, "Colour tint", cfg, "tint", 0.0, 1.0, 0.05)
_num(g, "Blend softness", cfg, "blend", 0.01, 1.0, 0.01)
_num(g, "Macro variation", cfg, "macro", 0.0, 0.5, 0.01)
_num(g, "Far tiling ×", cfg, "far_scale", 1.0, 16.0, 0.5)
_num(g, "Far tiling mix", cfg, "far_blend", 0.0, 1.0, 0.05)
_num(g, "Far tiling from (m)", cfg, "far_start", 0.0, 200.0, 1.0)
_num(g, "Far tiling to (m)", cfg, "far_end", 1.0, 400.0, 1.0)
var all := TexturePacks.ids()
for i in LAYERS:
var l: Dictionary = cfg["layers"][i]
var head := Label.new()
head.text = "%d. %s" % [i + 1, MASK_NAMES[i]]
_ui_root.add_child(head)
var opt := OptionButton.new()
opt.fit_to_longest_item = false
for j in all.size():
opt.add_item(all[j], j)
if all[j] == String(l["set"]):
opt.select(j)
opt.item_selected.connect(func(j: int) -> void: set_value(l, "set", all[j]))
_ui_root.add_child(opt)
var lg := GridContainer.new()
lg.columns = 2
_ui_root.add_child(lg)
_num(lg, "Tile (m)", l, "tile", 0.1, 64.0, 0.1)
_num(lg, "Relief (m)", l, "height", 0.0, 0.5, 0.005)
_num(lg, "Rotation (°)", l, "rot", -180.0, 180.0, 1.0)
_num_off(lg, "Offset U", l, 0)
_num_off(lg, "Offset V", l, 1)
var reset := Button.new()
reset.text = "Reset to defaults"
reset.pressed.connect(func() -> void:
# The MAP'S own sets (base_sets), not the ones currently picked: building the
# defaults from the current sets kept a texture change through a reset
# (user 2026-10-06: "the revert doesnt revert everything").
var sets: Array = base_sets.duplicate()
if sets.is_empty():
for ll: Dictionary in cfg["layers"]:
sets.append(ll["set"])
var why := apply(defaults(sets)) # unlocked: every value changed, redraw
if why != "":
push_warning("[TerrainMaterial] %s" % why)
if save.is_valid():
save.call(cfg))
_ui_root.add_child(reset)
func _num(parent: Control, label: String, d: Dictionary, key: String, lo: float, hi: float, step: float) -> void:
var l := Label.new()
l.text = label
parent.add_child(l)
var s := SpinBox.new()
s.min_value = lo
s.max_value = hi
s.step = step
s.value = float(d[key])
s.value_changed.connect(func(v: float) -> void: set_value(d, key, v))
parent.add_child(s)
func _num_off(parent: Control, label: String, l: Dictionary, axis: int) -> void:
var lab := Label.new()
lab.text = label
parent.add_child(lab)
var s := SpinBox.new()
s.min_value = -1.0
s.max_value = 1.0
s.step = 0.01
s.value = float(l["off"][axis])
s.value_changed.connect(func(v: float) -> void:
var o: Array = (l["off"] as Array).duplicate()
o[axis] = v
set_value(l, "off", o))
parent.add_child(s)
func set_value(d: Dictionary, key: String, v: Variant) -> void:
d[key] = v
_changed(cfg)
func _changed(new_cfg: Dictionary) -> void:
_ui_lock = true # don't rebuild the panel under the user's cursor
var why := apply(new_cfg)
_ui_lock = false
if why != "":
push_warning("[TerrainMaterial] %s" % why)
if save.is_valid():
save.call(cfg)