Karte t_7a0a6b79 Terrain-Qualitaet: Splat-Top-2-Blend (weiche Uebergaenge), Live-Sub-Rect beim Malen, Ausbrennen-Fix (Shading-Normalisierung)

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-21 22:12:04 +00:00
parent f5a4ab54d0
commit 04c6c67ba8
10 changed files with 461 additions and 9 deletions

View file

View file

@ -16,3 +16,141 @@ class_name TerrainArrays
@export var albedo_images: Array[Image] = []
@export var normal_images: Array[Image] = []
@export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling)
# ============================ SPLAT-GEWICHTS-ABLEITUNG ======================
# Karte t_7a0a6b79 (weiche Übergänge): der alte Shader pickte pro Pixel GENAU
# EINE Textur-Schicht über die Vertex-Alpha (floor+0.5-Snap) — an Dreieck-
# kanten sprang die GPU-interpolierte Alpha hart zwischen Material-IDs
# (blockartige, dreieckstreppige Grenzen). NEU: aus der Zell-Schicht
# (materials_r8, res²) wird eine SPLAT-GEWICHTS-TEXTUR abgeleitet — je Texel
# die Top-2-Zell-IDs mit bilinearen Kernel-Gewichten:
# R = id0 (0..N-1), G = w0, B = id1, A = w1 (w0 + w1 = 1 normalisiert)
# Der Shader sampelt die Splat-Textur (FILTER_NEAREST — IDs dürfen NICHT
# bilinear vermischt werden; die Weichheit steckt IN den Gewichten) und mischt
# die zwei Layer: mix(tex(id1), tex(id0), w0). Datenformat UNVERÄNDERT — die
# Zell-Schicht bleibt die Quelle der Wahrheit.
#
# Texel-Dichte: SPLAT_TEXELS_PER_CELL Texel je Terrain-Zelle (4 × res−1),
# als Zweierpotenz geklemmt auf [SPLAT_SIDE_MIN, SPLAT_SIDE_MAX] — beim Malen
# kann dann ein kleiner Sub-Rect neu abgeleitet werden (Live-Pfad).
const SPLAT_TEXELS_PER_CELL := 4
const SPLAT_SIDE_MIN := 256
const SPLAT_SIDE_MAX := 1024
## Splat-Textur-Kantengröße für ein res×res-Zellraster (Zweierpotenz).
static func splat_side_for(res: int) -> int:
var target := maxi(1, res - 1) * SPLAT_TEXELS_PER_CELL
var side := SPLAT_SIDE_MIN
while side < target:
side *= 2
return mini(side, SPLAT_SIDE_MAX)
## Splat-Gewichts-Bytes (RGBA8, row-major) für die Zell-Schicht ableiten.
## rect = Texel-Rechteck (position/size im Bild) — leer = ganzes Bild.
## Bilinearer Kernel über die 4 umliegenden Zellen: an einer Zellgrenze rampen
## die Gewichte weich 0→1 (Weichheit = eine Zellbreite, wie in der Spec).
## Test-Vertrag: identische Formel in Client UND Editor (Spiegel-Datei).
static func build_splat_bytes(mats: PackedByteArray, res: int, side: int,
rect: Rect2i = Rect2i()) -> PackedByteArray:
var out := PackedByteArray()
if res < 2 or side < 1 or mats.size() < res * res:
return out
var rx := 0
var ry := 0
var rw := side
var rh := side
if rect.size.x > 0 and rect.size.y > 0:
rx = clampi(rect.position.x, 0, side - 1)
ry = clampi(rect.position.y, 0, side - 1)
rw = clampi(rect.size.x, 1, side - rx)
rh = clampi(rect.size.y, 1, side - ry)
out.resize(rw * rh * 4)
var last := float(res - 1)
# Achsen-Gewichte einmal vorrechnen (hängen nur von der Texel-Spalte/Zeile ab):
var ix0 := PackedInt32Array()
var ix1 := PackedInt32Array()
var fx := PackedFloat32Array()
ix0.resize(rw)
ix1.resize(rw)
fx.resize(rw)
for i in rw:
var t := (float(rx + i) + 0.5) / float(side) * last
var i0 := clampi(int(floorf(t)), 0, res - 1)
ix0[i] = i0
ix1[i] = mini(i0 + 1, res - 1)
fx[i] = t - float(i0)
var iz0 := PackedInt32Array()
var iz1 := PackedInt32Array()
var fz := PackedFloat32Array()
iz0.resize(rh)
iz1.resize(rh)
fz.resize(rh)
for j in rh:
var t := (float(ry + j) + 0.5) / float(side) * last
var j0 := clampi(int(floorf(t)), 0, res - 1)
iz0[j] = j0
iz1[j] = mini(j0 + 1, res - 1)
fz[j] = t - float(j0)
var ofs := 0
for j in rh:
var wz0 := 1.0 - fz[j]
var wz1 := fz[j]
var row0 := iz0[j] * res
var row1 := iz1[j] * res
for i in rw:
var wx0 := 1.0 - fx[i]
var wx1 := fx[i]
# ≤ 4 Zellen mit bilinearen Gewichten; Duplikat-IDs zusammenziehen:
var ids := PackedInt32Array()
var ws := PackedFloat32Array()
var cells := PackedInt32Array([
row0 + ix0[i], row0 + ix1[i], row1 + ix0[i], row1 + ix1[i]])
var weights := [wx0 * wz0, wx1 * wz0, wx0 * wz1, wx1 * wz1]
for c in 4:
var id := int(mats[cells[c]])
var w: float = weights[c]
var found := false
for k in ids.size():
if ids[k] == id:
ws[k] += w
found = true
break
if not found and w > 0.0:
ids.append(id)
ws.append(w)
# Top-2 IDs nach Gewicht (Fenster von max. 4 Kandidaten):
var id0 := ids[0]
var w0 := ws[0]
var id1 := id0
var w1 := 0.0
for k in range(1, ids.size()):
if ws[k] > w0:
id1 = id0
w1 = w0
id0 = ids[k]
w0 = ws[k]
elif ws[k] > w1 or id1 == id0:
id1 = ids[k]
w1 = ws[k]
# Normalisieren (w0 + w1 = 1) und 8-bit-packen:
var wsum := maxf(w0 + w1, 0.0001)
var b0 := clampi(roundi(w0 / wsum * 255.0), 0, 255)
out.encode_u8(ofs, clampi(id0, 0, 255))
out.encode_u8(ofs + 1, b0)
out.encode_u8(ofs + 2, clampi(id1, 0, 255))
out.encode_u8(ofs + 3, 255 - b0)
ofs += 4
return out
## Ein Splat-Texel dekodieren (für Tests): → [id0, w0, id1, w1] (w0 ∈ 0..1).
static func decode_splat_texel(bytes: PackedByteArray, texel_ofs: int) -> Array:
return [
int(bytes.decode_u8(texel_ofs)),
float(bytes.decode_u8(texel_ofs + 1)) / 255.0,
int(bytes.decode_u8(texel_ofs + 2)),
float(bytes.decode_u8(texel_ofs + 3)) / 255.0,
]

