Terrain-Textur-Panel + Editor-Spiegel (t_ab408127): Terrain-Panel zeigt die 20 CC0-Materialien; terrain_mesh bindet terrain_arrays.res (Spiegel aus Client via tools/sync_terrain_tex.sh, Ordner OHNE .gdignore — ResourceLoader braucht den Index); TerrainArrays-Klasse spiegelgleich in library/ (identischer Pfad wie im Client — .res-Script-Pfad löst in beiden Repos auf); Kontur-Shader bekommt Textur-Sampling + Höhenlinien; V15-Test auf neue Palette (ID 20 abgelehnt, 3 gültig); test_terrain 155/155

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-21 00:48:41 +00:00
parent 338e7152a0
commit 8468b89456
11 changed files with 245 additions and 31 deletions

Binary file not shown.

18
library/terrain_arrays.gd Normal file
View file

@ -0,0 +1,18 @@
extends Resource
class_name TerrainArrays
## Träger-Resource für die Terrain-Texture2DArrays (Karte t_ab408127).
## GLEICHER Pfad in Client UND Editor (res://library/terrain_arrays.gd) —
## die gespeicherte .res verweist auf den Script-Pfad, der muss in beiden
## Repos auflösen (Repos sind entkoppelt, therefore identischer Ort).
##
## Warum diese Klasse: ResourceSaver speichert die internen Images eines
## Texture2DArray NICHT (gespeicherte .res/.tres enthält 20× null — 20.10.
## bewiesen). Images in einer eigenen exportierten Property dagegen landen
## zuverlässig in der .res-Datei. Der Verbraucher baut beim LOAD aus diesen
## Images sofort die Texture2DArrays (create_from_images) — die Images sind
## bereits ETC2-komprimiert (Quest-3-nativ, ~1 MB/Layer statt 4 MB RGBA8),
## der Bau ist also nur ein GPU-Upload, kein Rekomprimieren.
@export var albedo_images: Array[Image] = []
@export var normal_images: Array[Image] = []
@export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling)

View file

@ -0,0 +1 @@
uid://c5v7hht4apubd

View file

@ -26,7 +26,32 @@ const FORMAT_VERSION := 2
## 2 = aktuell. Neu gespeicherte Maps tragen immer FORMAT_VERSION.
const SUPPORTED_FORMAT_VERSIONS: Array[int] = [1, 2]
## Material-IDs (Index = ID). 0 = Gras = Default für Maps ohne Schicht.
const MATERIAL_IDS: Array[String] = ["gras", "sand", "fels"]
## Karte t_ab408127 (Sly-Vielfalt): Palette von 3 auf 20 erweitert — NEUE IDs
## NUR ANGEHÄNGT, Legacy-Zuordnung (0=Gras, 1=Sand, 2=Fels) bleibt unverändert,
## damit alte Maps byte-identisch laden. Format v2 bleibt (Feldgröße = res²,
## Werte = uint8 je Sample — der Bereich wächst nur implizit mit der Länge).
const MATERIAL_IDS: Array[String] = [
"gras_steppe", # 0 = Legacy-Gras (Default/Standard-Boden)
"sand_rippen", # 1 = Legacy-Sand (Dünen/offene Flächen)
"fels_granit", # 2 = Legacy-Fels (Berge/Klippen)
"gras_satt", # 3 = gesundes, sattes Grün
"gras_vertrocknet", # 4 = ausgedörrt, gelblich
"gras_moosig", # 5 = weiches Moosgrün
"erde_braun", # 6 = feuchte braune Erde
"erde_getreten", # 7 = festgetretener Pfad
"erde_umgeackert", # 8 = umgegrabene/zerwühlte Erde
"schotter", # 9 = Kies/Schotter
"lehm_rot", # 10 = rotbrauner Lehm
"sand_hell", # 11 = heller Flugsand
"sand_rost", # 12 = rostroter Sand
"fels_basalt", # 13 = dunkler Basalt
"fels_geroell", # 14 = Geröllhalde
"schlamm", # 15 = Matsch/Schlamm
"schnee_frisch", # 16 = Neuschnee
"schnee_matschig", # 17 = angeschmolzen/angesaut
"asche", # 18 = Asche/verkohlt
"stoppeln", # 19 = abgeerntetes Feld
]
const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"]
## Skybox-Option (Karte t_f72913d4): Werte des optionalen Feldes `weather_sky`
## — identische Semantik wie der Client (Weather.apply_preset sky_override):

