Karte t_5c94d098 Terrain-Blending v2: Splat-Texel-Dichte folgt der KARTENGROESSE

Gleiche Ursache wie im Client (BEWEISE.md in work/terrain_blending_v2_t_5c94d098_bilder/):
splat_side_for(129) = 512 px = 2 m/Texel auf der 1024-m-Standardmap (8 m/Zelle)
-> Gewichtsrampe in 2-m-Stufen, sichtbar als Baender/Stufen an der Materialgrenze.

Aenderung Editor:
- library/terrain_arrays.gd (Spiegel-Datei, byte-identisch zum Client):
  splat_side_for(res, map_size_m = 0.0) = MAX(4 Texel/Zelle, 1 m/Texel aus der
  Karten-Kantenlaenge), Zweierpotenz, geklemmt [256, 1024]; build_splat_bytes
  ohne Allokation je Texel + Schnellpfad (gleiche ID in allen vier Nachbarzellen)
  -> unveraenderte Bytes, deutlich schneller.
- src/viewport/terrain_mesh.gd: _build_splat bekommt size_x/size_z und gibt sie an
  splat_side_for; der Live-Sub-Rect-Pfad greift nur noch, wenn auch die
  Kantengroesse stimmt (Size-Aenderung ohne res-Aenderung baut die Splat neu).
- tests/test_terrain_splat.gd (Spiegel, identisch im Client): +6 Kartenmass-Checks
  (31/31).

Suiten (frischer Forgejo-Klon): test_terrain_splat 31/31, test_terrain 155/155,
test_terrain_mirror 106/106.
This commit is contained in:
Hermes (scopedvr-dev) 2026-10-22 19:37:30 +00:00
parent 04c6c67ba8
commit 1d8763b881
4 changed files with 99 additions and 32 deletions

View file