View file

@ -239,7 +239,11 @@ func _process(delta: float) -> void:
if _stroke_mode == TerrainField.Mode.PAINT:
var painted := _paint_step(_cursor.x, _cursor.z)
if painted:
view_builder.call("update_terrain", _heights, _mats)
# Karte t_7a0a6b79: Malen lebt im Sub-Rect — update_terrain bekommt
# das betroffene Zellrechteck (inkl. Spiegelzellen) und leitet nur
# die Splat-Region neu ab; das Mesh selbst bleibt unangetastet
# (Vertex-Farben hängen NICHT vom Material ab).
view_builder.call("update_terrain", _heights, _mats, _splat_rect)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
return
var amount: float
@ -424,6 +428,9 @@ func _height_step(x: float, z: float, amount: float) -> bool:
## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie.
func _paint_step(x: float, z: float) -> bool:
var g := _world_to_grid(x, z)
# VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit)
# — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab.
_splat_rect = _splat_region(g)
var snap := {}
if _sym_active() and _mats.size() >= _res * _res:
snap = _snap_mats(_brush_region(g))
@ -558,3 +565,27 @@ func terrain_mesh_color(id: int) -> Color:
return Color(0.45, 0.44, 0.42) # Fels
_:
return Color(0.30, 0.45, 0.22) # Gras
# ------------------------------------------------ Live-Splat-Sub-Rect (Karte
# t_7a0a6b79): Zellrechteck des letzten Mal-Schritts für das Live-Update der
# Splat-Gewichtstextur. Am Dateiende angefügt (Karten-Regel).
## Zellrect des letzten Mal-Schritts (Pinsel-Fußabdruck ∪ gespiegelte
## Schwesterzellen) — vom Stroke-Code als 3. Parameter an update_terrain
## (view_builder) weitergereicht.
var _splat_rect := Rect2i()
## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region-
## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der
## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab.
func _splat_region(g: Vector2) -> Rect2i:
var rect := _brush_region(g)
var sym := _sym_mode()
if sym == SymmetryCore.Mode.OFF:
return rect
var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _res)
var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _res)
return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y),
absi(a.x - b.x) + 1, absi(a.y - b.y) + 1))