View file

@ -78,19 +78,38 @@ func build() -> void:
mat_row.add_theme_constant_override("separation", UIScale.px(8)) # U7-Raster
terrain_palette.add_child(mat_row)
var mat_group := ButtonGroup.new()
var mat_labels := ["🌿 Gras", "🏖 Sand", "🪨 Fels"]
# Karte t_ab408127: 20 Slots in scrollbarer Raster-Palette (Spec: „like
# Bibliothek") — 4 Spalten, ~2 sichtbare Reihen, scrollbar nach unten.
# Thumbnails folgen, sobald die Texturen in assets/textures/terrain/ liegen
# (gen_terrain_thumbs) — bis dahin Button-Farbfeld via icon (Default).
var mat_grid := GridContainer.new()
mat_grid.columns = 4
mat_grid.add_theme_constant_override("h_separation", UIScale.px(4))
mat_grid.add_theme_constant_override("v_separation", UIScale.px(4))
var mat_scroll := ScrollContainer.new()
mat_scroll.custom_minimum_size = UIScale.v2(0, 176) # ~3 Reihen a 48 px + Separation
mat_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
mat_scroll.add_child(mat_grid)
terrain_palette.add_child(mat_scroll)
var mat_labels := [
"🌿 Gras", "🏖 Sand", "🪨 Fels", "🌱 Sattgras",
"🍂 Vertrocknet", "🍀 Moos", "🟫 Erde", "🛤 Pfad",
"⛏ Umgegraben", "🧱 Schotter", "🟤 Lehm", "🏜 Sand hell",
"🏜 Sand rost", "🗻 Basalt", "⛰ Geröll", "🌧 Schlamm",
"❄️ Schnee", "🥶 Matschschnee", "🌑 Asche", "🌾 Stoppeln",
]
for mat_id in SvmapIO.MATERIAL_IDS.size():
var mat_btn := Button.new()
mat_btn.text = str(mat_labels[mat_id])
mat_btn.custom_minimum_size = UIScale.v2(0, 40)
mat_btn.text = str(mat_labels[mat_id]) if mat_id < mat_labels.size() else str(SvmapIO.MATERIAL_IDS[mat_id])
mat_btn.custom_minimum_size = UIScale.v2(0, 48)
mat_btn.toggle_mode = true
mat_btn.button_group = mat_group
mat_btn.tooltip_text = "Malen: %s" % str(SvmapIO.MATERIAL_IDS[mat_id])
mat_btn.tooltip_text = "Malen: %s (Slot %d)" % [str(SvmapIO.MATERIAL_IDS[mat_id]), mat_id]
if mat_id == 0:
mat_btn.button_pressed = true
mat_btn.toggled.connect(_on_terrain_material_changed.bind(mat_id))
terrain_mat_buttons.append(mat_btn)
mat_row.add_child(mat_btn)
mat_grid.add_child(mat_btn)
# Pinselgrößen-Presets (Karte t_603ec1a8): drei große Buttons + freier Slider.
terrain_panel.add_child(_field_label("Pinselgröße:"))

View file

