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) # ============================ 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) 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). ## 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 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 # 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] var row0 := iz0[j] * res var row1 := iz1[j] * res for i in rw: var wx0 := 1.0 - fx[i] var wx1 := fx[i] # 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 w := cws[c] if w <= 0.0: continue var cid := cids[c] var found := false for k in n: if mids[k] == cid: mws[k] += w found = true break 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 := mids[0] var w0 := mws[0] var id1 := id0 var w1 := 0.0 for k in range(1, n): if mws[k] > w0: id1 = id0 w1 = w0 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) 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, ]