Sly 25.10.: „weich“ soll ein Textur-Verlauf am Pinselrand sein, nicht fleckig; gerade Strichkanten wirkten weiter hart (1-m-Übergang). - Format (optional, alte Karten gültig): materials_b_r8_b64 (zweites Material je Punkt) + materials_w_r8_b64 (Anteil 0–255); ohne Mischung fallen die Felder weg. V15 prüft Länge/IDs/Paarigkeit. - TerrainField.apply_material_brush_mix: innen voll, außen Smoothstep-Anteil; nur verstärken, nie abschwächen; Gras drübermalen mischt zurück. - Shader: Top-2 aus 4 Nachbarpunkten × (a, b) mit Anteilen → nur noch 2 Albedo-Fetches; Warp bleibt. - Pinsel/Live-Vorschau, Spiegeln, Symmetrie-Analyse/-Anwenden, Resize, Anheben, Undo (terrain-Snapshot) tragen die Misch-Schicht mit. - Streu-Rand (vorheriger Commit) entfernt. - Tests: test_mal_fein 30/30 (Verlauf fällt weich, nie abschwächen, zurückmischen, Kodierung), Editor-Suite unverändert grün bis auf die bekannten Umgebungs-Tests; Kette Editor → Server → Client per run_map_download_e2e 15/15 mit gemischter Karte. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
361 lines
14 KiB
GDScript
361 lines
14 KiB
GDScript
extends RefCounted
|
||
class_name TerrainField
|
||
## Höhenfeld-Mathematik des Map-Editors (M2, Karte t_9aba04f1 — Terrain-Werkzeug).
|
||
##
|
||
## Alles static + rein: arbeitet auf einem dekodierten PackedFloat32Array
|
||
## (res×res, row-major, -Z → +Z) statt auf Base64-Strings — das Terrain-Werkzeug
|
||
## dekodiert EINMAL pro Stroke und arbeitet flott darauf. Das Doc (svmap-Format)
|
||
## bleibt der Besitzer der Daten: vor einem Stroke dekodieren, nach dem Stroke
|
||
## mit encode() zurück ins Doc.
|
||
##
|
||
## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik.
|
||
|
||
enum Mode { RAISE, LOWER, SMOOTH, PAINT }
|
||
|
||
## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber
|
||
## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim
|
||
## Zeichnen, Ziel 60+ FPS). Neu-Maps werden direkt mit 129 angelegt.
|
||
const EDITOR_MAX_RESOLUTION := 129
|
||
|
||
|
||
## Höhe an Welt-X/Z auf einem bereits dekodierten Feld (bilinear).
|
||
## Gleiche Geometrie-Konvention wie SvmapDocument.terrain_height_at, aber ohne
|
||
## Base64-Neudekodierung pro Aufruf (macht den Pinsel flott).
|
||
static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
|
||
x: float, z: float) -> float:
|
||
var last := float(res - 1)
|
||
var fx := clampf((x / size_x + 0.5) * last, 0.0, last)
|
||
var fz := clampf((z / size_z + 0.5) * last, 0.0, last)
|
||
var xi := int(floor(fx))
|
||
var zi := int(floor(fz))
|
||
var tx := fx - float(xi)
|
||
var tz := fz - float(zi)
|
||
var xi1 := mini(xi + 1, res - 1)
|
||
var zi1 := mini(zi + 1, res - 1)
|
||
var h00 := heights[zi * res + xi]
|
||
var h10 := heights[zi * res + xi1]
|
||
var h01 := heights[zi1 * res + xi]
|
||
var h11 := heights[zi1 * res + xi1]
|
||
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
|
||
|
||
|
||
## Pinsel anwenden. amount:
|
||
## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer),
|
||
## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel).
|
||
## Gewicht: Kosinus-Falloff (1 in der Mitte, 0 am Rand) — weiche Hügel statt
|
||
## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde.
|
||
static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
|
||
hmin: float, hmax: float, cx: float, cz: float, radius: float,
|
||
mode: int, amount: float) -> bool:
|
||
if mode == Mode.PAINT:
|
||
# PAINT läuft über apply_material_brush (Material-Schicht, Karte
|
||
# t_603ec1a8) — Höhe bleibt unangetastet. Hier ohne Material-Argument
|
||
# aufrufbar, aber bewusst NEUTRAL: kein Pattern, keine Höhe.
|
||
return false
|
||
if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res:
|
||
return false
|
||
var cell_x := size_x / float(res - 1)
|
||
var cell_z := size_z / float(res - 1)
|
||
var r_cells_x := radius / cell_x
|
||
var r_cells_z := radius / cell_z
|
||
var gx := (cx / size_x + 0.5) * float(res - 1)
|
||
var gz := (cz / size_z + 0.5) * float(res - 1)
|
||
var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0)
|
||
var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1)
|
||
var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0)
|
||
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
|
||
if mode == Mode.SMOOTH:
|
||
return _apply_smooth(heights, res, hmin, hmax, gx, gz, r_cells_x, r_cells_z,
|
||
x0, x1, z0, z1, clampf(amount, 0.0, 1.0))
|
||
var changed := false
|
||
var z := z0
|
||
while z <= z1:
|
||
var x := x0
|
||
while x <= x1:
|
||
var dx := (float(x) - gx) * cell_x
|
||
var dz := (float(z) - gz) * cell_z
|
||
var d := sqrt(dx * dx + dz * dz)
|
||
if d >= radius:
|
||
x += 1
|
||
continue
|
||
var w := 0.5 * (1.0 + cos(PI * d / radius))
|
||
var idx := z * res + x
|
||
var h := heights[idx]
|
||
var nh := h + amount * w if mode == Mode.RAISE else h - amount * w
|
||
nh = clampf(nh, hmin, hmax)
|
||
if nh != h:
|
||
heights[idx] = nh
|
||
changed = true
|
||
x += 1
|
||
z += 1
|
||
return changed
|
||
|
||
|
||
## Material-Pinsel (Karte t_603ec1a8): setzt die Material-ID aller Zellen im
|
||
## Kreis (d < radius) auf material_id — Malen statt Formen, Höhe bleibt unangetastet.
|
||
## Kante bewusst CRISP (d < radius): Malen fühlt sich wie ein Malprogramm an,
|
||
## die Vertex-Interpolation des Meshes glättet die Grenze optisch.
|
||
## mats = dekodierte Material-Schicht (PackedByteArray, res² Einträge).
|
||
static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, size_z: float,
|
||
cx: float, cz: float, radius: float, material_id: int) -> bool:
|
||
if res < 2 or radius <= 0.0 or mats.size() < res * res:
|
||
return false
|
||
var gx := (cx / size_x + 0.5) * float(res - 1)
|
||
var gz := (cz / size_z + 0.5) * float(res - 1)
|
||
var r_cells_x := radius / (size_x / float(res - 1))
|
||
var r_cells_z := radius / (size_z / float(res - 1))
|
||
var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0)
|
||
var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1)
|
||
var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0)
|
||
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
|
||
var changed := false
|
||
var z := z0
|
||
while z <= z1:
|
||
var x := x0
|
||
while x <= x1:
|
||
var dx := (float(x) - gx) * (size_x / float(res - 1))
|
||
var dz := (float(z) - gz) * (size_z / float(res - 1))
|
||
if dx * dx + dz * dz < radius * radius:
|
||
var idx := z * res + x
|
||
if mats[idx] != material_id:
|
||
mats[idx] = material_id
|
||
changed = true
|
||
x += 1
|
||
z += 1
|
||
return changed
|
||
|
||
|
||
## Malen MIT weichem Rand (Karte 25.10., Sly: „weicher Übergang der Texturen
|
||
## am Rand des Malwerkzeugs“). Jeder Punkt kann zwei Materialien mischen:
|
||
## a = mats (Hauptschicht), b = mix_b, Anteil von b = mix_w/255. Innerhalb
|
||
## radius·(1−weichheit) wird voll gemalt, außen fällt der Anteil des neuen
|
||
## Materials weich auf 0 (Smoothstep). Ein Punkt wird nur verstärkt, nie
|
||
## abgeschwächt (Nachmalen am Rand macht ihn kräftiger, nicht schwächer).
|
||
## Mischung bleibt zweiteilig: das neue Material + das bisher stärkste andere.
|
||
static func apply_material_brush_mix(mats: PackedByteArray, mix_b: PackedByteArray,
|
||
mix_w: PackedByteArray, res: int, size_x: float, size_z: float,
|
||
cx: float, cz: float, radius: float, material_id: int, weichheit: float) -> bool:
|
||
if res < 2 or radius <= 0.0 or mats.size() < res * res \
|
||
or mix_b.size() < res * res or mix_w.size() < res * res:
|
||
return false
|
||
var soft := clampf(weichheit, 0.0, 1.0)
|
||
var inner := radius * (1.0 - soft)
|
||
var cell_x := size_x / float(res - 1)
|
||
var cell_z := size_z / float(res - 1)
|
||
var gx := (cx / size_x + 0.5) * float(res - 1)
|
||
var gz := (cz / size_z + 0.5) * float(res - 1)
|
||
var x0 := maxi(int(floor(gx - radius / cell_x)) - 1, 0)
|
||
var x1 := mini(int(ceil(gx + radius / cell_x)) + 1, res - 1)
|
||
var z0 := maxi(int(floor(gz - radius / cell_z)) - 1, 0)
|
||
var z1 := mini(int(ceil(gz + radius / cell_z)) + 1, res - 1)
|
||
var changed := false
|
||
for z in range(z0, z1 + 1):
|
||
for x in range(x0, x1 + 1):
|
||
var dx := (float(x) - gx) * cell_x
|
||
var dz := (float(z) - gz) * cell_z
|
||
var d := sqrt(dx * dx + dz * dz)
|
||
if d >= radius:
|
||
continue
|
||
var f := 1.0
|
||
if d > inner:
|
||
var t := clampf((d - inner) / maxf(radius - inner, 0.0001), 0.0, 1.0)
|
||
f = 1.0 - t * t * (3.0 - 2.0 * t)
|
||
var fb := int(roundf(f * 255.0))
|
||
if fb <= 0:
|
||
continue
|
||
var idx := z * res + x
|
||
var a := int(mats[idx])
|
||
var b := int(mix_b[idx])
|
||
var w := int(mix_w[idx])
|
||
var anteil := (255 - w if a == material_id else 0) + (w if b == material_id else 0)
|
||
if fb <= anteil:
|
||
continue
|
||
if fb >= 255:
|
||
mats[idx] = material_id
|
||
mix_b[idx] = material_id
|
||
mix_w[idx] = 0
|
||
else:
|
||
var andere := a if a != material_id else b
|
||
mats[idx] = andere
|
||
mix_b[idx] = material_id
|
||
mix_w[idx] = fb
|
||
changed = true
|
||
return changed
|
||
|
||
|
||
## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird
|
||
## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung).
|
||
static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float,
|
||
gx: float, gz: float, r_cells_x: float, r_cells_z: float,
|
||
x0: int, x1: int, z0: int, z1: int, amount: float) -> bool:
|
||
var avg := PackedFloat32Array()
|
||
avg.resize(heights.size())
|
||
var z := z0
|
||
while z <= z1:
|
||
var x := x0
|
||
while x <= x1:
|
||
var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001)
|
||
var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001)
|
||
var d := sqrt(dx * dx + dz * dz)
|
||
if d >= 1.0:
|
||
x += 1
|
||
continue
|
||
var sum := 0.0
|
||
var count := 0
|
||
var zi := maxi(z - 1, 0)
|
||
while zi <= mini(z + 1, res - 1):
|
||
var xi := maxi(x - 1, 0)
|
||
while xi <= mini(x + 1, res - 1):
|
||
sum += heights[zi * res + xi]
|
||
count += 1
|
||
xi += 1
|
||
zi += 1
|
||
avg[z * res + x] = sum / float(maxi(count, 1))
|
||
x += 1
|
||
z += 1
|
||
var changed := false
|
||
z = z0
|
||
while z <= z1:
|
||
var x := x0
|
||
while x <= x1:
|
||
var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001)
|
||
var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001)
|
||
var d := sqrt(dx * dx + dz * dz)
|
||
if d >= 1.0:
|
||
x += 1
|
||
continue
|
||
var w := 0.5 * (1.0 + cos(PI * d))
|
||
var idx := z * res + x
|
||
var nh := lerpf(heights[idx], avg[idx], clampf(amount * w, 0.0, 1.0))
|
||
nh = clampf(nh, hmin, hmax)
|
||
if nh != heights[idx]:
|
||
heights[idx] = nh
|
||
changed = true
|
||
x += 1
|
||
z += 1
|
||
return changed
|
||
|
||
|
||
## Höhenfeld → Base64-Rohdaten (svmap-Format: uint16 LE, row-major).
|
||
## Rundung halbwegs offen (round) — Fehler ≤ ½ Quantisierungsschritt.
|
||
static func encode(heights: PackedFloat32Array, hmin: float, hmax: float) -> String:
|
||
var span := maxf(hmax - hmin, 0.000001)
|
||
var bytes := PackedByteArray()
|
||
bytes.resize(heights.size() * 2)
|
||
for i in heights.size():
|
||
var v := int(round(clampf((heights[i] - hmin) / span, 0.0, 1.0) * 65535.0))
|
||
bytes.encode_u16(i * 2, v)
|
||
return Marshalls.raw_to_base64(bytes)
|
||
|
||
|
||
## Cursor-Strahl → erster Treffer auf dem Höhenfeld (ray-march + Bisektion).
|
||
## Für den Pinsel: der Kreis muss UNTER dem Mauszeiger auf dem GELÄNDE liegen,
|
||
## nicht auf der flachen Basis-Ebene. Rückgabe null = kein Treffer.
|
||
static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
|
||
origin: Vector3, dir: Vector3, max_dist: float = 0.0) -> Variant:
|
||
if res < 2 or heights.size() < res * res:
|
||
return null
|
||
if max_dist <= 0.0:
|
||
max_dist = maxf(size_x, size_z) * 3.0
|
||
var d := dir
|
||
if d.length_squared() < 0.0000001:
|
||
return null
|
||
d = d.normalized()
|
||
var step := maxf(size_x, size_z) / float(res - 1) * 0.5
|
||
step = clampf(step, 0.5, 4.0)
|
||
# Tangenten-Band (Karte t_234490cf, Teil 2 des Gerätebefunds): Ein Strahl,
|
||
# der EXAKT auf eine Hügelkuppe zielt, grast die gewölbte Oberfläche —
|
||
# f(t) bleibt minimal knapp ÜBER 0 am Kuppen-Scheitel, DANACH steigt f
|
||
# wieder, obwohl weiter hinten (verdeckt hinter dem Hügel) noch ein
|
||
# Vorzeichenwechsel an der Basis-Ebene käme. Regel „erster sichtbarer
|
||
# Oberflächen-Kontakt gewinnt": kommt f innerhalb des Bandes (½ Schritt)
|
||
# an die Oberfläche und entfernt sich der Strahl wieder, gilt die
|
||
# Oberfläche als getroffen (lokal verfeinert). Ein echter Treffer
|
||
# (f ≤ 0) gewinnt weiterhin immer.
|
||
var graze := step * 0.5
|
||
var best_t := -1.0
|
||
var best_f := INF
|
||
var t := 0.0
|
||
var prev_t := 0.0
|
||
var prev_f := INF
|
||
while t < max_dist:
|
||
t = minf(t + step, max_dist)
|
||
var f := _ray_error(heights, res, size_x, size_z, origin, d, t)
|
||
if f < best_f:
|
||
best_f = f
|
||
best_t = t
|
||
if f <= 0.0:
|
||
# Vorzeichenwechsel zwischen prev_t und t → Bisektion.
|
||
var lo := prev_t
|
||
var hi := t
|
||
for i in 18:
|
||
var mid := (lo + hi) * 0.5
|
||
if _ray_error(heights, res, size_x, size_z, origin, d, mid) <= 0.0:
|
||
hi = mid
|
||
else:
|
||
lo = mid
|
||
return origin + d * hi
|
||
# Tangenten-Erkennung (Karte t_234490cf): war der Strahl im Band an
|
||
# der Oberfläche und steigt f JETZT wieder, hat er die Oberfläche
|
||
# tangential berührt und entfernt sich — der erste sichtbare Kontakt
|
||
# war das Graze-Minimum. Ohne das fiele der Strahl über die Kuppe
|
||
# hinweg und träfe die (verdeckte) Basis-Ebene weit hinten.
|
||
if f > prev_f and best_f <= graze:
|
||
return _graze_hit(heights, res, size_x, size_z, origin, d,
|
||
best_t, best_f, step, max_dist)
|
||
prev_f = f
|
||
prev_t = t
|
||
# Band-Minimum direkt am March-Ende (kein steigender Sample mehr kam):
|
||
if best_f <= graze and best_t > 0.0:
|
||
return _graze_hit(heights, res, size_x, size_z, origin, d,
|
||
best_t, best_f, step, max_dist)
|
||
# Nichts getroffen → Basis-Ebene als Fallback (wie interaction._ground_point).
|
||
if absf(d.y) > 0.0001:
|
||
var plane_y: float = _min_h(heights)
|
||
var t_plane: float = (plane_y - origin.y) / d.y
|
||
if t_plane > 0.0:
|
||
return origin + d * t_plane
|
||
return null
|
||
|
||
|
||
## Tangenten-Treffer (Karte t_234490cf): dichteste Annäherung des Strahls an
|
||
## die Oberfläche lokal verfeinern (Ternärsuche um den besten March-Sample)
|
||
## und den Punkt auf dem Strahl zurückgeben — der x/z-Punkt liegt damit dort,
|
||
## wo der Cursor die Oberfläche sichtbar berührt, nicht meterweit dahinter.
|
||
static func _graze_hit(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
|
||
origin: Vector3, d: Vector3, best_t: float, _best_f: float,
|
||
step: float, max_dist: float) -> Vector3:
|
||
var glo := maxf(best_t - step, 0.0)
|
||
var ghi := minf(best_t + step, max_dist)
|
||
for i in 12:
|
||
var ga := glo + (ghi - glo) / 3.0
|
||
var gb := ghi - (ghi - glo) / 3.0
|
||
if _ray_error(heights, res, size_x, size_z, origin, d, ga) < _ray_error(heights, res, size_x, size_z, origin, d, gb):
|
||
ghi = gb
|
||
else:
|
||
glo = ga
|
||
return origin + d * ((glo + ghi) * 0.5)
|
||
|
||
|
||
## Minimum/Maximum des Feldes (PackedFloat32Array hat kein .min()/.max()).
|
||
static func _min_h(heights: PackedFloat32Array) -> float:
|
||
var out := INF
|
||
for v in heights:
|
||
if v < out:
|
||
out = v
|
||
return out
|
||
|
||
|
||
static func _max_h(heights: PackedFloat32Array) -> float:
|
||
var out := -INF
|
||
for v in heights:
|
||
if v > out:
|
||
out = v
|
||
return out
|
||
|
||
|
||
## f(t) < 0 → Strahl unter dem Terrain. (prev_f-Nutzung vermieden Godot-Warnung.)
|
||
static func _ray_error(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
|
||
origin: Vector3, dir: Vector3, t: float) -> float:
|
||
var p := origin + dir * t
|
||
return p.y - height_at(heights, res, size_x, size_z, p.x, p.z)
|