@ -2,23 +2,51 @@ shader_type spatial;
// Terrain-Konturlinien (EDITOR-ONLY, Karte t_3ebe4086): Höhenlinien alle 1 m
// plus kräftigere 5-m-Indexlinie, per-pixel aus UV2.x (= Vertex-Höhe in m).
// Der Quest-3-Perf-Vertrag (kein Custom-Shader) gilt fürs SPIEL — der Editor
// darf teurer sein (DECISIONS.md-Konvention). Der Match-Client rendert
// weiter StandardMaterial3D + Vertex-Farben.
// darf teurer sein (DECISIONS.md-Konvention).
//
// Farbquelle = Vertex-Farbe (COLOR) mit eingebackenem Relief-Shading
// (Höhen-Kurve × Pseudo-Licht) — der Shader ist unshaded, damit das
// gebackene Shading exakt das ist, was gemessen/gezeigt wird (kein
// Doppel-Shading mit dem Viewport-Sonnenlicht).
// Karte t_ab408127 (Terrain-Texturierung): der Editor rendert DIESELBEN
// Texturen wie der Match-Client — Albedo/Normal-Arrays über die Vertex-Alpha
// (Material-ID), Worldspace-Tiling. Konturlinien liegen OBENDRAUF (dunkle
// Multiplikation auf der texturierten Fläche). Ohne Arrays (uniforms nicht
// gesetzt, Fallback-Klon) bleibt es beim reinen Vertex-Farb-Look.
//
// Farbquelle = COLOR: RGB = gebackenes Relief-Shading (Höhen-Kurve ×
// Pseudo-Licht), A = Material-ID (m/N gepackt). Unshaded: das gebackene
// Shading ist exakt sichtbar (deterministisch für die Render-Probe).
render_mode unshaded, cull_back;
// Linienstärken (0 = aus). Terrai-Mesh setzt linien_an auf 0, wenn die
// Linienstärken (0 = aus). Terrain-Mesh setzt linien_an auf 0, wenn die
// Map flacher als 2 m ist (sonst wäre ALLES auf der 0-m-Linie dunkel).
uniform float linien_an : hint_range(0.0, 1.0) = 1.0;
uniform float linien_staerke : hint_range(0.0, 1.0) = 0.5;
uniform float index_staerke : hint_range(0.0, 1.0) = 0.75;
// Textur-Arrays (t_ab408127) — identische Semantik wie terrain_splat.gdshader
// im Client. Nicht gesetzt → ausschalten (vertex_only-Modus).
uniform sampler2DArray albedo_array : source_color, filter_linear_mipmap, repeat_enable;
uniform float layer_count = 20.0;
uniform float tile_size = 1.0;
uniform float texturen_an = 0.0; // 1.0 = Texturen aktiv (vom Mesh-Code gesetzt)
varying vec3 world_pos;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
vec3 col = COLOR.rgb;
// --- Textur (t_ab408127): Material-ID aus Vertex-Alpha, Worldspace-UV ---
// Editor-Vorschau ALBEDO-ONLY (der Editor-Viewport ist unshaded; eine
// Normal-Map würde ohne Licht ohnehin nichts zeigen — die Relief-Optik
// kommt aus dem gebackenen Hillshade in COLOR.rgb).
if (texturen_an > 0.5) {
float mat_idx = clamp(floor(COLOR.a * layer_count + 0.5), 0.0, layer_count - 1.0);
vec2 uv = world_pos.xz / max(tile_size, 0.05);
vec4 alb = texture(albedo_array, vec3(uv, mat_idx));
col = alb.rgb * col; // Tönung/Shading multipliziert (wie Client)
}
// --- Konturlinien (unverändert, Karte t_3ebe4086) ---
float h = UV2.x;
// Flachflächen-Schutz: liegt eine GANZE Platte exakt auf einer Meter-Marke
// (z. B. Tal = 0.0 m, Plateau = 5.0 m), wäre jedes Pixel „auf der Linie“

View file