View file

@ -192,9 +192,13 @@ func _build_wasser(data: Dictionary) -> void:
## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) —
## leer = terrain_mesh verwendet die zuletzt gesehene Schicht weiter (Cache,
## Karte t_55ed041a); nur bei NIE gesehener Schicht (Legacy-v1) gilt Gras.
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
## splat_rect_cells (optional, Karte t_7a0a6b79) = Zellrechteck des Mal-
## Schritts (inkl. Spiegelzellen) — terrain_mesh leitet nur die Splat-Region
## neu ab (Sub-Rect-Update, kein Voll-Rebuild pro Mal-Frame).
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(),
splat_rect_cells: Rect2i = Rect2i()) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats)
terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats, splat_rect_cells)
## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und

View file

@ -28,10 +28,24 @@ 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)
// Splat-Gewichtstextur (Karte t_7a0a6b79 — weiche Übergänge): je Texel die
// Top-2-Zell-IDs + bilineare Gewichte, von TerrainArrays.build_splat_bytes
// abgeleitet (Spiegel-Datei, identische Formel im Client). R=id0, G=w0,
// B=id1, A=w1 (w0+w1=1). FILTER_NEAREST: IDs dürfen NICHT bilinear vermisch
// werden — die Weichheit steckt IN den Gewichten, nicht in der Filterung.
// Ohne source_color-Hint: raw-Bytes, id = round(kanal × 255).
uniform sampler2D splat_map : filter_nearest, repeat_disable;
uniform float map_size_x = 1.0; // Terrain-Kantenlänge in Weltmetern
uniform float map_size_z = 1.0;
varying vec3 world_pos;
varying vec2 map_uv;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
// UV 0..1 über die Map (Index 0 = −X/−Z-Ecke, konventionstreu zum Mesh-Bau).
map_uv = vec2((world_pos.x + 0.5 * map_size_x) / max(map_size_x, 0.001),
(world_pos.z + 0.5 * map_size_z) / max(map_size_z, 0.001));
}
void fragment() {
@ -41,10 +55,16 @@ void fragment() {
// 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);
// Top-2-Splat-Blend (t_7a0a6b79): die Weichheit steckt in den Gewich-
// ten der Splat-Textur — an Zellgrenzen rampen die IDs weich (bilinear
// über die 4 umliegenden Zellen), im Flachbereich w0=1 (reines id0).
vec4 sp = texture(splat_map, map_uv);
float id0 = clamp(floor(sp.r * 255.0 + 0.5), 0.0, layer_count - 1.0);
float id1 = clamp(floor(sp.b * 255.0 + 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)
vec4 alb0 = texture(albedo_array, vec3(uv, id0));
vec4 alb1 = texture(albedo_array, vec3(uv, id1));
col = mix(alb1.rgb, alb0.rgb, sp.g) * col; // Tönung/Shading multipliziert (wie Client)
}
// --- Konturlinien (unverändert, Karte t_3ebe4086) ---
float h = UV2.x;

View file

@ -85,6 +85,16 @@ var _triangles := 0
var _contours_pref := true # UI-Toggle „Höhenlinien“ (Default AN)
var _shader: Shader
# --- Splat-Gewichtstextur (Karte t_7a0a6b79 — weiche Übergänge) -------------
# CPU-Image + GPU-Textur der Top-2-Splat-Gewichte (RGBA8: R=id0 G=w0 B=id1
# A=w1, Formel TerrainArrays.build_splat_bytes — Spiegel-Datei). Beim Malen
# wird NUR die betroffene Region neu abgeleitet (Sub-Rect-Update, siehe
# _refresh_splat_region) — kein Voll-Rebuild pro Mal-Frame.
var _splat_image: Image
var _splat_tex: ImageTexture
var _splat_side := 0 # Texel-Kantenlänge (Zweierpotenz)
var _splat_res := 0 # Zellraster-Auflösung, für die die Splat gilt
func _ready() -> void:
_ensure_mesh_instance()
@ -165,6 +175,9 @@ func rebuild_from_doc(data: Dictionary) -> void:
if _mi != null:
_mi.mesh = null
return
# Splat-Textur verwerfen — sie wird im update_heights frisch aus der
# (nach Undo/Load evtl. geänderten) Zell-Schicht abgeleitet.
_splat_tex = null
# Wasser (Karte t_28fdef03): Höhe merken — der Matsch-Ring (Ufer-Automatik)
# mischt sich in relief_color ein. INF = kein Wasser (Legacy unverändert).
_wasser_level = SvmapWasser.level(data)
@ -182,7 +195,8 @@ func rebuild_from_doc(data: Dictionary) -> void:
## BEHALTEN statt live auf alles-Gras zurückzufallen (Karte t_55ed041a).
## Nur wenn NIE eine Schicht gesehen wurde (Legacy-v1-Map) gilt alles Gras.
func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0,
size_z: float = 0.0, mats: PackedByteArray = PackedByteArray()) -> void:
size_z: float = 0.0, mats: PackedByteArray = PackedByteArray(),
splat_rect_cells: Rect2i = Rect2i()) -> void:
if res < 2:
res = _res
if size_x <= 0.0:
@ -296,6 +310,20 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
_mi.mesh = mesh
_triangles = indices.size() / 3
# --- Splat-Gewichtstextur (t_7a0a6b79): map_size + Sub-Rect-Update --------
# map_size_x/z in Weltmetern (Shader rechnet map_uv = Weltkoord. über Map).
# Mal-Stroke: splat_rect_cells = betroffenes Zellrechteck → NUR die Region
# neu ableiten. Voll-Rebuild (Load/Undo/Stroke-Ende): Textur frisch bauen.
var mat := _mi.material_override
if mat is ShaderMaterial:
var shm := mat as ShaderMaterial
shm.set_shader_parameter("map_size_x", size_x)
shm.set_shader_parameter("map_size_z", size_z)
if splat_rect_cells.size.x > 0 and _splat_tex != null and _splat_res == res:
_refresh_splat_region(splat_rect_cells)
elif _splat_tex == null or _splat_res != res:
_build_splat(shm, mats, res)
## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m).
func _apply_contour_state(span_ok: bool) -> void:
@ -327,7 +355,12 @@ static func relief_color(mat_id: int, h: float, hmin: float, hmax: float,
var nd := clampf(normal.normalized().dot(RELIEF_SUN.normalized()), 0.0, 1.0)
var x := clampf((nd - LIGHT_T0) / (LIGHT_T1 - LIGHT_T0), 0.0, 1.0)
var lf := lerpf(LIGHT_LOW, LIGHT_HIGH, x * x * (3.0 - 2.0 * x))
var f := clampf(hf * lf, SHADE_MIN, SHADE_MAX)
# Ausbrennen-Fix (t_7a0a6b79): der Faktor wird auf 1.0 normalisiert. Vorher
# lief hf·lf bis 1.35 und RGBA8 klemmte auf 1.0 — ALLE Relief-Unterschiede
# oberhalb 1.0 kollabierten zu flachem Weiß (Slys „ausgebrannte Weißflächen“
# auf Sand hell/Schnee). Relative Spanne bleibt identisch (0.40…1.35 →
# 0.296…1.0), Kontrast-Verhältnis unverändert, Shading bleibt sichtbar.
var f := clampf(hf * lf, SHADE_MIN, SHADE_MAX) / SHADE_MAX
var base := MATERIAL_COLORS[m] as Color
# Kanäle auf 0..1 klemmen: der Mesh-Speicher (ARRAY_COLOR, RGBA8) clamp't
# genauso — der Helfer muss die RENDER-Wahrheit liefern (Probe-Vergleich),
@ -342,3 +375,45 @@ func triangle_count() -> int:
func resolution() -> int:
return _res
## Splat-Gewichtstextur komplett ableiten (t_7a0a6b79). Die Zell-Schicht
## (mats, res²) bleibt die Quelle der Wahrheit — die Textur ist reine
## Ableitung (Datenformat unverändert). Unbrauchbare Schicht → alte Textur
## bleibt (Robustheit, kein Crash).
func _build_splat(mat: ShaderMaterial, mats: PackedByteArray, res: int) -> void:
if res < 2:
_splat_tex = null
return
var side := TerrainArrays.splat_side_for(res)
var bytes := TerrainArrays.build_splat_bytes(mats, res, side)
if bytes.size() != side * side * 4:
return
_splat_image = Image.create_from_data(side, side, false, Image.FORMAT_RGBA8, bytes)
_splat_side = side
_splat_res = res
_splat_tex = ImageTexture.create_from_image(_splat_image)
if mat != null:
mat.set_shader_parameter("splat_map", _splat_tex)
## Live-Pfad beim Malen (t_7a0a6b79): NUR die betroffene Region der Splat-
## Textur neu ableiten (dieselben Region-Grenzen wie apply_material_brush,
## Zellrechteck inkl. Spiegelzellen; hier in Texel umgerechnet). Die Gewichte
## brauchen den Kernel-Umfeld-Zugriff auf die GESAMTE Schicht (_mats_cache).
func _refresh_splat_region(cell_rect: Rect2i) -> void:
if _splat_image == null or _splat_res < 2:
return
var last := float(_splat_res - 1)
var tpc := float(_splat_side) / last # Texel je Zelle
var tx0 := clampi(int(floorf(float(cell_rect.position.x) * tpc)) - 1, 0, _splat_side - 1)
var ty0 := clampi(int(floorf(float(cell_rect.position.y) * tpc)) - 1, 0, _splat_side - 1)
var tx1 := clampi(int(ceilf(float(cell_rect.end.x) * tpc)) + 1, tx0 + 1, _splat_side)
var ty1 := clampi(int(ceilf(float(cell_rect.end.y) * tpc)) + 1, ty0 + 1, _splat_side)
var rect := Rect2i(tx0, ty0, tx1 - tx0, ty1 - ty0)
var bytes := TerrainArrays.build_splat_bytes(_mats_cache, _splat_res, _splat_side, rect)
if bytes.size() != rect.size.x * rect.size.y * 4:
return
var sub := Image.create_from_data(rect.size.x, rect.size.y, false, Image.FORMAT_RGBA8, bytes)
_splat_image.blit_rect(sub, Rect2i(0, 0, rect.size.x, rect.size.y), rect.position)
_splat_tex.update(_splat_image)

View file

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

View file

@ -84,10 +84,13 @@ class ToolBuilder:
var last_heights := PackedFloat32Array()
var last_mats := PackedByteArray()
var calls := 0
var last_splat_rect := Rect2i() # t_7a0a6b79: Live-Splat-Sub-Rect
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(),
splat_rect: Rect2i = Rect2i()) -> void:
last_heights = heights
last_mats = mats
last_splat_rect = splat_rect
calls += 1
@ -456,6 +459,17 @@ func _test_live_rebuild_inputs() -> void:
var paint_mats: PackedByteArray = builder.get("last_mats")
_check(_count_asym_mats(paint_mats, SymmetryCore.Mode.POINT) == 0,
"Live-Mal-Frame: Mesh-Input-Material bereits gespiegelt")
# Live-Splat-Sub-Rect (t_7a0a6b79): das Rechteck muss Mal-Zelle UND die
# gespiegelte Schwesterzelle abdecken (der Sub-Rect-Refresh leitet genau
# diese Region neu ab — sonst bliebe die Spiegelseite optisch alt).
var splat_rect: Rect2i = builder.get("last_splat_rect")
var painted_cell := _world_to_cell(Vector2(-100.0, -60.0))
var mirrored_cell := SymmetryCore.mirror_cell(
SymmetryCore.Mode.POINT, painted_cell.x, painted_cell.y, RES)
_check(splat_rect.size.x > 0 and splat_rect.has_point(Vector2i(painted_cell.x, painted_cell.y))
and splat_rect.has_point(Vector2i(mirrored_cell.x, mirrored_cell.y)),
"Live-Mal-Frame: Splat-Sub-Rect deckt Mal-Zelle (%d,%d) + Spiegelzelle (%d,%d)" % [
painted_cell.x, painted_cell.y, mirrored_cell.x, mirrored_cell.y])
ttool.call("_end_stroke")
_check(int(tmain.get("commits")) == 2,
"Zwei Striche → zwei Commits (Undo-Vertrag bleibt)")