@ -31,17 +31,28 @@ class_name TerrainArrays
# 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).
# Texel-Dichte: SPLAT_TEXELS_PER_CELL Texel je Terrain-Zelle (4 × res−1) UND
# mindestens SPLAT_TARGET_M_PER_TEXEL Meter je Texel (Karte t_5c94d098 —
# Messung: 8 m/Zelle × 4 = 2 m/Texel bei der 1024-m-Standardmap ergab eine
# in 2-m-Stufen gerasterte Gewichtsrampe, sichtbar als Bänder/Stufen quer zur
# Materialgrenze), 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
const SPLAT_TARGET_M_PER_TEXEL := 1.0
## Splat-Textur-Kantengröße für ein res×res-Zellraster (Zweierpotenz).
static func splat_side_for(res: int) -> int:
## map_size_m = Kantenlänge der Map in Weltmetern (optional, 0 = nicht gegeben):
## die Karten-GRÖSSE begrenzt die erreichbare Weichheit, nicht die Zellzahl —
## dieselbe Zellzahl liefert auf einer großen Map viel gröbere Texel.
## Ergebnis (Budget-geklemmt, 4 MB RGBA8 bei 1024²): 1024 m → 1024 (1 m/Texel),
## 256 m → 512 (0,5 m/Texel), 4096 m → 1024 (4 m/Texel, Klemme).
static func splat_side_for(res: int, map_size_m: float = 0.0) -> int:
var target := maxi(1, res - 1) * SPLAT_TEXELS_PER_CELL
if map_size_m > 0.0:
target = maxi(target, int(ceilf(map_size_m / SPLAT_TARGET_M_PER_TEXEL)))
var side := SPLAT_SIDE_MIN
while side < target:
side *= 2
@ -95,6 +106,17 @@ static func build_splat_bytes(mats: PackedByteArray, res: int, side: int,
iz1[j] = mini(j0 + 1, res - 1)
fz[j] = t - float(j0)
var ofs := 0
# Wiederverwendete Kandidaten-Slots: die Ableitung läuft bei 1024² über
# 1 Mio. Texel — Allokationen je Texel (alte Fassung: 2 Packed-Arrays +
# Array-Literale) kosteten messbar Zeit (Karte t_5c94d098).
var cids := PackedInt32Array()
var cws := PackedFloat32Array()
var mids := PackedInt32Array()
var mws := PackedFloat32Array()
cids.resize(4)
cws.resize(4)
mids.resize(4)
mws.resize(4)
for j in rh:
var wz0 := 1.0 - fz[j]
var wz1 := fz[j]
@ -103,38 +125,68 @@ static func build_splat_bytes(mats: PackedByteArray, res: int, side: int,
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]
# Die vier Nachbarzellen (bilineare Gewichte) — Reihenfolge identisch
# zur Vorversion (Referenz-Checks in tests/test_terrain_splat.gd).
cids[0] = int(mats[row0 + ix0[i]])
cids[1] = int(mats[row0 + ix1[i]])
cids[2] = int(mats[row1 + ix0[i]])
cids[3] = int(mats[row1 + ix1[i]])
cws[0] = wx0 * wz0
cws[1] = wx1 * wz0
cws[2] = wx0 * wz1
cws[3] = wx1 * wz1
var a0 := cids[0]
if a0 == cids[1] and a0 == cids[2] and a0 == cids[3]:
# SCHNELLPFAD (Loch im Material, der Regelfall): alle vier Zellen
# dieselbe ID → w0 = 1, keine Mischung. Überspringt das
# Zusammenziehen der Gewichte (innerhalb einer Materialfläche
# praktisch jeder Texel, macht die 1024²-Ableitung bezahlbar).
out.encode_u8(ofs, a0)
out.encode_u8(ofs + 1, 255)
out.encode_u8(ofs + 2, a0)
out.encode_u8(ofs + 3, 0)
ofs += 4
continue
# Duplikat-IDs zusammenziehen (Gewichte aufsummieren):
var n := 0
for c in 4:
var id := int(mats[cells[c]])
var w: float = weights[c]
var w := cws[c]
if w <= 0.0:
continue
var cid := cids[c]
var found := false
for k in ids.size():
if ids[k] == id:
ws[k] += w
for k in n:
if mids[k] == cid:
mws[k] += w
found = true
break
if not found and w > 0.0:
ids.append(id)
ws.append(w)
if not found:
mids[n] = cid
mws[n] = w
n += 1
if n == 0:
# Kann rechnerisch nicht auftreten (die 4 Gewichte summieren zu 1);
# defensiv: Texel = Layer 0 mit vollem Gewicht.
out.encode_u8(ofs, 0)
out.encode_u8(ofs + 1, 255)
out.encode_u8(ofs + 2, 0)
out.encode_u8(ofs + 3, 0)
ofs += 4
continue
# Top-2 IDs nach Gewicht (Fenster von max. 4 Kandidaten):
var id0 := ids[0]
var w0 := ws[0]
var id0 := mids[0]
var w0 := mws[0]
var id1 := id0
var w1 := 0.0
for k in range(1, ids.size()):
if ws[k] > w0:
for k in range(1, n):
if mws[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]
id0 = mids[k]
w0 = mws[k]
elif mws[k] > w1 or id1 == id0:
id1 = mids[k]
w1 = mws[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)

View file

@ -319,10 +319,11 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
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:
if splat_rect_cells.size.x > 0 and _splat_tex != null and _splat_res == res \
and _splat_side == TerrainArrays.splat_side_for(res, maxf(size_x, size_z)):
_refresh_splat_region(splat_rect_cells)
elif _splat_tex == null or _splat_res != res:
_build_splat(shm, mats, res)
else:
_build_splat(shm, mats, res, size_x, size_z)
## Höhenlinien-Uniform an die aktuelle Situation anpassen (Pref ∧ Relief ≥ 2 m).
@ -381,11 +382,14 @@ func resolution() -> int:
## (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:
## size_x/size_z (t_5c94d098): Karten-Kantenlänge in Metern → Texel-Dichte der
## Splat (1 m/Texel bei 1024 m; vorher 2 m/Texel = sichtbare Stufen).
func _build_splat(mat: ShaderMaterial, mats: PackedByteArray, res: int,
size_x: float = 0.0, size_z: float = 0.0) -> void:
if res < 2:
_splat_tex = null
return
var side := TerrainArrays.splat_side_for(res)
var side := TerrainArrays.splat_side_for(res, maxf(size_x, size_z))
var bytes := TerrainArrays.build_splat_bytes(mats, res, side)
if bytes.size() != side * side * 4:
return

View file

@ -53,6 +53,16 @@ func _test_side_for() -> void:
_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)")
# Karten-KANTENLÄNGE (Karte t_5c94d098): die Texel-Dichte hängt auch an der
# Map-Größe, nicht nur an der Zellzahl — 129 Zellen sind auf einer
# 1024-m-Map 8 m/Zelle (4 Texel/Zelle = 2 m/Texel, sichtbare Stufen), auf
# einer 256-m-Map 2 m/Zelle (4 Texel/Zelle = 0,5 m/Texel, fein genug).
_check(TerrainArrays.splat_side_for(129, 256.0) == 512, "res 129 + 256 m → 512 (0,5 m/Texel)")
_check(TerrainArrays.splat_side_for(129, 1024.0) == 1024, "res 129 + 1024 m → 1024 (1 m/Texel statt 2)")
_check(TerrainArrays.splat_side_for(129, 2048.0) == 1024, "res 129 + 2048 m → 1024 (Budget-Klemme)")
_check(TerrainArrays.splat_side_for(257, 1024.0) == 1024, "res 257 + 1024 m → 1024")
_check(TerrainArrays.splat_side_for(65, 512.0) == 512, "res 65 + 512 m → 512 (Meter-Ziel gewinnt)")
_check(TerrainArrays.splat_side_for(129, 0.0) == 512, "ohne Kartenmaß → alte res-Regel (512)")
func _test_uniform_region() -> void:

View file

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