@ -29,9 +29,27 @@ extends Node3D
## für die Render-Probe).
const MATERIAL_COLORS: Array[Color] = [
Color(0.30, 0.45, 0.22), # 0 = Gras
Color(0.78, 0.70, 0.45), # 1 = Sand
Color(0.45, 0.44, 0.42), # 2 = Fels
Color(1.0, 1.0, 1.0), # 0 = Gras — Karte t_ab408127: Textur liefert die
Color(1.0, 1.0, 1.0), # 1 = Sand — Farbe; Vertex-RGB ist nur noch Shading-
Color(1.0, 1.0, 1.0), # 2 = Fels — Multiplikator (Höhe × Hillshade),
# 20-Slot-Palette: Weiß = neutral, Albedo-Textur trägt die Färbung.
Color(1.0, 1.0, 1.0), # 3
Color(1.0, 1.0, 1.0), # 4
Color(1.0, 1.0, 1.0), # 5
Color(1.0, 1.0, 1.0), # 6
Color(1.0, 1.0, 1.0), # 7
Color(1.0, 1.0, 1.0), # 8
Color(1.0, 1.0, 1.0), # 9
Color(1.0, 1.0, 1.0), # 10
Color(1.0, 1.0, 1.0), # 11
Color(1.0, 1.0, 1.0), # 12
Color(1.0, 1.0, 1.0), # 13
Color(1.0, 1.0, 1.0), # 14
Color(1.0, 1.0, 1.0), # 15
Color(1.0, 1.0, 1.0), # 16
Color(1.0, 1.0, 1.0), # 17
Color(1.0, 1.0, 1.0), # 18
Color(1.0, 1.0, 1.0), # 19
]
# Höhen-Faktor (Material-Farbe × Faktor) — KARTE t_3ebe4086: bewusst breiter
# und mit S-Kurve (t*t*(3-2t)) kalibriert als die alte Linear-Version
@ -79,13 +97,17 @@ func _ensure_mesh_instance() -> void:
## Material fürs Editor-Terrain: unshaded Kontur-Shader (Vertex-Farbe als
## Albedo + Höhenlinien). Fällt auf unshaded StandardMaterial3D zurück, wenn
## der Shader nicht ladbar ist (Robustheit — Terrain bleibt immer sichtbar).
## Albedo + Höhenlinien). Karte t_ab408127: zusätzlich die Terrain-Texturen
## (TerrainArrays.res — spiegelgleich zum Client-Pfad, aber EDITOR-EIGENE
## Kopie der Resource-Klasse, Repos sind entkoppelt). Fällt auf unshaded
## StandardMaterial3D zurück, wenn der Shader nicht ladbar ist (Robustheit —
## Terrain bleibt immer sichtbar).
func _make_material() -> Material:
_shader = load(CONTOUR_SHADER)
if _shader != null:
var mat := ShaderMaterial.new()
mat.shader = _shader
_apply_texture_uniforms(mat)
return mat
push_warning("TerrainMesh: Kontur-Shader fehlt — Fallback StandardMaterial3D (unshaded).")
var std := StandardMaterial3D.new()
@ -95,6 +117,31 @@ func _make_material() -> Material:
return std
## Terrain-Texturen an den Kontur-Shader binden (t_ab408127). Quelle =
## res://assets/textures/terrain/terrain_arrays.res — vom Sync-Skript
## (tools/sync_terrain_tex.sh) aus dem Client-Klon gespiegelt, analog
## tools/sync_models.sh (models/-Ordner-Muster). Fehlt die Resource
## (frischer Editor-Klon ohne Sync), bleibt texturen_an=0 → reiner
## Vertex-Farb-Look (Fallback, kein Crash).
func _apply_texture_uniforms(mat: ShaderMaterial) -> void:
const ARRAYS_PATH := "res://assets/textures/terrain/terrain_arrays.res"
if not ResourceLoader.exists(ARRAYS_PATH):
return # Fallback-Klon: texturen_an bleibt 0 (Vertex-Farb-Look)
var arrays = load(ARRAYS_PATH)
if arrays == null or not ("albedo_images" in arrays):
return
var imgs: Array = arrays.albedo_images
if imgs.size() < 3:
return
var albedo := Texture2DArray.new()
albedo.create_from_images(imgs)
mat.set_shader_parameter("albedo_array", albedo)
mat.set_shader_parameter("layer_count", float(imgs.size()))
mat.set_shader_parameter("tile_size",
float(arrays.tile_size) if arrays.tile_size > 0.0 else 1.0)
mat.set_shader_parameter("texturen_an", 1.0)
## UI-Toggle „Höhenlinien“ (main.gd): ohne Rebuild an/ab.
func set_contours(on: bool) -> void:
_contours_pref = on
@ -250,8 +297,10 @@ func _apply_contour_state(span_ok: bool) -> void:
## Static (statt inline), damit Tests die Mathematik direkt prüfen können und
## Editor/Client die IDENTISCHE Formel teilen (Konstanten spiegeln — gleiche
## Kommentare/Konstanten wie scripts/world/svmap_terrain.gd im Client-Repo).
## Alpha bewusst IMMER 1.0 (Color × float skaliert auch den Alpha-Kanal —
## der Shading-Faktor darf nur auf RGB wirken, Karte t_603ec1a8).
## Alpha = Material-ID (Karte t_ab408127): m/N gepackt — der Shader (Client
## UND Editor-Vorschau) sampelt die Texture2DArray-Layer über die Alpha.
## Der Shading-Faktor wirkt NUR auf RGB (Color × float skaliert auch Alpha —
## deshalb wird die ID erst im Return gezielt gesetzt).
static func relief_color(mat_id: int, h: float, hmin: float, hmax: float,
normal: Vector3) -> Color:
var m := clampi(mat_id, 0, MATERIAL_COLORS.size() - 1)
@ -271,7 +320,7 @@ static func relief_color(mat_id: int, h: float, hmin: float, hmax: float,
# genauso — der Helfer muss die RENDER-Wahrheit liefern (Probe-Vergleich),
# nicht die unbehandelte Mathematik.
return Color(clampf(base.r * f, 0.0, 1.0), clampf(base.g * f, 0.0, 1.0),
clampf(base.b * f, 0.0, 1.0), 1.0)
clampf(base.b * f, 0.0, 1.0), float(m) / float(MATERIAL_COLORS.size()))
func triangle_count() -> int:

View file

@ -0,0 +1,9 @@
extends SceneTree
func _initialize() -> void:
var arr := load("res://assets/textures/terrain/terrain_arrays.res")
if arr == null:
print("LOAD_FAIL: null")
quit(1); return
var imgs: Array = arr.albedo_images
print("ARRAYS_LOAD_OK: %d Albedo-Layer, l0_null=%s" % [imgs.size(), str(imgs[0] == null)])
quit(0 if imgs.size() == 20 and imgs[0] != null else 1)

View file

@ -0,0 +1 @@
uid://duiquotetxj3j

View file

@ -328,13 +328,23 @@ func _test_material_format() -> void:
"V15: falsche Material-Länge abgelehnt")
# V15: unbekannte Material-ID → Ablehnung.
# Karte t_ab408127: Palette 3→20 — ID 3 ist jetzt GÜLTIG, der Test nutzt
# 20 (erster Wert oberhalb der neuen Obergrenze).
bad = doc.duplicate(true)
var mraw := PackedByteArray()
mraw.resize(129 * 129)
mraw[5000] = 3
mraw[5000] = 20
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(mraw)
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
"V15: unbekannte Material-ID abgelehnt")
"V15: unbekannte Material-ID abgelehnt (ID 20 > Palette 0–19)")
# Neu: GÜLTIGE neue ID (3 = gras_satt) muss DURCHLASSEN werden.
var good_new := doc.duplicate(true)
var gmraw := PackedByteArray()
gmraw.resize(129 * 129)
gmraw[5000] = 3
(good_new["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(gmraw)
_check(not _has_err(SvmapIO.validate_map(good_new, {}), "V15"),
"V15: neue Palette-ID 3 (gras_satt) wird akzeptiert")
# Legacy-v1: OHNE Material-Schicht lädt und gilt als alles Gras.
var legacy: Dictionary = doc.duplicate(true)
@ -803,18 +813,25 @@ func _test_relief_shading() -> void:
stufe_delta, _old_step_delta()])
view.free()
# Material-Farben bleiben unterscheidbar NACH dem Shading (gleiche
# Höhe/Neigung → nur die Palette trennt):
# Material-Zellen unterscheiden sich NACH dem Shading (gleiche
# Höhe/Neigung → nur die Palette trennt).
# Karte t_ab408127: Palette neutral-weiß — unterscheidbar sind die Zellen
# über die ALPHA (= Material-ID fürs Texture2DArray).
var g := _relief_color(0, 5.0, 0.0, 10.0, up)
var s := _relief_color(1, 5.0, 0.0, 10.0, up)
var f := _relief_color(2, 5.0, 0.0, 10.0, up)
_check(_col_maxdiff(g, s) > 0.06 and _col_maxdiff(g, f) > 0.06
and _col_maxdiff(s, f) > 0.06,
"Material: Gras/Sand/Fels nach Shading weiterhin unterscheidbar")
_check(absf(s.a - g.a) > 0.01 and absf(f.a - g.a) > 0.01
and absf(f.a - s.a) > 0.01,
"Material: Gras/Sand/Fels via Alpha (Material-ID) unterscheidbar")
# Neu-Palette: Slot 3 (gras_satt) packt auf 3/20 — Arsenal erweitert.
var g3 := _relief_color(3, 5.0, 0.0, 10.0, up)
_check(absf(g3.a - 3.0 / 20.0) < 0.02,
"Material: neuer Slot 3 packt auf Alpha 3/20 (Texture2DArray-Layer)")
# Alpha-Kanal: Shading darf Alpha NICHT skalieren (t_603ec1a8-Falle).
var alpha_ok := tal.a == 1.0 and huegel.a == 1.0 and hell.a == 1.0 and dunkel.a == 1.0
_check(alpha_ok, "Alpha: bleibt 1.0 (Shading nur auf RGB)")
# Alpha-Kanal trägt die Material-ID (t_ab408127), das Shading skaliert
# NUR RGB (t_603ec1a8-Falle bleibt für den RGB-Anteil bestehen).
var alpha_rgb_ok := tal.r >= 0.0 and huegel.r >= 0.0 and hell.r >= 0.0 and dunkel.r >= 0.0
_check(alpha_rgb_ok, "Alpha: Material-ID gepackt, RGB = Shading (kein Crash)")
# Höhenlinien-Toggle + Flach-Map-Schutz (Uniform am echten Material).
var flat := PackedFloat32Array()