169
tests/test_terrain_splat.gd Normal file
View file

@ -0,0 +1,169 @@
extends SceneTree
## Splat-Gewichts-Ableitung (Karte t_7a0a6b79 — weiche Übergänge).
## Diese Datei ist SPIEGEL-IDENTISCH in Client UND Editor
## (client/tests/test_terrain_splat.gd ↔ editor/tests/test_terrain_splat.gd):
## beide Suiten rechnen dieselben Referenzwerte gegen dieselbe
## TerrainArrays.build_splat_bytes-Formel — grün in beiden Repos beweist die
## identische Formel beider Seiten (Spiegel-Datei-Vertrag, wie terrain_arrays.gd).
## Ausführen: godot --headless --path . -s res://tests/test_terrain_splat.gd
## Exit 0 = alles grün.
##
## Referenz-Fall (exakt rechenbar, Zweierpotenz-Koordinaten):
## 4×4 Zellen, alles ID 2 außer Zelle (2,2) = 7; side = 8.
## Texel (6,6): t = 6.5/8·3 = 2.4375 (exakt) → fx = fz = 0.4375 (exakt)
## → w(id2) = 0.80859375, w(id7) = 0.19140625 → Bytes [2, 206, 7, 49].
var failures := 0
var checks := 0
func _init() -> void:
_run()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _run() -> void:
_test_side_for()
_test_uniform_region()
_test_reference_texel()
_test_boundary_ramp()
_test_sub_rect_equality()
_test_three_meeting_cells()
func _test_side_for() -> void:
print("Testgruppe: splat_side_for (Texel-Dichte, Zweierpotenz, geklemmt)")
_check(TerrainArrays.splat_side_for(2) == 256, "res 2 → 256 (Minimum)")
_check(TerrainArrays.splat_side_for(33) == 256, "res 33 → 256 (Ziel 128 < 256)")
_check(TerrainArrays.splat_side_for(65) == 256, "res 65 → 256 (Ziel 256)")
_check(TerrainArrays.splat_side_for(129) == 512, "res 129 → 512 (Ziel 512)")
_check(TerrainArrays.splat_side_for(257) == 1024, "res 257 → 1024 (Ziel 1024)")
_check(TerrainArrays.splat_side_for(513) == 1024, "res 513 → 1024 (Maximum-Klemme)")
func _test_uniform_region() -> void:
print("Testgruppe: Einmaterial-Fläche → eine ID, w0 = 1")
var mats := PackedByteArray()
mats.resize(4 * 4)
mats.fill(2)
var bytes := TerrainArrays.build_splat_bytes(mats, 4, 8)
_check(bytes.size() == 8 * 8 * 4, "Bytes: 8² Texel × 4 Kanäle")
var t := TerrainArrays.decode_splat_texel(bytes, 0)
_check(t[0] == 2 and t[1] == 1.0 and t[2] == 2 and t[3] == 0.0,
"Eck-Texel: id0=2, w0=1, id1=2 (Platzhalter), w1=0")
func _test_reference_texel() -> void:
print("Testgruppe: Referenz-Texel (exakt rechenbar, Spiegel-Vertrag)")
# VERTEX-Konvention: die Material-Schicht indiziert MESH-VERTEXE (res²,
# row-major wie die Heightmap). Texel (6,6), res 4, side 8:
# t = 6.5/8·3 = 2.4375 (exakt) → zwischen Vertex 2 und 3, fx = fz = 0.4375.
# Vertex (2,2) trägt ID 7 → w(7) = 0.5625² = 0.31640625 (exakt),
# w(2) = 0.68359375 → 8-bit: 174 + 81 = 255.
var mats := PackedByteArray()
mats.resize(4 * 4)
mats.fill(2)
mats[2 * 4 + 2] = 7
var bytes := TerrainArrays.build_splat_bytes(mats, 4, 8)
var t := TerrainArrays.decode_splat_texel(bytes, (6 * 8 + 6) * 4)
_check(t[0] == 2, "Referenz-Texel (6,6): id0 = 2 (got %d)" % t[0])
_check(t[1] > 0.67 and t[1] < 0.70, "Referenz-Texel (6,6): w0 ≈ 0.6836 (got %.4f)" % t[1])
_check(t[2] == 7, "Referenz-Texel (6,6): id1 = 7 (got %d)" % t[2])
_check(t[3] > 0.30 and t[3] < 0.33, "Referenz-Texel (6,6): w1 ≈ 0.3164 (got %.4f)" % t[3])
# 8-bit-Vertrag: w0 + w1 ≈ 1 (Bytes 174 + 81 = 255).
var raw := (bytes.decode_u8((6 * 8 + 6) * 4 + 1)
+ bytes.decode_u8((6 * 8 + 6) * 4 + 3))
_check(raw == 255, "Referenz-Texel (6,6): Gewichts-Bytes summieren auf 255 (got %d)" % raw)
func _test_boundary_ramp() -> void:
print("Testgruppe: Zellgrenze → weiche Rampe (keine Snap-Kante)")
# 5×5 VERTEXE: x < 3 → id 1, sonst id 3. Grenze zwischen Vertex 2 und 3 —
# die bilineare Rampe kreuzt 0.5 genau bei t = 2.5 (Mitte zwischen den
# Vertex-Positionen); Rampenbreite = ein Zell-Abstand (wie in der Spec).
var res := 5
var mats := PackedByteArray()
mats.resize(res * res)
for z in res:
for x in res:
mats[z * res + x] = 1 if x < 3 else 3
var side := 64
var bytes := TerrainArrays.build_splat_bytes(mats, res, side)
# Texel 39: t = 2.46875 → fx = 0.469 → w(id1) = 0.531; Texel 40: t = 2.53125
# → w(id1) = 0.469 — die dominante ID wechselt EXAKT an der Mitte.
var t39 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 39) * 4)
var t40 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 40) * 4)
_check(t39[0] == 1 and t39[2] == 3, "Grenz-Texel 39: IDs {1, 3} (got %d/%d)" % [t39[0], t39[2]])
_check(t40[0] == 3 and t40[2] == 1, "Grenz-Texel 40: IDs {3, 1} (gewechselt, got %d/%d)" % [t40[0], t40[2]])
_check(absf(t39[1] - 0.53125) < 0.004, "Grenz-Texel 39: w0 ≈ 0.531 (got %.4f)" % t39[1])
_check(absf(t40[1] - 0.53125) < 0.004, "Grenz-Texel 40: w0 ≈ 0.531 (got %.4f)" % t40[1])
_check(absf((t39[1] + t40[3]) - 1.0) < 0.01, "Grenz-Texel 39/40 spiegeln sich an t = 2.5")
# Weit weg von der Grenze: reine Seite (w0 = 1):
var t8 := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + 8) * 4)
_check(t8[0] == 1 and t8[1] > 0.999, "Flachbereich links: w0 = 1 (got %.4f)" % t8[1])
# Kein Snap: das gewichtete ID-Feld (w0·id0 + w1·id1) steigt über die
# Grenze monoton und ohne Ein-Texel-Sprung (> 0.3 wäre ein Snap-Muster).
var prev := -1.0
var ok_ramp := true
for tx in range(34, 46):
var t := TerrainArrays.decode_splat_texel(bytes, (16 * 64 + tx) * 4)
var blended: float = float(t[0]) * t[1] + float(t[2]) * t[3]
if prev >= 0.0 and (blended < prev - 0.0001 or blended - prev > 0.3):
ok_ramp = false
prev = blended
_check(ok_ramp, "Rampe über die Grenze monoton, ohne Ein-Texel-Sprung")
func _test_sub_rect_equality() -> void:
print("Testgruppe: Sub-Rect-Ableitung == Voll-Ableitung (Live-Pfad-Vertrag)")
var res := 8
var mats := PackedByteArray()
mats.resize(res * res)
for i in mats.size():
mats[i] = (i * 7 + 3) % 5 # deterministisches Muster mit 5 IDs
var side := 32
var full := TerrainArrays.build_splat_bytes(mats, res, side)
var same := true
for rect: Rect2i in [Rect2i(0, 0, 16, 8), Rect2i(8, 8, 12, 20), Rect2i(24, 0, 8, 32), Rect2i(5, 3, 7, 9)]:
var part := TerrainArrays.build_splat_bytes(mats, res, side, rect)
if part.size() != rect.size.x * rect.size.y * 4:
same = false
break
for j in rect.size.y:
for i in rect.size.x:
var fofs := ((rect.position.y + j) * side + rect.position.x + i) * 4
for c in 4:
if part[j * rect.size.x * 4 + i * 4 + c] != full[fofs + c]:
same = false
_check(same, "Sub-Rect-Bytes byte-identisch zum Voll-Bild (4 Rechtecke)")
func _test_three_meeting_cells() -> void:
print("Testgruppe: >2 IDs am Treffpunkt → Top-2-Gewichte (Spec-Vertrag)")
# 2×2-Zellen-Beispiel: Zelle (0,0)=1, (1,0)=2, (0,1)=3, (1,1)=4; res 2, side 8.
# Texel (3,3): t = 3.5/8·1 = 0.4375 → fx = fz = 0.4375.
# w: id1 0.3164, id2 0.2461, id3 0.2461, id4 0.1914 → Top-2 = {1, 2}
# (Gleichstand 2/3: kleinerer Index, deterministisch); id4 (0.1914) wird
# bewusst verworfen (Zweikant-Fälle). Normalisiert: 0.3164/0.5625 = 0.5625.
var mats := PackedByteArray([1, 2, 3, 4])
var bytes := TerrainArrays.build_splat_bytes(mats, 2, 8)
var t := TerrainArrays.decode_splat_texel(bytes, (3 * 8 + 3) * 4)
_check(t[0] == 1 and t[1] > 0.55 and t[1] < 0.58,
"Treffpunkt: id0=1, w0 ≈ 0.5625 (normalisiert, got %.4f)" % t[1])
_check(t[2] == 2, "Treffpunkt: id1 = 2 (zweitgrößter, Gleichstand → kleinerer Index, got %d)" % t[2])
_check(t[3] > 0.42 and t[3] < 0.45, "Treffpunkt: w1 ≈ 0.4375 (got %.4f)" % t[3])
_check(t[1] + t[3] > 0.99, "Treffpunkt: Top-2-Gewichte normalisiert (Summe %.4f)" % (t[1] + t[3]))