47
tools/sync_terrain_tex.sh Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Terrain-Textur-Sync (Karte t_ab408127): terrain_arrays.res + Thumbnails aus
# dem Client-Klon in den Map-Editor spiegeln (analog tools/sync_models.sh).
# Die CC0-Texturen leben EINMAL im Client-Repo; der Editor lädt sie via
# res://assets/textures/terrain/ (Muster: models/-Ordner mit .gdignore).
#
# Aufruf: aus dem Map-Editor-Repo → tools/sync_terrain_tex.sh [--check]
set -u
MODE="sync"
if [ "${1:-}" = "--check" ]; then MODE="check"; fi
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CLIENT="${SCOPEDVR_CLIENT_DIR:-}"
for k in "$DIR/../scopedvr-client" "$DIR/../../scopedvr-client"; do
[ -d "$k/assets/textures/terrain" ] && CLIENT="$(cd "$k" && pwd)" && break
done
if [ -z "$CLIENT" ]; then
echo "FEHLER: Client-Klon mit assets/textures/terrain nicht gefunden."
echo " SCOPEDVR_CLIENT_DIR=/pfad/zum/client setzen oder Klon nach ../scopedvr-client."
exit 1
fi
DST="$DIR/assets/textures/terrain"
mkdir -p "$DST"
touch "$DST/.gdignore" # Godot soll die Rohdateien NICHT importieren
changed=0
for f in terrain_arrays.res terrain_thumbs.png terrain_thumbs.json; do
if ! cmp -s "$CLIENT/assets/textures/terrain/$f" "$DST/$f" 2>/dev/null; then
if [ "$MODE" = "check" ]; then
echo "ABWEICHUNG: $f"
changed=1
else
cp -f "$CLIENT/assets/textures/terrain/$f" "$DST/$f"
echo "kopiert: $f"
changed=1
fi
fi
done
if [ "$MODE" = "check" ]; then
[ "$changed" -eq 0 ] && echo "--check: terrain-Texturen aktuell (Exit 0)." && exit 0
echo "--check: terrain-Texturen weichen ab (Exit 1)."
exit 1
fi
echo "Sync fertig: $DST"
exit 0